Merge branch 'main' into use-weights

This commit is contained in:
ggurdin 2024-07-25 11:44:35 -04:00 committed by GitHub
commit b1212907bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1677 additions and 1560 deletions

View file

@ -189,7 +189,7 @@ jobs:
name: web
path: build/web
- name: Set up AWS CLI
uses: aws-actions/configure-aws-credentials@v2
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View file

@ -4111,5 +4111,7 @@
"deleteSubscriptionWarningBody": "Deleting your account will not automatically cancel your subscription.",
"manageSubscription": "Manage Subscription",
"createSpace": "Create space",
"createChat": "Create chat"
"createChat": "Create chat",
"error520Title": "Please try again.",
"error520Desc": "Sorry, we could not understand your message..."
}

View file

@ -20,7 +20,6 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_e
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/models/space_model.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
@ -294,10 +293,6 @@ class ChatController extends State<ChatPageWithRoom>
}
}
// #Pangea
bool showPermissionsError = false;
// #Pangea
@override
void initState() {
scrollController.addListener(_updateScrollController);
@ -327,31 +322,13 @@ class ChatController extends State<ChatPageWithRoom>
context,
() => Future.delayed(
Duration.zero,
() => setState(
() {},
),
() => setState(() {}),
),
);
}
await Matrix.of(context).client.roomsLoading;
choreographer.setRoomId(roomId);
choreographer.messageOptions.resetSelectedDisplayLang();
choreographer.stateListener.stream.listen((event) {
debugPrint("chat.dart choreo event $event");
setState(() {});
});
showPermissionsError = !pangeaController.permissionsController
.isToolEnabled(ToolSetting.interactiveTranslator, room) ||
!pangeaController.permissionsController
.isToolEnabled(ToolSetting.interactiveGrammar, room);
});
Future.delayed(
const Duration(seconds: 5),
() {
if (mounted) setState(() => showPermissionsError = false);
},
);
// Pangea#
_tryLoadTimeline();
if (kIsWeb) {
@ -440,7 +417,7 @@ class ChatController extends State<ChatPageWithRoom>
onInsert: onInsert,
);
// #Pangea
if (visibleEvents.length < 10) {
if (visibleEvents.length < 10 && timeline != null) {
int prevNumEvents = timeline!.events.length;
await requestHistory();
int numRequests = 0;
@ -498,10 +475,6 @@ class ChatController extends State<ChatPageWithRoom>
if (kIsWeb && !Matrix.of(context).webHasFocus) return;
// #Pangea
} catch (err, s) {
ErrorHandler.logError(
e: PangeaWarningError("Web focus error: $err"),
s: s,
);
return;
}
// Pangea#
@ -602,10 +575,9 @@ class ChatController extends State<ChatPageWithRoom>
});
// #Pangea
final List<String> edittingEvents = [];
void clearEdittingEvent(String eventId) {
edittingEvents.remove(eventId);
setState(() {});
Event? pangeaEditingEvent;
void clearEditingEvent() {
pangeaEditingEvent = null;
}
// Future<void> send() async {
@ -665,11 +637,9 @@ class ChatController extends State<ChatPageWithRoom>
.then(
(String? msgEventId) async {
// #Pangea
setState(() {
if (previousEdit != null) {
edittingEvents.add(previousEdit.eventId);
}
});
if (previousEdit != null) {
pangeaEditingEvent = previousEdit;
}
GoogleAnalytics.sendMessage(
room.id,
@ -1262,9 +1232,6 @@ class ChatController extends State<ChatPageWithRoom>
void clearSelectedEvents() => setState(() {
selectedEvents.clear();
showEmojiPicker = false;
//#Pangea
choreographer.messageOptions.resetSelectedDisplayLang();
//Pangea#
});
void clearSingleSelectedEvent() {
@ -1336,19 +1303,19 @@ class ChatController extends State<ChatPageWithRoom>
// Pangea#
if (!event.redacted) {
// #Pangea
// If previous selectedEvent has same eventId, delete previous selectedEvent
final matches =
selectedEvents.where((e) => e.eventId == event.eventId).toList();
// if (selectedEvents.contains(event)) {
// setState(
// () => selectedEvents.remove(event),
// );
// }
// If delete first selected event with the selected eventID
final matches = selectedEvents.where((e) => e.eventId == event.eventId);
if (matches.isNotEmpty) {
// if (selectedEvents.contains(event)) {
// Pangea#
setState(
// #Pangea
() => selectedEvents.remove(matches.first),
// () => selectedEvents.remove(event),
// Pangea#
);
} else {
setState(() => selectedEvents.remove(matches.first));
}
// Pangea#
else {
setState(
() => selectedEvents.add(event),
);
@ -1557,35 +1524,6 @@ class ChatController extends State<ChatPageWithRoom>
});
// #Pangea
double? availableSpace;
double? inputRowSize;
bool? lastState;
bool get isRowScrollable {
if (availableSpace == null || inputRowSize == null) {
if (lastState == null) {
lastState = false;
Future.delayed(Duration.zero, () {
setState(() {});
});
}
return false;
}
const double offSetValue = 10;
final bool currentState = inputRowSize! > (availableSpace! - offSetValue);
if (!lastState! && currentState) {
Future.delayed(Duration.zero, () {
setState(() {});
});
}
if (lastState! && !currentState) {
Future.delayed(Duration.zero, () {
setState(() {});
});
}
lastState = currentState;
return currentState;
}
final Map<String, PangeaMessageEvent> _pangeaMessageEvents = {};
final Map<String, ToolbarDisplayController> _toolbarDisplayControllers = {};

View file

@ -170,8 +170,6 @@ class ChatEventList extends StatelessWidget {
controller.scrollToEventId(eventId),
longPressSelect: controller.selectedEvents.isNotEmpty,
// #Pangea
selectedDisplayLang:
controller.choreographer.messageOptions.selectedDisplayLang,
immersionMode: controller.choreographer.immersionMode,
definitions: controller.choreographer.definitionsEnabled,
controller: controller,

View file

@ -3,6 +3,7 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/widgets/send_button.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/widgets/chat/input_bar_wrapper.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -12,7 +13,6 @@ import 'package:matrix/matrix.dart';
import '../../config/themes.dart';
import 'chat.dart';
import 'input_bar.dart';
class ChatInputRow extends StatelessWidget {
final ChatController controller;
@ -322,7 +322,10 @@ class ChatInputRow extends StatelessWidget {
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0.0),
child: InputBar(
// #Pangea
// child: InputBar(
child: InputBarWrapper(
// Pangea#
room: controller.room,
minLines: 1,
maxLines: 8,

View file

@ -7,11 +7,9 @@ import 'package:fluffychat/pages/chat/chat_event_list.dart';
import 'package:fluffychat/pages/chat/pinned_events.dart';
import 'package:fluffychat/pages/chat/reactions_picker.dart';
import 'package:fluffychat/pages/chat/reply_display.dart';
import 'package:fluffychat/pangea/choreographer/widgets/has_error_button.dart';
import 'package:fluffychat/pangea/choreographer/widgets/language_permissions_warning_buttons.dart';
import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/pages/class_analytics/measure_able.dart';
import 'package:fluffychat/pangea/widgets/chat/chat_floating_action_button.dart';
import 'package:fluffychat/utils/account_config.dart';
import 'package:fluffychat/widgets/chat_settings_popup_menu.dart';
import 'package:fluffychat/widgets/connection_status_header.dart';
@ -266,32 +264,20 @@ class ChatView extends StatelessWidget {
// #Pangea
// floatingActionButton: controller.showScrollDownButton &&
// controller.selectedEvents.isEmpty
floatingActionButton: controller.selectedEvents.isEmpty
? (controller.showScrollDownButton
// Pangea#
? Padding(
padding: const EdgeInsets.only(bottom: 56.0),
child: FloatingActionButton(
onPressed: controller.scrollDown,
heroTag: null,
mini: true,
child: const Icon(Icons.arrow_downward_outlined),
),
)
// #Pangea
: controller.choreographer.errorService.error != null
? ChoreographerHasErrorButton(
controller.pangeaController,
controller.choreographer.errorService.error!,
)
: controller.showPermissionsError
? LanguagePermissionsButtons(
choreographer: controller.choreographer,
roomID: controller.roomId,
)
: null)
// #Pangea
: null,
// ? Padding(
// padding: const EdgeInsets.only(bottom: 56.0),
// child: FloatingActionButton(
// onPressed: controller.scrollDown,
// heroTag: null,
// mini: true,
// child: const Icon(Icons.arrow_downward_outlined),
// ),
// )
// : null,
floatingActionButton: ChatFloatingActionButton(
controller: controller,
),
// Pangea#
body:
// #Pangea
// DropTarget(
@ -338,120 +324,100 @@ class ChatView extends StatelessWidget {
),
if (controller.room.canSendDefaultMessages &&
controller.room.membership == Membership.join)
// #Pangea
// Container(
ConditionalFlexible(
isScroll: controller.isRowScrollable,
child: ConditionalScroll(
isScroll: controller.isRowScrollable,
child: MeasurableWidget(
onChange: (size, position) {
controller.inputRowSize = size!.height;
},
child: Container(
// Pangea#
margin: EdgeInsets.only(
bottom: bottomSheetPadding,
left: bottomSheetPadding,
right: bottomSheetPadding,
),
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 2.5,
),
alignment: Alignment.center,
child: Material(
clipBehavior: Clip.hardEdge,
color: Theme.of(context)
.colorScheme
// ignore: deprecated_member_use
.surfaceVariant,
borderRadius: const BorderRadius.all(
Radius.circular(24),
),
child: controller.room.isAbandonedDMRoom ==
true
? Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
// #Pangea
if (controller.room.isRoomAdmin)
TextButton.icon(
style: TextButton.styleFrom(
padding:
const EdgeInsets.all(
16,
),
foregroundColor:
Theme.of(context)
.colorScheme
.error,
),
icon: const Icon(
Icons.archive_outlined,
),
onPressed:
controller.archiveChat,
label: Text(
L10n.of(context)!.archive,
),
),
// Pangea#
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.all(
16,
),
foregroundColor:
Theme.of(context)
.colorScheme
.error,
),
icon: const Icon(
// #Pangea
// Icons.archive_outlined,
Icons.arrow_forward,
// Pangea#
),
onPressed: controller.leaveChat,
label: Text(
L10n.of(context)!.leave,
),
),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.all(
16,
),
),
icon: const Icon(
Icons.forum_outlined,
),
onPressed:
controller.recreateChat,
label: Text(
L10n.of(context)!.reopenChat,
),
),
],
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ConnectionStatusHeader(),
ITBar(
choreographer:
controller.choreographer,
),
ReactionsPicker(controller),
ReplyDisplay(controller),
ChatInputRow(controller),
ChatEmojiPicker(controller),
],
),
),
),
Container(
margin: EdgeInsets.only(
bottom: bottomSheetPadding,
left: bottomSheetPadding,
right: bottomSheetPadding,
),
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 2.5,
),
alignment: Alignment.center,
child: Material(
clipBehavior: Clip.hardEdge,
color: Theme.of(context)
.colorScheme
// ignore: deprecated_member_use
.surfaceVariant,
borderRadius: const BorderRadius.all(
Radius.circular(24),
),
child: controller.room.isAbandonedDMRoom == true
? Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
// #Pangea
if (controller.room.isRoomAdmin)
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.all(
16,
),
foregroundColor: Theme.of(context)
.colorScheme
.error,
),
icon: const Icon(
Icons.archive_outlined,
),
onPressed: controller.archiveChat,
label: Text(
L10n.of(context)!.archive,
),
),
// Pangea#
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.all(
16,
),
foregroundColor: Theme.of(context)
.colorScheme
.error,
),
icon: const Icon(
// #Pangea
// Icons.archive_outlined,
Icons.arrow_forward,
// Pangea#
),
onPressed: controller.leaveChat,
label: Text(
L10n.of(context)!.leave,
),
),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.all(
16,
),
),
icon: const Icon(
Icons.forum_outlined,
),
onPressed: controller.recreateChat,
label: Text(
L10n.of(context)!.reopenChat,
),
),
],
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ConnectionStatusHeader(),
ITBar(
choreographer:
controller.choreographer,
),
ReactionsPicker(controller),
ReplyDisplay(controller),
ChatInputRow(controller),
ChatEmojiPicker(controller),
],
),
),
),
],
@ -484,35 +450,3 @@ class ChatView extends StatelessWidget {
);
}
}
// #Pangea
Widget ConditionalFlexible({required bool isScroll, required Widget child}) {
if (isScroll) {
return Flexible(
flex: 9999999,
child: child,
);
}
return child;
}
class ConditionalScroll extends StatelessWidget {
final bool isScroll;
final Widget child;
const ConditionalScroll({
super.key,
required this.isScroll,
required this.child,
});
@override
Widget build(BuildContext context) {
if (isScroll) {
return SingleChildScrollView(
child: child,
);
}
return child;
}
}
// Pangea#

View file

@ -298,7 +298,10 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
final statusText = this.statusText ??= _durationString ?? '00:00';
final audioPlayer = this.audioPlayer;
return Padding(
padding: const EdgeInsets.all(12.0),
// #Pangea
// padding: const EdgeInsets.all(12.0),
padding: const EdgeInsets.all(5.0),
// Pangea#
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
@ -332,7 +335,10 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
},
),
),
const SizedBox(width: 8),
// #Pangea
// const SizedBox(width: 8),
const SizedBox(width: 5),
// Pangea#
Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -368,7 +374,10 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
),
],
),
const SizedBox(width: 8),
// #Pangea
// const SizedBox(width: 8),
const SizedBox(width: 5),
// Pangea#
SizedBox(
width: 36,
child: Text(

View file

@ -2,7 +2,6 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/enum/use_type.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/widgets/chat/message_buttons.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
@ -39,7 +38,6 @@ class Message extends StatelessWidget {
final bool animateIn;
final void Function()? resetAnimateIn;
// #Pangea
final LanguageModel? selectedDisplayLang;
final bool immersionMode;
final bool definitions;
final ChatController controller;
@ -64,7 +62,6 @@ class Message extends StatelessWidget {
this.resetAnimateIn,
this.avatarPresenceBackgroundColor,
// #Pangea
required this.selectedDisplayLang,
required this.immersionMode,
required this.definitions,
required this.controller,
@ -82,9 +79,9 @@ class Message extends StatelessWidget {
// #Pangea
debugPrint('Message.build()');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (controller.edittingEvents.contains(event.eventId)) {
if (controller.pangeaEditingEvent?.eventId == event.eventId) {
pangeaMessageEvent?.updateLatestEdit();
controller.clearEdittingEvent(event.eventId);
controller.clearEditingEvent();
}
});
// Pangea#
@ -317,8 +314,9 @@ class Message extends StatelessWidget {
padding: const EdgeInsets.only(left: 8),
child: GestureDetector(
// #Pangea
onTap: () =>
toolbarController?.showToolbar(context),
onTap: () => toolbarController?.showToolbar(
context,
),
onDoubleTap: () =>
toolbarController?.showToolbar(context),
// Pangea#
@ -588,7 +586,9 @@ class Message extends StatelessWidget {
: MainAxisAlignment.start,
children: [
if (pangeaMessageEvent?.showMessageButtons ?? false)
MessageButtons(toolbarController: toolbarController),
MessageButtons(
toolbarController: toolbarController,
),
MessageReactions(event, timeline),
],
),

View file

@ -289,17 +289,20 @@ class MessageContent extends StatelessWidget {
// #Pangea
// return Linkify(
final messageTextStyle = TextStyle(
overflow: TextOverflow.ellipsis,
color: textColor,
fontSize: bigEmotes ? fontSize * 3 : fontSize,
decoration: event.redacted ? TextDecoration.lineThrough : null,
height: 1.3,
);
if (immersionMode && pangeaMessageEvent != null) {
return PangeaRichText(
style: messageTextStyle,
pangeaMessageEvent: pangeaMessageEvent!,
immersionMode: immersionMode,
toolbarController: toolbarController,
return Flexible(
child: PangeaRichText(
style: messageTextStyle,
pangeaMessageEvent: pangeaMessageEvent!,
immersionMode: immersionMode,
toolbarController: toolbarController,
),
);
} else if (pangeaMessageEvent != null) {
toolbarController?.toolbar?.textSelection.setMessageText(

View file

@ -1,21 +1,20 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html;
import 'package:video_player/video_player.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/events/image_bubble.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/blur_hash.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html;
import 'package:video_player/video_player.dart';
import '../../../utils/error_reporter.dart';
class EventVideoPlayer extends StatefulWidget {
@ -71,7 +70,7 @@ class EventVideoPlayerState extends State<EventVideoPlayer> {
autoInitialize: true,
);
}
} on MatrixConnectionException catch (e) {
} on Exception catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toLocalizedString(context)),

View file

@ -504,6 +504,9 @@ class InputBar extends StatelessWidget {
onSubmitted!(text);
},
// #Pangea
style: controller?.isMaxLength ?? false
? const TextStyle(color: Colors.red)
: null,
onTap: () {
controller!.onInputTap(
context,

View file

@ -9,7 +9,6 @@ import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_det
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_invitation_buttons.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_name_button.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_rules_editor.dart';
import 'package:fluffychat/pangea/utils/lock_room.dart';
import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart';
import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_settings.dart';
@ -269,11 +268,14 @@ class ChatDetailsView extends StatelessWidget {
// roomId: controller.roomId,
// startOpen: false,
// ),
if (room.pangeaRoomRules != null)
RoomRulesEditor(
roomId: controller.roomId,
startOpen: false,
),
// Commenting out pangea room rules for now
// if (room.pangeaRoomRules != null)
// RoomRulesEditor(
// roomId: controller.roomId,
// startOpen: false,
// ),
// if (!room.canChangeStateEvent(EventTypes.RoomTopic))
// ListTile(
// title: Text(
@ -434,7 +436,9 @@ class ChatDetailsView extends StatelessWidget {
onTap: () =>
context.go('/rooms/${room.id}/invite'),
),
if (room.showClassEditOptions && room.isSpace)
if (room.showClassEditOptions &&
room.isSpace &&
!room.isSubspace)
SpaceDetailsToggleAddStudentsTile(
controller: controller,
),

View file

@ -476,6 +476,8 @@ class ChatListController extends State<ChatList>
StreamSubscription? classStream;
StreamSubscription? _invitedSpaceSubscription;
StreamSubscription? _subscriptionStatusStream;
StreamSubscription? _spaceChildSubscription;
final Set<String> hasUpdates = {};
//Pangea#
@override
@ -567,6 +569,16 @@ class ChatListController extends State<ChatList>
showSubscribedSnackbar(context);
}
});
// listen for space child updates for any space that is not the active space
// so that when the user navigates to the space that was updated, it will
// reload any rooms that have been added / removed
final client = pangeaController.matrixState.client;
_spaceChildSubscription ??= client.onRoomState.stream.where((u) {
return u.state.type == EventTypes.SpaceChild && u.roomId != activeSpaceId;
}).listen((update) {
hasUpdates.add(update.roomId);
});
//Pangea#
super.initState();
@ -581,17 +593,29 @@ class ChatListController extends State<ChatList>
classStream?.cancel();
_invitedSpaceSubscription?.cancel();
_subscriptionStatusStream?.cancel();
_spaceChildSubscription?.cancel();
//Pangea#
scrollController.removeListener(_onScroll);
super.dispose();
}
// #Pangea
final StreamController<String> selectionsStream =
StreamController.broadcast();
// Pangea#
void toggleSelection(String roomId) {
setState(
() => selectedRoomIds.contains(roomId)
? selectedRoomIds.remove(roomId)
: selectedRoomIds.add(roomId),
);
// #Pangea
// setState(
// () => selectedRoomIds.contains(roomId)
// ? selectedRoomIds.remove(roomId)
// : selectedRoomIds.add(roomId),
// );
selectedRoomIds.contains(roomId)
? selectedRoomIds.remove(roomId)
: selectedRoomIds.add(roomId);
selectionsStream.add(roomId);
// Pangea#
}
Future<void> toggleUnread() async {
@ -663,8 +687,8 @@ class ChatListController extends State<ChatList>
context: context,
future: () => _archiveSelectedRooms(),
);
setState(() {});
// #Pangea
// setState(() {});
if (archivedActiveRoom) {
context.go('/rooms');
}
@ -696,7 +720,6 @@ class ChatListController extends State<ChatList>
context: context,
future: () => _leaveSelectedRooms(onlyAdmin),
);
setState(() {});
if (leftActiveRoom) {
context.go('/rooms');
}
@ -819,8 +842,7 @@ class ChatListController extends State<ChatList>
label: space.nameIncludingParents(context),
// If user is not admin of space, button is grayed out
textStyle: TextStyle(
color: (firstSelectedRoom == null ||
(firstSelectedRoom.isSpace && !space.isRoomAdmin))
color: (firstSelectedRoom == null)
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.surfaceTint,
),
@ -838,10 +860,6 @@ class ChatListController extends State<ChatList>
if (firstSelectedRoom == null) {
throw L10n.of(context)!.nonexistentSelection;
}
// If user is not admin of the would-be parent space, does not allow
if (firstSelectedRoom.isSpace && !space.isRoomAdmin) {
throw L10n.of(context)!.cantAddSpaceChild;
}
if (space.canSendDefaultStates) {
for (final roomId in selectedRoomIds) {
@ -863,7 +881,12 @@ class ChatListController extends State<ChatList>
);
}
setState(() => selectedRoomIds.clear());
// #Pangea
// setState(() => selectedRoomIds.clear());
if (firstSelectedRoom != null) {
toggleSelection(firstSelectedRoom.id);
}
// Pangea#
}
bool get anySelectedRoomNotMarkedUnread => selectedRoomIds.any(
@ -908,13 +931,14 @@ class ChatListController extends State<ChatList>
// #Pangea
if (mounted) {
// TODO try not to await so much
GoogleAnalytics.analyticsUserUpdate(client.userID);
await pangeaController.subscriptionController.initialize();
await pangeaController.myAnalytics.initialize();
pangeaController.afterSyncAndFirstLoginInitialization(context);
await pangeaController.inviteBotToExistingSpaces();
await pangeaController.setPangeaPushRules();
await client.migrateAnalyticsRooms();
client.migrateAnalyticsRooms();
} else {
ErrorHandler.logError(
m: "didn't run afterSyncAndFirstLoginInitialization because not mounted",
@ -932,7 +956,12 @@ class ChatListController extends State<ChatList>
if (selectMode == SelectMode.share) {
setState(() => Matrix.of(context).shareContent = null);
} else {
setState(() => selectedRoomIds.clear());
// #Pangea
// setState(() => selectedRoomIds.clear());
for (final roomId in selectedRoomIds.toList()) {
toggleSelection(roomId);
}
// Pangea#
}
}

View file

@ -1,11 +1,11 @@
import 'package:animations/animations.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/space_view.dart';
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/pangea/widgets/chat_list/chat_list_body_text.dart';
import 'package:fluffychat/pangea/widgets/chat_list/chat_list_header_wrapper.dart';
import 'package:fluffychat/pangea/widgets/chat_list/chat_list_item_wrapper.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -17,7 +17,6 @@ import 'package:matrix/matrix.dart';
import '../../config/themes.dart';
import '../../widgets/connection_status_header.dart';
import '../../widgets/matrix.dart';
import 'chat_list_header.dart';
class ChatListViewBody extends StatelessWidget {
final ChatListController controller;
@ -76,7 +75,10 @@ class ChatListViewBody extends StatelessWidget {
child: CustomScrollView(
controller: controller.scrollController,
slivers: [
ChatListHeader(controller: controller),
// #Pangea
// ChatListHeader(controller: controller),
ChatListHeaderWrapper(controller: controller),
// Pangea#
SliverList(
delegate: SliverChildListDelegate(
[
@ -247,17 +249,23 @@ class ChatListViewBody extends StatelessWidget {
SliverList.builder(
itemCount: rooms.length,
itemBuilder: (BuildContext context, int i) {
return ChatListItem(
// #Pangea
// return ChatListItem(
return ChatListItemWrapper(
controller: controller,
// Pangea#
rooms[i],
key: Key('chat_list_item_${rooms[i].id}'),
filter: filter,
selected:
controller.selectedRoomIds.contains(rooms[i].id),
onTap: controller.selectMode == SelectMode.select
? () => controller.toggleSelection(rooms[i].id)
: () => onChatTap(rooms[i], context),
onLongPress: () =>
controller.toggleSelection(rooms[i].id),
// #Pangea
// selected:
// controller.selectedRoomIds.contains(rooms[i].id),
// onTap: controller.selectMode == SelectMode.select
// ? () => controller.toggleSelection(rooms[i].id)
// : () => onChatTap(rooms[i], context),
// onLongPress: () =>
// controller.toggleSelection(rooms[i].id),
// Pangea#
activeChat: controller.activeChat == rooms[i].id,
);
},

View file

@ -4,14 +4,15 @@ import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/extensions/sync_update_extension.dart';
import 'package:fluffychat/pangea/utils/chat_list_handle_space_tap.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat_list/chat_list_header_wrapper.dart';
import 'package:fluffychat/pangea/widgets/chat_list/chat_list_item_wrapper.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:flutter/material.dart';
@ -23,7 +24,6 @@ import 'package:matrix/matrix.dart';
import '../../utils/localized_exception_extension.dart';
import '../../widgets/matrix.dart';
import 'chat_list_header.dart';
class SpaceView extends StatefulWidget {
final ChatListController controller;
@ -45,21 +45,61 @@ class _SpaceViewState extends State<SpaceView> {
Object? error;
bool loading = false;
// #Pangea
StreamSubscription<SyncUpdate>? _roomSubscription;
bool refreshing = false;
StreamSubscription? _roomSubscription;
final String _chatCountsKey = 'chatCounts';
Map<String, int> get chatCounts => Map.from(
widget.controller.pangeaController.pStoreService.read(_chatCountsKey) ??
{},
);
/// Used to filter out sync updates with hierarchy updates for the active
/// space so that the view can be auto-reloaded in the room subscription
bool hasHierarchyUpdate(SyncUpdate update) {
final joinTimeline =
update.rooms?.join?[widget.controller.activeSpaceId]?.timeline;
final leaveTimeline =
update.rooms?.leave?[widget.controller.activeSpaceId]?.timeline;
if (joinTimeline == null && leaveTimeline == null) return false;
final bool hasJoinUpdate = joinTimeline?.events?.any(
(event) => event.type == EventTypes.SpaceChild,
) ??
false;
final bool hasLeaveUpdate = leaveTimeline?.events?.any(
(event) => event.type == EventTypes.SpaceChild,
) ??
false;
return hasJoinUpdate || hasLeaveUpdate;
}
// Pangea#
@override
void initState() {
loadHierarchy();
// #Pangea
// loadHierarchy();
// If, on launch, this room has had updates to its children,
// ensure the hierarchy is properly reloaded
final bool hasUpdate = widget.controller.hasUpdates.contains(
widget.controller.activeSpaceId,
);
loadHierarchy(hasUpdate: hasUpdate).then(
// remove this space ID from the set of space IDs with updates
(_) => widget.controller.hasUpdates.remove(
widget.controller.activeSpaceId,
),
);
loadChatCounts();
// Listen for changes to the activeSpace's hierarchy,
// and reload the hierarchy when they come through
final client = Matrix.of(context).client;
_roomSubscription ??= client.onSync.stream
.where(hasHierarchyUpdate)
.listen((update) => loadHierarchy(hasUpdate: true));
// Pangea#
super.initState();
}
@ -75,87 +115,184 @@ class _SpaceViewState extends State<SpaceView> {
void _refresh() {
// #Pangea
// _lastResponse.remove(widget.controller.activseSpaceId);
if (mounted) {
// Pangea#
loadHierarchy();
// #Pangea
}
// loadHierarchy();
if (mounted) setState(() => refreshing = true);
loadHierarchy(hasUpdate: true).whenComplete(() {
if (mounted) setState(() => refreshing = false);
});
// Pangea#
}
Future<GetSpaceHierarchyResponse> loadHierarchy([
String? prevBatch,
// #Pangea
// #Pangea
// Future<GetSpaceHierarchyResponse?> loadHierarchy([String? prevBatch]) async {
// final activeSpaceId = widget.controller.activeSpaceId;
// if (activeSpaceId == null) return null;
// final client = Matrix.of(context).client;
// final activeSpace = client.getRoomById(activeSpaceId);
// await activeSpace?.postLoad();
// setState(() {
// error = null;
// loading = true;
// });
// try {
// final response = await client.getSpaceHierarchy(
// activeSpaceId,
// maxDepth: 1,
// from: prevBatch,
// );
// if (prevBatch != null) {
// response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []);
// }
// setState(() {
// _lastResponse[activeSpaceId] = response;
// });
// return _lastResponse[activeSpaceId]!;
// } catch (e) {
// setState(() {
// error = e;
// });
// rethrow;
// } finally {
// setState(() {
// loading = false;
// });
// }
// }
/// Loads the hierarchy of the active space (or the given spaceId) and stores
/// it in _lastResponse map. If there's already a response in that map for the
/// spaceId, it will try to load the next batch and add the new rooms to the
/// already loaded ones. Displays a loading indicator while loading, and an error
/// message if an error occurs.
/// If hasUpdate is true, it will force the hierarchy to be reloaded.
Future<void> loadHierarchy({
String? spaceId,
// Pangea#
]) async {
// #Pangea
bool hasUpdate = false,
}) async {
if ((widget.controller.activeSpaceId == null && spaceId == null) ||
loading) {
return GetSpaceHierarchyResponse(
rooms: [],
nextBatch: null,
);
return;
}
setState(() {
error = null;
loading = true;
});
// Pangea#
// #Pangea
// final activeSpaceId = widget.controller.activeSpaceId!;
final activeSpaceId = (widget.controller.activeSpaceId ?? spaceId)!;
// Pangea#
final client = Matrix.of(context).client;
final activeSpace = client.getRoomById(activeSpaceId);
await activeSpace?.postLoad();
// #Pangea
// setState(() {
// error = null;
// loading = true;
// });
// Pangea#
loading = true;
error = null;
setState(() {});
try {
await _loadHierarchy(spaceId: spaceId, hasUpdate: hasUpdate);
} catch (e, s) {
if (mounted) {
setState(() => error = e);
}
ErrorHandler.logError(e: e, s: s);
} finally {
if (mounted) {
setState(() => loading = false);
}
}
}
/// Internal logic of loadHierarchy. It will load the hierarchy of
/// the active space id (or specified spaceId).
Future<void> _loadHierarchy({
String? spaceId,
bool hasUpdate = false,
}) async {
final client = Matrix.of(context).client;
final activeSpaceId = (widget.controller.activeSpaceId ?? spaceId)!;
final activeSpace = client.getRoomById(activeSpaceId);
if (activeSpace == null) {
ErrorHandler.logError(
e: Exception('Space not found in loadHierarchy'),
data: {'spaceId': activeSpaceId},
);
return;
}
// Load all of the space's state events. Space Child events
// are used to filtering out unsuggested, unjoined rooms.
await activeSpace.postLoad();
// The current number of rooms loaded for this space that are visible in the UI
final int prevLength = _lastResponse[activeSpaceId] != null && !hasUpdate
? filterHierarchyResponse(
activeSpace,
_lastResponse[activeSpaceId]!.rooms,
).length
: 0;
// Failsafe to prevent too many calls to the server in a row
int callsToServer = 0;
GetSpaceHierarchyResponse? currentHierarchy =
hasUpdate ? null : _lastResponse[activeSpaceId];
// Makes repeated calls to the server until 10 new visible rooms have
// been loaded, or there are no rooms left to load. Using a loop here,
// rather than one single call to the endpoint, because some spaces have
// so many invisible rooms (analytics rooms) that it might look like
// pressing the 'load more' button does nothing (Because the only rooms
// coming through from those calls are analytics rooms).
while (callsToServer < 5) {
// if this space has been loaded and there are no more rooms to load, break
if (currentHierarchy != null && currentHierarchy.nextBatch == null) {
break;
}
// if this space has been loaded and 10 new rooms have been loaded, break
if (currentHierarchy != null) {
final int currentLength = filterHierarchyResponse(
activeSpace,
currentHierarchy.rooms,
).length;
if (currentLength - prevLength >= 10) {
break;
}
}
// make the call to the server
final response = await client.getSpaceHierarchy(
activeSpaceId,
maxDepth: 1,
from: prevBatch,
from: currentHierarchy?.nextBatch,
limit: 100,
);
callsToServer++;
if (prevBatch != null) {
response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []);
}
setState(() {
_lastResponse[activeSpaceId] = response;
});
return _lastResponse[activeSpaceId]!;
} catch (e) {
setState(() {
error = e;
});
rethrow;
} finally {
// #Pangea
if (activeSpace != null) {
await setChatCount(
activeSpace,
_lastResponse[activeSpaceId] ??
GetSpaceHierarchyResponse(
rooms: [],
),
// if rooms have earlier been loaded for this space, add those
// previously loaded rooms to the front of the response list
if (currentHierarchy != null) {
response.rooms.insertAll(
0,
currentHierarchy.rooms,
);
}
// Pangea#
setState(() {
loading = false;
});
// finally, set the response to the last response for this space
currentHierarchy = response;
}
if (currentHierarchy != null) {
_lastResponse[activeSpaceId] = currentHierarchy;
}
// After making those calls to the server, set the chat count for
// this space. Used for the UI of the 'All Spaces' view
setChatCount(
activeSpace,
_lastResponse[activeSpaceId] ??
GetSpaceHierarchyResponse(
rooms: [],
),
);
}
// Pangea#
void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async {
final client = Matrix.of(context).client;
@ -451,71 +588,60 @@ class _SpaceViewState extends State<SpaceView> {
// #Pangea
Future<void> loadChatCounts() async {
for (final Room room in Matrix.of(context).client.rooms) {
if (room.isSpace && !chatCounts.containsKey(room.id)) {
await loadHierarchy(null, room.id);
// if not in the call spaces view, don't load chat count yet
if (widget.controller.activeSpaceId != null) return;
final List<Room> allSpaces =
Matrix.of(context).client.rooms.where((room) => room.isSpace).toList();
for (final Room space in allSpaces) {
// check if the space is visible in the all spaces list
final bool isRootSpace = !allSpaces.any(
(parentSpace) =>
parentSpace.spaceChildren.any((child) => child.roomId == space.id),
);
// if it's visible, and it hasn't been loaded yet, load chat count
if (isRootSpace && !chatCounts.containsKey(space.id)) {
loadHierarchy(spaceId: space.id);
}
}
}
Future<void> refreshOnUpdate(SyncUpdate event) async {
/* refresh on leave, invite, and space child update
not join events, because there's already a listener on
onTapSpaceChild, and they interfere with each other */
if (widget.controller.activeSpaceId == null || !mounted || refreshing) {
return;
}
setState(() => refreshing = true);
final client = Matrix.of(context).client;
if (mounted &&
event.isMembershipUpdateByType(
Membership.leave,
client.userID!,
) ||
event.isMembershipUpdateByType(
Membership.invite,
client.userID!,
) ||
event.isSpaceChildUpdate(
widget.controller.activeSpaceId!,
)) {
await loadHierarchy();
}
setState(() => refreshing = false);
}
bool includeSpaceChild(
Room space,
SpaceRoomsChunk hierarchyMember,
) {
if (!mounted) return false;
final bool isAnalyticsRoom =
hierarchyMember.roomType == PangeaRoomTypes.analytics;
bool includeSpaceChild(sc, matchingSpaceChildren) {
final bool isAnalyticsRoom = sc.roomType == PangeaRoomTypes.analytics;
final bool isMember = [Membership.join, Membership.invite]
.contains(Matrix.of(context).client.getRoomById(sc.roomId)?.membership);
final bool isSuggested = matchingSpaceChildren.any(
(matchingSpaceChild) =>
matchingSpaceChild.roomId == sc.roomId &&
matchingSpaceChild.suggested == true,
final bool isMember = [Membership.join, Membership.invite].contains(
Matrix.of(context).client.getRoomById(hierarchyMember.roomId)?.membership,
);
final bool isSuggested =
space.spaceChildSuggestionStatus[hierarchyMember.roomId] ?? true;
return !isAnalyticsRoom && (isMember || isSuggested);
}
List<SpaceRoomsChunk> filterSpaceChildren(
List<SpaceRoomsChunk> filterHierarchyResponse(
Room space,
List<SpaceRoomsChunk> spaceChildren,
List<SpaceRoomsChunk> hierarchyResponse,
) {
final childIds =
spaceChildren.map((hierarchyMember) => hierarchyMember.roomId);
final List<SpaceRoomsChunk> filteredChildren = [];
for (final child in hierarchyResponse) {
final isDuplicate = filteredChildren.any(
(filtered) => filtered.roomId == child.roomId,
);
if (isDuplicate) continue;
final matchingSpaceChildren = space.spaceChildren
.where((spaceChild) => childIds.contains(spaceChild.roomId))
.toList();
final filteredSpaceChildren = spaceChildren
.where(
(sc) => includeSpaceChild(
sc,
matchingSpaceChildren,
),
)
.toList();
return filteredSpaceChildren;
if (includeSpaceChild(space, child)) {
filteredChildren.add(child);
}
}
return filteredChildren;
}
int sortSpaceChildren(
@ -539,7 +665,7 @@ class _SpaceViewState extends State<SpaceView> {
) async {
final Map<String, int> updatedChatCounts = Map.from(chatCounts);
final List<SpaceRoomsChunk> spaceChildren = response?.rooms ?? [];
final filteredChildren = filterSpaceChildren(space, spaceChildren)
final filteredChildren = filterHierarchyResponse(space, spaceChildren)
.where((sc) => sc.roomId != space.id)
.toList();
updatedChatCounts[space.id] = filteredChildren.length;
@ -599,7 +725,10 @@ class _SpaceViewState extends State<SpaceView> {
child: CustomScrollView(
controller: widget.scrollController,
slivers: [
ChatListHeader(controller: widget.controller),
// #Pangea
// ChatListHeader(controller: widget.controller),
ChatListHeaderWrapper(controller: widget.controller),
// Pangea#
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) {
@ -663,12 +792,6 @@ class _SpaceViewState extends State<SpaceView> {
);
}
// #Pangea
_roomSubscription ??= client.onSync.stream
.where((event) => event.hasRoomUpdate)
.listen(refreshOnUpdate);
// Pangea#
final parentSpace = allSpaces.firstWhereOrNull(
(space) =>
space.spaceChildren.any((child) => child.roomId == activeSpaceId),
@ -685,7 +808,13 @@ class _SpaceViewState extends State<SpaceView> {
child: CustomScrollView(
controller: widget.scrollController,
slivers: [
ChatListHeader(controller: widget.controller, globalSearch: false),
// #Pangea
// ChatListHeader(controller: widget.controller, globalSearch: false),
ChatListHeaderWrapper(
controller: widget.controller,
globalSearch: false,
),
// Pangea#
SliverAppBar(
automaticallyImplyLeading: false,
primary: false,
@ -771,7 +900,7 @@ class _SpaceViewState extends State<SpaceView> {
final space =
Matrix.of(context).client.getRoomById(activeSpaceId);
if (space != null) {
spaceChildren = filterSpaceChildren(space, spaceChildren);
spaceChildren = filterHierarchyResponse(space, spaceChildren);
}
spaceChildren.sort(sortSpaceChildren);
// Pangea#
@ -790,7 +919,10 @@ class _SpaceViewState extends State<SpaceView> {
onPressed: loading
? null
: () {
loadHierarchy(response.nextBatch);
// #Pangea
// loadHierarchy(response.nextBatch);
loadHierarchy();
// Pangea#
},
),
);
@ -804,7 +936,11 @@ class _SpaceViewState extends State<SpaceView> {
room.membership != Membership.leave
// Pangea#
) {
return ChatListItem(
// #Pangea
// return ChatListItem(
return ChatListItemWrapper(
controller: widget.controller,
// Pangea#
room,
onLongPress: () =>
_onSpaceChildContextMenu(spaceChild, room),

View file

@ -147,7 +147,7 @@ class LoginView extends StatelessWidget {
controller.showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: Colors.black,
// color: Colors.black,
),
),
),

View file

@ -1,11 +1,8 @@
import 'dart:developer';
import 'package:file_picker/file_picker.dart';
import 'package:fluffychat/pages/new_space/new_space_view.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_rules_editor.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/utils/class_chat_power_levels.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
@ -32,7 +29,7 @@ class NewSpaceController extends State<NewSpace> {
// #Pangea
// bool publicGroup = false;
bool publicGroup = true;
final GlobalKey<RoomRulesState> rulesEditorKey = GlobalKey<RoomRulesState>();
// final GlobalKey<RoomRulesState> rulesEditorKey = GlobalKey<RoomRulesState>();
final GlobalKey<AddToSpaceState> addToSpaceKey = GlobalKey<AddToSpaceState>();
// commenting out language settings in spaces for now
// final GlobalKey<LanguageSettingsState> languageSettingsKey =
@ -87,11 +84,12 @@ class NewSpaceController extends State<NewSpace> {
),
);
if (rulesEditorKey.currentState?.rules != null) {
events.add(rulesEditorKey.currentState!.rules.toStateEvent);
} else {
debugger(when: kDebugMode);
}
// commenting out pangea room rules in spaces for now
// if (rulesEditorKey.currentState?.rules != null) {
// events.add(rulesEditorKey.currentState!.rules.toStateEvent);
// } else {
// debugger(when: kDebugMode);
// }
// commenting out language settings in spaces for now
// if (languageSettingsKey.currentState != null) {
// events
@ -110,10 +108,11 @@ class NewSpaceController extends State<NewSpace> {
// Pangea#
});
// #Pangea
if (rulesEditorKey.currentState == null) {
debugger(when: kDebugMode);
return;
}
// commenting out pangea room rules in spaces for now
// if (rulesEditorKey.currentState == null) {
// debugger(when: kDebugMode);
// return;
// }
// commenting out language settings in spaces for now
// if (languageSettingsKey.currentState != null &&
// languageSettingsKey.currentState!.sameLanguages) {
@ -174,15 +173,17 @@ class NewSpaceController extends State<NewSpace> {
addToSpaceKey.currentState!.parent,
)
: null,
// initialState: [
// if (avatar != null)
// sdk.StateEvent(
// type: sdk.EventTypes.RoomAvatar,
// content: {'url': avatarUrl.toString()},
// ),
// ],
initialState: initialState,
// Pangea#
initialState: [
if (avatar != null)
sdk.StateEvent(
type: sdk.EventTypes.RoomAvatar,
content: {'url': avatarUrl.toString()},
),
// #Pangea
...initialState,
// Pangea#
],
);
// #Pangea
final List<Future<dynamic>> futures = [
@ -198,14 +199,6 @@ class NewSpaceController extends State<NewSpace> {
if (capacity != null && space != null) {
space.updateRoomCapacity(capacity);
}
final newChatRoomId = await Matrix.of(context).client.createGroupChat(
enableEncryption: false,
preset: sdk.CreateRoomPreset.publicChat,
// Welcome chat name is '[space name acronym]: Welcome Chat'
groupName:
'${nameController.text.trim().split(RegExp(r"\s+")).map((s) => s[0]).join()}: ${L10n.of(context)!.classWelcomeChat}',
);
GoogleAnalytics.createChat(newChatRoomId);
final Room? room = Matrix.of(context).client.getRoomById(spaceId);
if (room == null) {
@ -217,12 +210,6 @@ class NewSpaceController extends State<NewSpace> {
return;
}
room.setSpaceChild(newChatRoomId, suggested: true);
GoogleAnalytics.addParent(
newChatRoomId,
room.classCode,
);
GoogleAnalytics.createClass(room.name, room.classCode);
try {
await room.invite(BotName.byEnvironment);

View file

@ -1,12 +1,8 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/models/space_model.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_rules_editor.dart';
import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -133,35 +129,39 @@ class NewSpaceView extends StatelessWidget {
startOpen: true,
spaceMode: true,
),
if (controller.rulesEditorKey.currentState != null)
RoomRulesEditor(
key: controller.rulesEditorKey,
roomId: null,
startOpen: false,
initialRules: controller.rulesEditorKey.currentState!.rules,
),
if (controller.rulesEditorKey.currentState == null)
FutureBuilder<PangeaRoomRules?>(
future: Matrix.of(context).client.lastUpdatedRoomRules,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return RoomRulesEditor(
key: controller.rulesEditorKey,
roomId: null,
startOpen: false,
initialRules: snapshot.data,
);
} else {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child:
CircularProgressIndicator.adaptive(strokeWidth: 2),
),
);
}
},
),
// Commenting out pangea room rules for now
// if (controller.rulesEditorKey.currentState != null)
// RoomRulesEditor(
// key: controller.rulesEditorKey,
// roomId: null,
// startOpen: false,
// initialRules: controller.rulesEditorKey.currentState!.rules,
// ),
// Commenting out pangea room rules for now
// if (controller.rulesEditorKey.currentState == null)
// FutureBuilder<PangeaRoomRules?>(
// future: Matrix.of(context).client.lastUpdatedRoomRules,
// builder: (context, snapshot) {
// if (snapshot.connectionState == ConnectionState.done) {
// return RoomRulesEditor(
// key: controller.rulesEditorKey,
// roomId: null,
// startOpen: false,
// initialRules: snapshot.data,
// );
// } else {
// return const Padding(
// padding: EdgeInsets.all(16.0),
// child: Center(
// child:
// CircularProgressIndicator.adaptive(strokeWidth: 2),
// ),
// );
// }
// },
// ),
// SwitchListTile.adaptive(
// title: Text(L10n.of(context)!.spaceIsPublic),
// value: controller.publicGroup,

View file

@ -4,7 +4,6 @@ import 'dart:developer';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/choreographer/controllers/alternative_translator.dart';
import 'package:fluffychat/pangea/choreographer/controllers/igc_controller.dart';
import 'package:fluffychat/pangea/choreographer/controllers/message_options.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
@ -38,7 +37,6 @@ class Choreographer {
late PangeaTextController _textController;
late ITController itController;
late IgcController igc;
late MessageOptions messageOptions;
late AlternativeTranslator altTranslator;
late ErrorService errorService;
@ -59,7 +57,6 @@ class Choreographer {
_textController = PangeaTextController(choreographer: this);
itController = ITController(this);
igc = IgcController(this);
messageOptions = MessageOptions(this);
errorService = ErrorService(this);
altTranslator = AlternativeTranslator(this);
_textController.addListener(_onChangeListener);

View file

@ -1,46 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
class MessageOptions {
Choreographer choreographer;
LanguageModel? _selectedDisplayLang;
MessageOptions(this.choreographer);
LanguageModel? get selectedDisplayLang {
if (_selectedDisplayLang != null &&
_selectedDisplayLang!.langCode != LanguageKeys.unknownLanguage) {
return _selectedDisplayLang;
}
_selectedDisplayLang = choreographer.l2Lang;
return _selectedDisplayLang;
}
bool get isTranslationOn =>
_selectedDisplayLang?.langCode != choreographer.l2LangCode;
// void setSelectedDisplayLang(LanguageModel? newLang) {
// _selectedDisplayLang = newLang;
// choreographer.setState();
// }
void toggleSelectedDisplayLang() {
if (_selectedDisplayLang?.langCode == choreographer.l2LangCode) {
_selectedDisplayLang = choreographer.l1Lang;
} else {
_selectedDisplayLang = choreographer.l2Lang;
}
debugPrint('toggleSelectedDisplayLang: ${_selectedDisplayLang?.langCode}');
choreographer.setState();
GoogleAnalytics.messageTranslate();
}
void resetSelectedDisplayLang() {
_selectedDisplayLang = choreographer.l2Lang;
choreographer.setState();
}
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
@ -7,7 +8,6 @@ import 'package:fluffychat/pangea/choreographer/widgets/it_feedback_card.dart';
import 'package:fluffychat/pangea/choreographer/widgets/translation_finished_flow.dart';
import 'package:fluffychat/pangea/constants/choreo_constants.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -18,112 +18,140 @@ import '../../utils/overlay.dart';
import '../../widgets/igc/word_data_card.dart';
import 'choice_array.dart';
class ITBar extends StatelessWidget {
class ITBar extends StatefulWidget {
final Choreographer choreographer;
const ITBar({super.key, required this.choreographer});
ITController get itController => choreographer.itController;
@override
ITBarState createState() => ITBarState();
}
class ITBarState extends State<ITBar> {
ITController get itController => widget.choreographer.itController;
StreamSubscription? _choreoSub;
@override
void initState() {
// Rebuild the widget each time there's an update from choreo.
_choreoSub = widget.choreographer.stateListener.stream.listen((_) {
setState(() {});
});
super.initState();
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedSize(
duration: itController.willOpen
? const Duration(milliseconds: 2000)
: const Duration(milliseconds: 500),
? const Duration(milliseconds: 2000)
: const Duration(milliseconds: 500),
curve: Curves.fastOutSlowIn,
clipBehavior: Clip.none,
child: !itController.willOpen
? const SizedBox()
: CompositedTransformTarget(
link: choreographer.itBarLinkAndKey.link,
child: AnimatedOpacity(
duration: itController.willOpen
? const Duration(milliseconds: 2000)
: const Duration(milliseconds: 500),
opacity: itController.willOpen ? 1.0 : 0.0,
child: Container(
key: choreographer.itBarLinkAndKey.key,
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? Colors.white
: Colors.black,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppConfig.borderRadius),
topRight: Radius.circular(AppConfig.borderRadius),
),
),
width: double.infinity,
padding: const EdgeInsets.fromLTRB(0, 3, 3, 3),
child: Stack(
children: [
SingleChildScrollView(
child: Column(
children: [
// Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// // Row(
// // mainAxisAlignment: MainAxisAlignment.start,
// // crossAxisAlignment: CrossAxisAlignment.start,
// // children: [
// // CounterDisplay(
// // correct: controller.correctChoices,
// // custom: controller.customChoices,
// // incorrect: controller.incorrectChoices,
// // yellow: controller.wildcardChoices,
// // ),
// // CompositedTransformTarget(
// // link: choreographer.itBotLayerLinkAndKey.link,
// // child: ITBotButton(
// // key: choreographer.itBotLayerLinkAndKey.key,
// // choreographer: choreographer,
// // ),
// // ),
// // ],
// // ),
// ITCloseButton(choreographer: choreographer),
// ],
// ),
// const SizedBox(height: 40.0),
OriginalText(controller: itController),
const SizedBox(height: 7.0),
IntrinsicHeight(
child: Container(
constraints: const BoxConstraints(minHeight: 80),
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Center(
child: itController.choreographer.errorService.isError
? ITError(
error: itController
.choreographer.errorService.error!,
controller: itController,
)
: itController.showChoiceFeedback
? ChoiceFeedbackText(controller: itController)
: itController.isTranslationDone
? TranslationFeedback(
controller: itController,
)
: ITChoices(controller: itController),
),
),
),
],
? const SizedBox()
: CompositedTransformTarget(
link: widget.choreographer.itBarLinkAndKey.link,
child: AnimatedOpacity(
duration: itController.willOpen
? const Duration(milliseconds: 2000)
: const Duration(milliseconds: 500),
opacity: itController.willOpen ? 1.0 : 0.0,
child: Container(
key: widget.choreographer.itBarLinkAndKey.key,
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? Colors.white
: Colors.black,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppConfig.borderRadius),
topRight: Radius.circular(AppConfig.borderRadius),
),
),
Positioned(
top: 0.0,
right: 0.0,
child: ITCloseButton(choreographer: choreographer),
width: double.infinity,
padding: const EdgeInsets.fromLTRB(0, 3, 3, 3),
child: Stack(
children: [
SingleChildScrollView(
child: Column(
children: [
// Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// // Row(
// // mainAxisAlignment: MainAxisAlignment.start,
// // crossAxisAlignment: CrossAxisAlignment.start,
// // children: [
// // CounterDisplay(
// // correct: controller.correctChoices,
// // custom: controller.customChoices,
// // incorrect: controller.incorrectChoices,
// // yellow: controller.wildcardChoices,
// // ),
// // CompositedTransformTarget(
// // link: choreographer.itBotLayerLinkAndKey.link,
// // child: ITBotButton(
// // key: choreographer.itBotLayerLinkAndKey.key,
// // choreographer: choreographer,
// // ),
// // ),
// // ],
// // ),
// ITCloseButton(choreographer: choreographer),
// ],
// ),
// const SizedBox(height: 40.0),
OriginalText(controller: itController),
const SizedBox(height: 7.0),
IntrinsicHeight(
child: Container(
constraints:
const BoxConstraints(minHeight: 80),
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 4.0),
child: Center(
child: itController
.choreographer.errorService.isError
? ITError(
error: itController.choreographer
.errorService.error!,
controller: itController,
)
: itController.showChoiceFeedback
? ChoiceFeedbackText(
controller: itController,
)
: itController.isTranslationDone
? TranslationFeedback(
controller: itController,
)
: ITChoices(
controller: itController,
),
),
),
),
],
),
),
Positioned(
top: 0.0,
right: 0.0,
child:
ITCloseButton(choreographer: widget.choreographer),
),
],
),
],
),
),
),
),
),
);
}
}
@ -199,20 +227,16 @@ class OriginalText extends StatelessWidget {
),
),
),
if (
!controller.isEditingSourceText
&& controller.sourceText != null
)
if (!controller.isEditingSourceText && controller.sourceText != null)
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: controller.nextITStep != null
? 1.0
: 0.0,
opacity: controller.nextITStep != null ? 1.0 : 0.0,
child: IconButton(
onPressed: () => {
if (controller.nextITStep != null) {
controller.setIsEditingSourceText(true),
},
if (controller.nextITStep != null)
{
controller.setIsEditingSourceText(true),
},
},
icon: const Icon(Icons.edit_outlined),
),
@ -309,9 +333,9 @@ class ITChoices extends StatelessWidget {
choices: controller.currentITStep!.continuances.map((e) {
try {
return Choice(
text: e.text.trim(),
color: e.color,
isGold: e.description == "best",
text: e.text.trim(),
color: e.color,
isGold: e.description == "best",
);
} catch (e) {
debugger(when: kDebugMode);

View file

@ -1,56 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../../config/app_config.dart';
import '../../../pages/chat/chat.dart';
class LanguageDisplayToggle extends StatelessWidget {
const LanguageDisplayToggle({
super.key,
required this.controller,
});
final ChatController controller;
get onPressed =>
controller.choreographer.messageOptions.toggleSelectedDisplayLang;
@override
Widget build(BuildContext context) {
// if (!controller.choreographer.translationEnabled) {
// return const SizedBox();
// }
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: controller.choreographer.messageOptions.isTranslationOn
? AppConfig.primaryColor
: null,
),
child: IconButton(
tooltip: L10n.of(context)!.toggleLanguages,
onPressed: onPressed,
icon: const Icon(Icons.translate_outlined),
selectedIcon: const Icon(Icons.translate),
isSelected: controller.choreographer.messageOptions.isTranslationOn,
),
);
// return Tooltip(
// message: L10n.of(context)!.toggleLanguages,
// waitDuration: const Duration(milliseconds: 1000),
// child: FloatingActionButton(
// onPressed: onPressed,
// backgroundColor: Colors.white,
// mini: false,
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(200), // <-- Radius
// ),
// child: LanguageFlag(
// flagUrl: controller
// .choreographer.messageOptions.displayLang?.languageFlag,
// size: 50,
// ),
// ),
// );
}
}

View file

@ -1,22 +1,47 @@
import 'dart:async';
import 'package:fluffychat/pangea/enum/assistance_state_enum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../../pages/chat/chat.dart';
class ChoreographerSendButton extends StatelessWidget {
class ChoreographerSendButton extends StatefulWidget {
const ChoreographerSendButton({
super.key,
required this.controller,
});
final ChatController controller;
@override
State<ChoreographerSendButton> createState() =>
ChoreographerSendButtonState();
}
class ChoreographerSendButtonState extends State<ChoreographerSendButton> {
StreamSubscription? _choreoSub;
@override
void initState() {
// Rebuild the widget each time there's an update from
// choreo. This keeps the spin up-to-date.
_choreoSub =
widget.controller.choreographer.stateListener.stream.listen((_) {
setState(() {});
});
super.initState();
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// commit for cicd
return controller.choreographer.isFetching &&
controller.choreographer.isAutoIGCEnabled
return widget.controller.choreographer.isFetching &&
widget.controller.choreographer.isAutoIGCEnabled
? Container(
height: 56,
width: 56,
@ -28,10 +53,10 @@ class ChoreographerSendButton extends StatelessWidget {
alignment: Alignment.center,
child: IconButton(
icon: const Icon(Icons.send_outlined),
color:
controller.choreographer.assistanceState.stateColor(context),
color: widget.controller.choreographer.assistanceState
.stateColor(context),
onPressed: () {
controller.choreographer.send(context);
widget.controller.choreographer.send(context);
},
tooltip: L10n.of(context)!.send,
),

View file

@ -31,18 +31,16 @@ class ClassController extends BaseController {
setState(data: {"activeSpaceId": classId});
}
Future<void> fixClassPowerLevels() async {
try {
final teacherSpaces =
await _pangeaController.matrixState.client.spacesImTeaching;
final List<Future<void>> classFixes = List<Room>.from(teacherSpaces)
.map((adminSpace) => adminSpace.setClassPowerLevels())
.toList();
await Future.wait(classFixes);
} catch (err, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: stack);
}
/// For all the spaces that the user is teaching, set the power levels
/// to enable all other users to add child rooms to the space.
void fixClassPowerLevels() {
Future.wait(
_pangeaController.matrixState.client.spacesImTeaching.map(
(space) => space.setClassPowerLevels().catchError((err, s) {
ErrorHandler.logError(e: err, s: s);
}),
),
);
}
Future<void> checkForClassCodeAndSubscription(BuildContext context) async {
@ -131,10 +129,10 @@ class ClassController extends BaseController {
_pangeaController.matrixState.client.getRoomById(classChunk.roomId);
// when possible, add user's analytics room the to space they joined
await joinedSpace?.addAnalyticsRoomsToSpace();
joinedSpace?.addAnalyticsRoomsToSpace();
// and invite the space's teachers to the user's analytics rooms
await joinedSpace?.inviteSpaceTeachersToAnalyticsRooms();
joinedSpace?.inviteSpaceTeachersToAnalyticsRooms();
GoogleAnalytics.joinClass(classCode);
return;
} catch (err) {

View file

@ -198,9 +198,7 @@ class AnalyticsController extends BaseController {
// gets all the summary analytics events for the students
// in a space since the current timespace's cut off date
// ensure that all the space's events are loaded (mainly the for langCode)
// and that the participants are loaded
await space.postLoad();
// ensure that the participants of the space are loaded
await space.requestParticipants();
// TODO switch to using list of futures
@ -439,7 +437,6 @@ class AnalyticsController extends BaseController {
timeSpan: currentAnalyticsTimeSpan,
);
}
await space.postLoad();
}
DateTime? lastUpdated;
@ -545,7 +542,6 @@ class AnalyticsController extends BaseController {
Future<List<ConstructAnalyticsEvent>> allSpaceMemberConstructs(
Room space,
) async {
await space.postLoad();
await space.requestParticipants();
final List<ConstructAnalyticsEvent> constructEvents = [];
for (final student in space.students) {
@ -788,7 +784,6 @@ class AnalyticsController extends BaseController {
);
return [];
}
await space.postLoad();
}
DateTime? lastUpdated;

View file

@ -81,8 +81,7 @@ class PangeaController {
BuildContext context,
) async {
await classController.checkForClassCodeAndSubscription(context);
// startChatWithBotIfNotPresent();
await classController.fixClassPowerLevels();
classController.fixClassPowerLevels();
}
/// Initialize controllers

View file

@ -1,5 +1,4 @@
import 'package:fluffychat/pangea/constants/age_limits.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
@ -36,63 +35,73 @@ class PermissionsController extends BaseController {
return dob?.isAtLeastYearsOld(AgeLimits.toAccessFeatures) ?? false;
}
/// A user can private chat if
/// 1) they are 18 and outside a class context or
/// 2) they are in a class context and the class rules permit it
/// If no class is passed, uses classController.activeClass
/// A user can private chat if they are 18+
bool canUserPrivateChat({String? roomID}) {
final Room? classContext =
firstRoomWithState(roomID: roomID, type: PangeaEventTypes.rules);
return classContext?.pangeaRoomRules == null
? isUser18()
: classContext!.pangeaRoomRules!.oneToOneChatClass ||
classContext.isRoomAdmin;
return isUser18();
// Rules can't be edited; default to true
// final Room? classContext =
// firstRoomWithState(roomID: roomID, type: PangeaEventTypes.rules);
// return classContext?.pangeaRoomRules == null
// ? isUser18()
// : classContext!.pangeaRoomRules!.oneToOneChatClass ||
// classContext.isRoomAdmin;
}
bool canUserGroupChat({String? roomID}) {
final Room? classContext =
firstRoomWithState(roomID: roomID, type: PangeaEventTypes.rules);
return isUser18();
// Rules can't be edited; default to true
// final Room? classContext =
// firstRoomWithState(roomID: roomID, type: PangeaEventTypes.rules);
return classContext?.pangeaRoomRules == null
? isUser18()
: classContext!.pangeaRoomRules!.isCreateRooms ||
classContext.isRoomAdmin;
// return classContext?.pangeaRoomRules == null
// ? isUser18()
// : classContext!.pangeaRoomRules!.isCreateRooms ||
// classContext.isRoomAdmin;
}
bool showChatInputAddButton(String roomId) {
final PangeaRoomRules? perms = _getRoomRules(roomId);
if (perms == null) return isUser18();
return perms.isShareFiles ||
perms.isShareLocation ||
perms.isSharePhoto ||
perms.isShareVideo;
// Rules can't be edited; default to true
// final PangeaRoomRules? perms = _getRoomRules(roomId);
// if (perms == null) return isUser18();
// return perms.isShareFiles ||
// perms.isShareLocation ||
// perms.isSharePhoto ||
// perms.isShareVideo;
return isUser18();
}
/// works for both roomID of chat and class
bool canShareVideo(String? roomID) =>
_getRoomRules(roomID)?.isShareVideo ?? isUser18();
bool canShareVideo(String? roomID) => isUser18();
// Rules can't be edited; default to true
// _getRoomRules(roomID)?.isShareVideo ?? isUser18();
/// works for both roomID of chat and class
bool canSharePhoto(String? roomID) =>
_getRoomRules(roomID)?.isSharePhoto ?? isUser18();
bool canSharePhoto(String? roomID) => isUser18();
// Rules can't be edited; default to true
// _getRoomRules(roomID)?.isSharePhoto ?? isUser18();
/// works for both roomID of chat and class
bool canShareFile(String? roomID) =>
_getRoomRules(roomID)?.isShareFiles ?? isUser18();
bool canShareFile(String? roomID) => isUser18();
// Rules can't be edited; default to true
// _getRoomRules(roomID)?.isShareFiles ?? isUser18();
/// works for both roomID of chat and class
bool canShareLocation(String? roomID) =>
_getRoomRules(roomID)?.isShareLocation ?? isUser18();
bool canShareLocation(String? roomID) => isUser18();
// Rules can't be edited; default to true
// _getRoomRules(roomID)?.isShareLocation ?? isUser18();
int? classLanguageToolPermission(Room room, ToolSetting setting) =>
room.firstRules?.getToolSettings(setting);
int? classLanguageToolPermission(Room room, ToolSetting setting) => 1;
// Rules can't be edited; default to student choice
// room.firstRules?.getToolSettings(setting);
//what happens if a room isn't in a class?
// what happens if a room isn't in a class?
bool isToolDisabledByClass(ToolSetting setting, Room? room) {
if (room?.isSpaceAdmin ?? false) return false;
final int? classPermission =
room != null ? classLanguageToolPermission(room, setting) : 1;
return classPermission == 0;
return false;
// Rules can't be edited; default to false
// if (room?.isSpaceAdmin ?? false) return false;
// final int? classPermission =
// room != null ? classLanguageToolPermission(room, setting) : 1;
// return classPermission == 0;
}
bool userToolSetting(ToolSetting setting) {
@ -117,18 +126,22 @@ class PermissionsController extends BaseController {
}
bool isToolEnabled(ToolSetting setting, Room? room) {
if (room?.isSpaceAdmin ?? false) {
return userToolSetting(setting);
}
final int? classPermission =
room != null ? classLanguageToolPermission(room, setting) : 1;
if (classPermission == 0) return false;
if (classPermission == 2) return true;
// Rules can't be edited; default to true
return userToolSetting(setting);
// if (room?.isSpaceAdmin ?? false) {
// return userToolSetting(setting);
// }
// final int? classPermission =
// room != null ? classLanguageToolPermission(room, setting) : 1;
// if (classPermission == 0) return false;
// if (classPermission == 2) return true;
// return userToolSetting(setting);
}
bool isWritingAssistanceEnabled(Room? room) {
return isToolEnabled(ToolSetting.interactiveTranslator, room) &&
isToolEnabled(ToolSetting.interactiveGrammar, room);
// Rules can't be edited; default to true
return true;
// return isToolEnabled(ToolSetting.interactiveTranslator, room) &&
// isToolEnabled(ToolSetting.interactiveGrammar, room);
}
}

View file

@ -1,48 +1,40 @@
part of "client_extension.dart";
extension AnalyticsClientExtension on Client {
// get analytics room matching targetlanguage
// if not present, create it and invite teachers of that language
// set description to let people know what the hell it is
/// Get the logged in user's analytics room matching
/// a given langCode. If not present, create it.
Future<Room> _getMyAnalyticsRoom(String langCode) async {
await roomsLoading;
// ensure room state events (room create,
// to check for analytics type) are loaded
for (final room in rooms) {
if (room.partial) await room.postLoad();
}
final Room? analyticsRoom = analyticsRoomLocal(langCode);
final Room? analyticsRoom = _analyticsRoomLocal(langCode);
if (analyticsRoom != null) return analyticsRoom;
return _makeAnalyticsRoom(langCode);
}
//note: if langCode is null and user has >1 analyticsRooms then this could
//return the wrong one. this is to account for when an exchange might not
//be in a class.
Room? _analyticsRoomLocal(String? langCode, [String? userIdParam]) {
/// Get local analytics room for a given langCode and
/// optional userId (if not specified, uses current user).
/// If user is invited to the room, joins the room.
Room? _analyticsRoomLocal(String langCode, [String? userIdParam]) {
final Room? analyticsRoom = rooms.firstWhereOrNull((e) {
return e.isAnalyticsRoom &&
e.isAnalyticsRoomOfUser(userIdParam ?? userID!) &&
(langCode != null ? e.isMadeForLang(langCode) : true);
e.isMadeForLang(langCode);
});
if (analyticsRoom != null &&
analyticsRoom.membership == Membership.invite) {
debugger(when: kDebugMode);
analyticsRoom
.join()
.onError(
analyticsRoom.join().onError(
(error, stackTrace) =>
ErrorHandler.logError(e: error, s: stackTrace),
)
.then((value) => analyticsRoom.postLoad());
);
return analyticsRoom;
}
return analyticsRoom;
}
/// Creates an analytics room with the specified language code and returns the created room.
/// Additionally, the room is added to the user's spaces and all teachers are invited to the room.
///
/// If the room does not appear immediately after creation, this method waits for it to appear in sync.
/// Returns the created [Room] object.
Future<Room> _makeAnalyticsRoom(String langCode) async {
final String roomID = await createRoom(
creationContent: {
@ -53,7 +45,6 @@ extension AnalyticsClientExtension on Client {
topic: "This room stores learning analytics for $userID.",
invite: [
...(await myTeachers).map((e) => e.id),
// BotName.localBot,
BotName.byEnvironment,
],
);
@ -66,14 +57,14 @@ extension AnalyticsClientExtension on Client {
// add this analytics room to all spaces so teachers can join them
// via the space hierarchy
await analyticsRoom?.addAnalyticsRoomToSpaces();
analyticsRoom?.addAnalyticsRoomToSpaces();
// and invite all teachers to new analytics room
await analyticsRoom?.inviteTeachersToAnalyticsRoom();
analyticsRoom?.inviteTeachersToAnalyticsRoom();
return getRoomById(roomID)!;
}
// Get all my analytics rooms
/// Get all my analytics rooms
List<Room> get _allMyAnalyticsRooms => rooms
.where(
(e) => e.isAnalyticsRoomOfUser(userID!),
@ -83,76 +74,77 @@ extension AnalyticsClientExtension on Client {
// migration function to change analytics rooms' vsibility to public
// so they will appear in the space hierarchy
Future<void> _updateAnalyticsRoomVisibility() async {
final List<Future> makePublicFutures = [];
for (final Room room in allMyAnalyticsRooms) {
final visability = await getRoomVisibilityOnDirectory(room.id);
if (visability != Visibility.public) {
await setRoomVisibilityOnDirectory(
room.id,
visibility: Visibility.public,
);
}
}
await Future.wait(makePublicFutures);
await Future.wait(
allMyAnalyticsRooms.map((room) async {
final visability = await getRoomVisibilityOnDirectory(room.id);
if (visability != Visibility.public) {
await setRoomVisibilityOnDirectory(
room.id,
visibility: Visibility.public,
);
}
}),
);
}
// Add all the users' analytics room to all the spaces the student studies in
// So teachers can join them via space hierarchy
// Will not always work, as there may be spaces where students don't have permission to add chats
// But allows teachers to join analytics rooms without being invited
Future<void> _addAnalyticsRoomsToAllSpaces() async {
final List<Future> addFutures = [];
/// Add all the users' analytics room to all the spaces the user is studying in
/// so teachers can join them via space hierarchy.
/// Allows teachers to join analytics rooms without being invited.
void _addAnalyticsRoomsToAllSpaces() {
for (final Room room in allMyAnalyticsRooms) {
addFutures.add(room.addAnalyticsRoomToSpaces());
room.addAnalyticsRoomToSpaces();
}
await Future.wait(addFutures);
}
// Invite teachers to all my analytics room
// Handles case when students cannot add analytics room to space(s)
// So teacher is still able to get analytics data for this student
Future<void> _inviteAllTeachersToAllAnalyticsRooms() async {
final List<Future> inviteFutures = [];
for (final Room analyticsRoom in allMyAnalyticsRooms) {
inviteFutures.add(analyticsRoom.inviteTeachersToAnalyticsRoom());
/// Invite teachers to all my analytics room.
/// Handles case when students cannot add analytics room to space(s)
/// so teacher is still able to get analytics data for this student
void _inviteAllTeachersToAllAnalyticsRooms() {
for (final Room room in allMyAnalyticsRooms) {
room.inviteTeachersToAnalyticsRoom();
}
await Future.wait(inviteFutures);
}
// Join all analytics rooms in all spaces
// Allows teachers to join analytics rooms without being invited
Future<void> _joinAnalyticsRoomsInAllSpaces() async {
final List<Future> joinFutures = [];
for (final Room space in (await _spacesImTeaching)) {
joinFutures.add(space.joinAnalyticsRoomsInSpace());
}
await Future.wait(joinFutures);
}
// Join invited analytics rooms
// Checks for invites to any student analytics rooms
// Handles case of analytics rooms that can't be added to some space(s)
Future<void> _joinInvitedAnalyticsRooms() async {
final List<Room> allRooms = List.from(rooms);
for (final Room room in allRooms) {
if (room.membership == Membership.invite && room.isAnalyticsRoom) {
try {
await room.join();
} catch (err) {
debugPrint("Failed to join analytics room ${room.id}");
}
}
for (final Room space in _spacesImTeaching) {
// Each call to joinAnalyticsRoomsInSpace calls getSpaceHierarchy, which has a
// strict rate limit. So we wait a second between each call to prevent a 429 error.
await Future.delayed(
const Duration(seconds: 1),
() => space.joinAnalyticsRoomsInSpace(),
);
}
}
// helper function to join all relevant analytics rooms
// and set up those rooms to be joined by relevant teachers
Future<void> _migrateAnalyticsRooms() async {
await _updateAnalyticsRoomVisibility();
await _addAnalyticsRoomsToAllSpaces();
await _inviteAllTeachersToAllAnalyticsRooms();
await _joinInvitedAnalyticsRooms();
await _joinAnalyticsRoomsInAllSpaces();
/// Join invited analytics rooms.
/// Checks for invites to any student analytics rooms.
/// Handles case of analytics rooms that can't be added to some space(s).
void _joinInvitedAnalyticsRooms() {
Future.wait(
rooms
.where(
(room) =>
room.membership == Membership.invite && room.isAnalyticsRoom,
)
.map(
(room) => room.join().catchError((err, s) {
ErrorHandler.logError(e: err, s: s);
}),
),
);
}
/// Helper function to join all relevant analytics rooms
/// and set up those rooms to be joined by other users.
void _migrateAnalyticsRooms() {
_updateAnalyticsRoomVisibility().then((_) {
_addAnalyticsRoomsToAllSpaces();
_inviteAllTeachersToAllAnalyticsRooms();
_joinInvitedAnalyticsRooms();
_joinAnalyticsRoomsInAllSpaces();
});
}
Future<Map<String, DateTime?>> _allAnalyticsRoomsLastUpdated() async {

View file

@ -1,7 +1,6 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
@ -20,10 +19,15 @@ part "space_extension.dart";
extension PangeaClient on Client {
// analytics
/// Get the logged in user's analytics room matching
/// a given langCode. If not present, create it.
Future<Room> getMyAnalyticsRoom(String langCode) async =>
await _getMyAnalyticsRoom(langCode);
Room? analyticsRoomLocal(String? langCode, [String? userIdParam]) =>
/// Get local analytics room for a given langCode and
/// optional userId (if not specified, uses current user).
/// If user is invited to the room, joins the room.
Room? analyticsRoomLocal(String langCode, [String? userIdParam]) =>
_analyticsRoomLocal(langCode, userIdParam);
List<Room> get allMyAnalyticsRooms => _allMyAnalyticsRooms;
@ -31,35 +35,24 @@ extension PangeaClient on Client {
Future<void> updateAnalyticsRoomVisibility() async =>
await _updateAnalyticsRoomVisibility();
Future<void> addAnalyticsRoomsToAllSpaces() async =>
await _addAnalyticsRoomsToAllSpaces();
Future<void> inviteAllTeachersToAllAnalyticsRooms() async =>
await _inviteAllTeachersToAllAnalyticsRooms();
Future<void> joinAnalyticsRoomsInAllSpaces() async =>
await _joinAnalyticsRoomsInAllSpaces();
Future<void> joinInvitedAnalyticsRooms() async =>
await _joinInvitedAnalyticsRooms();
Future<void> migrateAnalyticsRooms() async => await _migrateAnalyticsRooms();
/// Helper function to join all relevant analytics rooms
/// and set up those rooms to be joined by other users.
void migrateAnalyticsRooms() => _migrateAnalyticsRooms();
Future<Map<String, DateTime?>> allAnalyticsRoomsLastUpdated() async =>
await _allAnalyticsRoomsLastUpdated();
// spaces
Future<List<Room>> get spacesImTeaching async => await _spacesImTeaching;
List<Room> get spacesImTeaching => _spacesImTeaching;
Future<List<Room>> get chatsImAStudentIn async => await _chatsImAStudentIn;
Future<List<Room>> get spaceImAStudentIn async => await _spacesImStudyingIn;
List<Room> get spacesImAStudentIn => _spacesImStudyingIn;
List<Room> get spacesImIn => _spacesImIn;
Future<PangeaRoomRules?> get lastUpdatedRoomRules async =>
await _lastUpdatedRoomRules;
PangeaRoomRules? get lastUpdatedRoomRules => _lastUpdatedRoomRules;
// general_info

View file

@ -1,23 +1,8 @@
part of "client_extension.dart";
extension SpaceClientExtension on Client {
Future<List<Room>> get _spacesImTeaching async {
final allSpaces = rooms.where((room) => room.isSpace);
for (final Room space in allSpaces) {
if (space.getState(EventTypes.RoomPowerLevels) == null) {
await space.postLoad();
}
}
final spaces = rooms
.where(
(e) =>
(e.isSpace) &&
e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin,
)
.toList();
return spaces;
}
List<Room> get _spacesImTeaching =>
rooms.where((e) => e.isSpace && e.isRoomAdmin).toList();
Future<List<Room>> get _chatsImAStudentIn async {
final List<String> nowteacherRoomIds = await teacherRoomIds;
@ -31,39 +16,18 @@ extension SpaceClientExtension on Client {
.toList();
}
Future<List<Room>> get _spacesImStudyingIn async {
final List<Room> joinedSpaces = rooms
.where(
(room) => room.isSpace && room.membership == Membership.join,
)
.toList();
for (final Room space in joinedSpaces) {
if (space.getState(EventTypes.RoomPowerLevels) == null) {
await space.postLoad();
}
}
final spaces = rooms
.where(
(e) =>
e.isSpace &&
e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin,
)
.toList();
return spaces;
}
List<Room> get _spacesImStudyingIn =>
rooms.where((e) => e.isSpace && !e.isRoomAdmin).toList();
List<Room> get _spacesImIn => rooms.where((e) => e.isSpace).toList();
Future<PangeaRoomRules?> get _lastUpdatedRoomRules async =>
(await _spacesImTeaching)
.where((space) => space.rulesUpdatedAt != null)
.sorted(
(a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!),
)
.firstOrNull
?.pangeaRoomRules;
PangeaRoomRules? get _lastUpdatedRoomRules => _spacesImTeaching
.where((space) => space.rulesUpdatedAt != null)
.sorted(
(a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!),
)
.firstOrNull
?.pangeaRoomRules;
// LanguageSettingsModel? get _lastUpdatedLanguageSettings => rooms
// .where((room) => room.isSpace && room.languageSettingsUpdatedAt != null)

View file

@ -1,6 +1,8 @@
part of "pangea_room_extension.dart";
extension ChildrenAndParentsRoomExtension on Room {
bool get _isSubspace => _pangeaSpaceParents.isNotEmpty;
//note this only will return rooms that the user has joined or been invited to
List<Room> get _joinedChildren {
if (!isSpace) return [];
@ -91,7 +93,7 @@ extension ChildrenAndParentsRoomExtension on Room {
String _nameIncludingParents(BuildContext context) {
String nameSoFar = getLocalizedDisplayname(MatrixLocals(L10n.of(context)!));
Room currentRoom = this;
if (currentRoom.pangeaSpaceParents.isEmpty) {
if (!currentRoom._isSubspace) {
return nameSoFar;
}
currentRoom = currentRoom.pangeaSpaceParents.first;
@ -100,7 +102,7 @@ extension ChildrenAndParentsRoomExtension on Room {
nameToAdd =
nameToAdd.length <= 10 ? nameToAdd : "${nameToAdd.substring(0, 10)}...";
nameSoFar = '$nameToAdd > $nameSoFar';
if (currentRoom.pangeaSpaceParents.isEmpty) {
if (!currentRoom._isSubspace) {
return nameSoFar;
}
return "... > $nameSoFar";
@ -129,7 +131,8 @@ extension ChildrenAndParentsRoomExtension on Room {
spaceMode = child?.isSpace ?? spaceMode;
// get the bool for adding chats to spaces
final bool canAddChild = _canIAddSpaceChild(child, spaceMode: spaceMode);
final bool canAddChild =
(child?.isRoomAdmin ?? true) && canSendEvent(EventTypes.SpaceChild);
if (!spaceMode) return canAddChild;
// if adding space to a space, check if the child is an ancestor
@ -161,4 +164,14 @@ extension ChildrenAndParentsRoomExtension on Room {
await setSpaceChild(roomId, suggested: suggested);
}
}
/// A map of child suggestion status for a space.
Map<String, bool> get _spaceChildSuggestionStatus {
if (!isSpace) return {};
final Map<String, bool> suggestionStatus = {};
for (final child in spaceChildren) {
suggestionStatus[child.roomId!] = child.suggested ?? true;
}
return suggestionStatus;
}
}

View file

@ -2,7 +2,6 @@ part of "pangea_room_extension.dart";
extension EventsRoomExtension on Room {
Future<bool> _leaveIfFull() async {
await postLoad();
if (!isRoomAdmin &&
(_capacity != null) &&
(await _numNonAdmins) > (_capacity!)) {

View file

@ -49,26 +49,35 @@ part "user_permissions_extension.dart";
extension PangeaRoom on Room {
// analytics
/// Join analytics rooms in space.
/// Allows teachers to join analytics rooms without being invited.
Future<void> joinAnalyticsRoomsInSpace() async =>
await _joinAnalyticsRoomsInSpace();
Future<void> addAnalyticsRoomToSpace(Room analyticsRoom) async =>
await _addAnalyticsRoomToSpace(analyticsRoom);
Future<void> addAnalyticsRoomToSpaces() async =>
await _addAnalyticsRoomToSpaces();
/// Add analytics room to all spaces the user is a student in (1 analytics room to all spaces).
/// Enables teachers to join student analytics rooms via space hierarchy.
/// Will not always work, as there may be spaces where students don't have permission to add chats,
/// but allows teachers to join analytics rooms without being invited.
void addAnalyticsRoomToSpaces() => _addAnalyticsRoomToSpaces();
Future<void> addAnalyticsRoomsToSpace() async =>
await _addAnalyticsRoomsToSpace();
/// Add all the user's analytics rooms to 1 space.
void addAnalyticsRoomsToSpace() => _addAnalyticsRoomsToSpace();
/// Invite teachers of 1 space to 1 analytics room
Future<void> inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async =>
await _inviteSpaceTeachersToAnalyticsRoom(analyticsRoom);
Future<void> inviteTeachersToAnalyticsRoom() async =>
await _inviteTeachersToAnalyticsRoom();
/// Invite all the user's teachers to 1 analytics room.
/// Handles case when students cannot add analytics room to space
/// so teacher is still able to get analytics data for this student.
void inviteTeachersToAnalyticsRoom() => _inviteTeachersToAnalyticsRoom();
Future<void> inviteSpaceTeachersToAnalyticsRooms() async =>
await _inviteSpaceTeachersToAnalyticsRooms();
/// Invite teachers of 1 space to all users' analytics rooms
void inviteSpaceTeachersToAnalyticsRooms() =>
_inviteSpaceTeachersToAnalyticsRooms();
Future<AnalyticsEvent?> getLastAnalyticsEvent(
String type,
@ -122,6 +131,19 @@ extension PangeaRoom on Room {
}) async =>
await _pangeaSetSpaceChild(roomId, suggested: suggested);
/// Returns a map of child suggestion status for a space.
///
/// If the current object is not a space, an empty map is returned.
/// Otherwise, it iterates through each child in the `spaceChildren` list
/// and adds their suggestion status to the `suggestionStatus` map.
/// The suggestion status is determined by the `suggested` property of each child.
/// If the `suggested` property is `null`, it defaults to `true`.
Map<String, bool> get spaceChildSuggestionStatus =>
_spaceChildSuggestionStatus;
/// Checks if this space has a parent space
bool get isSubspace => _isSubspace;
// class_and_exchange_settings
DateTime? get rulesUpdatedAt => _rulesUpdatedAt;
@ -134,6 +156,12 @@ extension PangeaRoom on Room {
Future<List<User>> get teachers async => await _teachers;
/// Synchronous version of teachers getter. Does not request
/// participants, so this list may not be complete.
List<User> get teachersLocal => _teachersLocal;
/// If the user is an admin of this space, and the space's
/// m.space.child power level hasn't yet been set, so it to 0
Future<void> setClassPowerLevels() async => await _setClassPowerLevels();
Event? get pangeaRoomRulesStateEvent => _pangeaRoomRulesStateEvent;
@ -278,10 +306,6 @@ extension PangeaRoom on Room {
bool get canDelete => _canDelete;
bool canIAddSpaceChild(Room? room, {bool spaceMode = false}) {
return _canIAddSpaceChild(room, spaceMode: spaceMode);
}
bool get canIAddSpaceParents => _canIAddSpaceParents;
bool pangeaCanSendEvent(String eventType) => _pangeaCanSendEvent(eventType);

View file

@ -1,57 +1,44 @@
part of "pangea_room_extension.dart";
extension AnalyticsRoomExtension on Room {
// Join analytics rooms in space
// Allows teachers to join analytics rooms without being invited
/// Join analytics rooms in space.
/// Allows teachers to join analytics rooms without being invited.
Future<void> _joinAnalyticsRoomsInSpace() async {
if (!isSpace) {
debugPrint("joinAnalyticsRoomsInSpace called on non-space room");
Sentry.addBreadcrumb(
Breadcrumb(
message: "joinAnalyticsRoomsInSpace called on non-space room",
),
);
return;
}
// added delay because without it power levels don't load and user is not
// recognized as admin
await Future.delayed(const Duration(milliseconds: 500));
await postLoad();
if (!isRoomAdmin) {
debugPrint("joinAnalyticsRoomsInSpace called by non-admin");
Sentry.addBreadcrumb(
Breadcrumb(
message: "joinAnalyticsRoomsInSpace called by non-admin",
),
);
return;
}
final spaceHierarchy = await client.getSpaceHierarchy(
id,
maxDepth: 1,
);
final List<String> analyticsRoomIds = spaceHierarchy.rooms
.where(
(r) => r.roomType == PangeaRoomTypes.analytics,
)
.map((r) => r.roomId)
.toList();
for (final String roomID in analyticsRoomIds) {
try {
await joinSpaceChild(roomID);
} catch (err, s) {
debugPrint("Failed to join analytics room $roomID in space $id");
ErrorHandler.logError(
e: err,
m: "Failed to join analytics room $roomID in space $id",
s: s,
);
try {
if (!isSpace) {
debugger(when: kDebugMode);
return;
}
if (!isRoomAdmin) return;
final spaceHierarchy = await client.getSpaceHierarchy(
id,
maxDepth: 1,
);
final List<String> analyticsRoomIds = spaceHierarchy.rooms
.where((r) => r.roomType == PangeaRoomTypes.analytics)
.map((r) => r.roomId)
.toList();
await Future.wait(
analyticsRoomIds.map(
(roomID) => joinSpaceChild(roomID).catchError((err, s) {
debugPrint("Failed to join analytics room $roomID in space $id");
ErrorHandler.logError(
e: err,
m: "Failed to join analytics room $roomID in space $id",
s: s,
);
}),
),
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
);
return;
}
}
@ -67,124 +54,90 @@ extension AnalyticsRoomExtension on Room {
return Future.value();
}
// Checks that user has permission to add child to space
if (!canSendEvent(EventTypes.SpaceChild)) return;
if (spaceChildren.any((sc) => sc.roomId == analyticsRoom.id)) return;
if (canIAddSpaceChild(null)) {
try {
await setSpaceChild(analyticsRoom.id);
} catch (err) {
debugPrint(
"Failed to add analytics room ${analyticsRoom.id} for student to space $id",
);
Sentry.addBreadcrumb(
Breadcrumb(
message: "Failed to add analytics room to space $id",
),
);
}
}
}
// Add analytics room to all spaces the user is a student in (1 analytics room to all spaces)
// So teachers can join them via space hierarchy
// Will not always work, as there may be spaces where students don't have permission to add chats
// But allows teachers to join analytics rooms without being invited
Future<void> _addAnalyticsRoomToSpaces() async {
if (!isAnalyticsRoomOfUser(client.userID!)) {
debugPrint("addAnalyticsRoomToSpaces called on non-analytics room");
Sentry.addBreadcrumb(
Breadcrumb(
message: "addAnalyticsRoomToSpaces called on non-analytics room",
),
);
return;
}
for (final Room space in (await client.spaceImAStudentIn)) {
if (space.spaceChildren.any((sc) => sc.roomId == id)) continue;
await space.addAnalyticsRoomToSpace(this);
}
}
// Add all analytics rooms to space
// Similar to addAnalyticsRoomToSpaces, but all analytics room to 1 space
Future<void> _addAnalyticsRoomsToSpace() async {
await postLoad();
final List<Room> allMyAnalyticsRooms = client.allMyAnalyticsRooms;
for (final Room analyticsRoom in allMyAnalyticsRooms) {
await addAnalyticsRoomToSpace(analyticsRoom);
}
}
// invite teachers of 1 space to 1 analytics room
Future<void> _inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async {
if (!isSpace) {
try {
await setSpaceChild(analyticsRoom.id);
} catch (err) {
debugPrint(
"inviteSpaceTeachersToAnalyticsRoom called on non-space room",
"Failed to add analytics room ${analyticsRoom.id} for student to space $id",
);
Sentry.addBreadcrumb(
Breadcrumb(
message:
"inviteSpaceTeachersToAnalyticsRoom called on non-space room",
message: "Failed to add analytics room to space $id",
),
);
return;
}
}
/// Add analytics room to all spaces the user is a student in (1 analytics room to all spaces).
/// Enables teachers to join student analytics rooms via space hierarchy.
/// Will not always work, as there may be spaces where students don't have permission to add chats,
/// but allows teachers to join analytics rooms without being invited.
void _addAnalyticsRoomToSpaces() {
if (!isAnalyticsRoomOfUser(client.userID!)) return;
Future.wait(
client.spacesImAStudentIn
.where((space) => !space.spaceChildren.any((sc) => sc.roomId == id))
.map((space) => space.addAnalyticsRoomToSpace(this)),
);
}
/// Add all the user's analytics rooms to 1 space.
void _addAnalyticsRoomsToSpace() {
Future.wait(
client.allMyAnalyticsRooms.map((room) => addAnalyticsRoomToSpace(room)),
);
}
/// Invite teachers of 1 space to 1 analytics room
Future<void> _inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async {
if (!isSpace) return;
if (!analyticsRoom.participantListComplete) {
await analyticsRoom.requestParticipants();
}
final List<User> participants = analyticsRoom.getParticipants();
for (final User teacher in (await teachers)) {
if (!participants.any((p) => p.id == teacher.id)) {
try {
await analyticsRoom.invite(teacher.id);
} catch (err, s) {
debugPrint(
"Failed to invite teacher ${teacher.id} to analytics room ${analyticsRoom.id}",
);
ErrorHandler.logError(
e: err,
m: "Failed to invite teacher ${teacher.id} to analytics room ${analyticsRoom.id}",
s: s,
);
}
}
final List<User> uninvitedTeachers = teachersLocal
.where((teacher) => !participants.contains(teacher))
.toList();
if (analyticsRoom.canSendEvent(EventTypes.RoomMember)) {
Future.wait(
uninvitedTeachers.map(
(teacher) => analyticsRoom.invite(teacher.id).catchError((err, s) {
ErrorHandler.logError(
e: err,
m: "Failed to invite teacher ${teacher.id} to analytics room ${analyticsRoom.id}",
s: s,
);
}),
),
);
}
}
// Invite all teachers to 1 analytics room
// Handles case when students cannot add analytics room to space
// So teacher is still able to get analytics data for this student
Future<void> _inviteTeachersToAnalyticsRoom() async {
if (client.userID == null) {
debugPrint("inviteTeachersToAnalyticsRoom called with null userId");
Sentry.addBreadcrumb(
Breadcrumb(
message: "inviteTeachersToAnalyticsRoom called with null userId",
),
);
return;
}
if (!isAnalyticsRoomOfUser(client.userID!)) {
debugPrint("inviteTeachersToAnalyticsRoom called on non-analytics room");
Sentry.addBreadcrumb(
Breadcrumb(
message: "inviteTeachersToAnalyticsRoom called on non-analytics room",
),
);
return;
}
for (final Room space in (await client.spaceImAStudentIn)) {
await space.inviteSpaceTeachersToAnalyticsRoom(this);
}
/// Invite all the user's teachers to 1 analytics room.
/// Handles case when students cannot add analytics room to space
/// so teacher is still able to get analytics data for this student.
void _inviteTeachersToAnalyticsRoom() {
if (client.userID == null || !isAnalyticsRoomOfUser(client.userID!)) return;
Future.wait(
client.spacesImAStudentIn.map(
(space) => inviteSpaceTeachersToAnalyticsRoom(this),
),
);
}
// Invite teachers of 1 space to all users' analytics rooms
Future<void> _inviteSpaceTeachersToAnalyticsRooms() async {
for (final Room analyticsRoom in client.allMyAnalyticsRooms) {
await inviteSpaceTeachersToAnalyticsRoom(analyticsRoom);
}
/// Invite teachers of 1 space to all users' analytics rooms
void _inviteSpaceTeachersToAnalyticsRooms() {
Future.wait(
client.allMyAnalyticsRooms.map(
(room) => inviteSpaceTeachersToAnalyticsRoom(room),
),
);
}
Future<AnalyticsEvent?> _getLastAnalyticsEvent(

View file

@ -55,27 +55,39 @@ extension SpaceRoomExtension on Room {
: participants;
}
/// Synchronous version of _teachers. Does not request participants, so this list may not be complete.
List<User> get _teachersLocal {
if (!isSpace) return [];
return getParticipants()
.where(
(e) =>
e.powerLevel == ClassDefaultValues.powerLevelOfAdmin &&
e.id != BotName.byEnvironment,
)
.toList();
}
/// If the user is an admin of this space, and the space's
/// m.space.child power level hasn't yet been set, so it to 0
Future<void> _setClassPowerLevels() async {
try {
if (ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin) {
return;
}
if (!isRoomAdmin) return;
final dynamic currentPower = getState(EventTypes.RoomPowerLevels);
if (currentPower is! Event?) {
return;
}
final Map<String, dynamic>? currentPowerContent =
if (currentPower is! Event?) return;
final currentPowerContent =
currentPower?.content["events"] as Map<String, dynamic>?;
final spaceChildPower = currentPowerContent?[EventTypes.SpaceChild];
if (spaceChildPower == null && currentPowerContent != null) {
currentPowerContent["events"][EventTypes.SpaceChild] = 0;
currentPowerContent[EventTypes.SpaceChild] = 0;
currentPower!.content["events"] = currentPowerContent;
await client.setRoomStateWithKey(
id,
EventTypes.RoomPowerLevels,
currentPower?.stateKey ?? "",
currentPowerContent,
currentPower.stateKey ?? "",
currentPower.content,
);
}
} catch (err, s) {

View file

@ -78,36 +78,10 @@ extension UserPermissionsRoomExtension on Room {
bool get _canDelete => isSpaceAdmin;
bool _canIAddSpaceChild(Room? room, {bool spaceMode = false}) {
if (!isSpace) {
ErrorHandler.logError(
m: "should not call canIAddSpaceChildren on non-space room. Room id: $id",
data: toJson(),
s: StackTrace.current,
);
return false;
}
final isSpaceAdmin = isRoomAdmin;
final isChildRoomAdmin = room?.isRoomAdmin ?? true;
// if user is not admin of child room, return false
if (!isChildRoomAdmin) return false;
// if the child room is a space, or will be a space,
// then the user must be an admin of the parent space
if (room?.isSpace ?? spaceMode) return isSpaceAdmin;
// otherwise, the user can add the child room to the parent
// if they're the admin of the parent or if the parent creation
// of group chats
return isSpaceAdmin || (pangeaRoomRules?.isCreateRooms ?? false);
}
bool get _canIAddSpaceParents =>
_isRoomAdmin || pangeaCanSendEvent(EventTypes.SpaceParent);
//overriding the default canSendEvent to check power levels
// Overriding the default canSendEvent to check power levels
bool _pangeaCanSendEvent(String eventType) {
final powerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content;
if (powerLevelsMap == null) return 0 <= ownPowerLevel;

View file

@ -82,10 +82,9 @@ class PangeaMessageEvent {
.firstOrNull ??
_event;
Event updateLatestEdit() {
void updateLatestEdit() {
_latestEditCache = null;
_representations = null;
return _latestEdit;
}
Future<PangeaAudioFile> getMatrixAudioFile(

View file

@ -13,36 +13,40 @@ class AnalyticsViewButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<BarChartViewSelection>(
tooltip: L10n.of(context)!.changeAnalyticsView,
initialValue: value,
onSelected: (BarChartViewSelection? view) {
if (view == null) {
debugPrint("when is view null?");
return;
}
onChange(view);
},
itemBuilder: (BuildContext context) => BarChartViewSelection.values
.map<PopupMenuEntry<BarChartViewSelection>>(
(BarChartViewSelection view) {
return PopupMenuItem<BarChartViewSelection>(
value: view,
child: Text(view.string(context)),
);
}).toList(),
child: TextButton.icon(
label: Text(
value.string(context),
style: TextStyle(
return Flexible(
child: PopupMenuButton<BarChartViewSelection>(
tooltip: L10n.of(context)!.changeAnalyticsView,
initialValue: value,
onSelected: (BarChartViewSelection? view) {
if (view == null) {
debugPrint("when is view null?");
return;
}
onChange(view);
},
itemBuilder: (BuildContext context) => BarChartViewSelection.values
.map<PopupMenuEntry<BarChartViewSelection>>(
(BarChartViewSelection view) {
return PopupMenuItem<BarChartViewSelection>(
value: view,
child: Text(
view.string(context),
),
);
}).toList(),
child: TextButton.icon(
label: Text(
value.string(context),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
icon: Icon(
value.icon,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: null,
),
icon: Icon(
value.icon,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: null,
),
);
}

View file

@ -64,7 +64,6 @@ class SpaceAnalyticsV2Controller extends State<SpaceAnalyticsPage> {
Future<void> getChatAndStudents() async {
try {
await spaceRoom?.postLoad();
await spaceRoom?.requestParticipants();
if (spaceRoom != null) {

View file

@ -49,15 +49,8 @@ class StudentAnalyticsController extends State<StudentAnalyticsPage> {
return _chats;
}
List<Room> _spaces = [];
List<Room> get spaces {
if (_spaces.isEmpty) {
_pangeaController.matrixState.client.spaceImAStudentIn.then((result) {
setState(() => _spaces = result);
});
}
return _spaces;
}
List<Room> get spaces =>
_pangeaController.matrixState.client.spacesImAStudentIn;
String? get userId {
final id = _pangeaController.matrixState.client.userID;

View file

@ -14,35 +14,37 @@ class TimeSpanMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<TimeSpan>(
tooltip: L10n.of(context)!.changeDateRange,
initialValue: value,
onSelected: (TimeSpan? timeSpan) {
if (timeSpan == null) {
debugPrint("when is timeSpan null?");
return;
}
onChange(timeSpan);
},
itemBuilder: (BuildContext context) =>
TimeSpan.values.map<PopupMenuEntry<TimeSpan>>((TimeSpan timeSpan) {
return PopupMenuItem<TimeSpan>(
value: timeSpan,
child: Text(timeSpan.string(context)),
);
}).toList(),
child: TextButton.icon(
label: Text(
value.string(context),
style: TextStyle(
return Flexible(
child: PopupMenuButton<TimeSpan>(
tooltip: L10n.of(context)!.changeDateRange,
initialValue: value,
onSelected: (TimeSpan? timeSpan) {
if (timeSpan == null) {
debugPrint("when is timeSpan null?");
return;
}
onChange(timeSpan);
},
itemBuilder: (BuildContext context) =>
TimeSpan.values.map<PopupMenuEntry<TimeSpan>>((TimeSpan timeSpan) {
return PopupMenuItem<TimeSpan>(
value: timeSpan,
child: Text(timeSpan.string(context)),
);
}).toList(),
child: TextButton.icon(
label: Text(
value.string(context),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
icon: Icon(
Icons.calendar_month_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: null,
),
icon: Icon(
Icons.calendar_month_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: null,
),
);
}

View file

@ -1,57 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
class MeasurableWidget extends StatefulWidget {
final Widget child;
final Function(Size? size, Offset? position) onChange;
const MeasurableWidget({
super.key,
required this.onChange,
required this.child,
});
@override
_WidgetSizeState createState() => _WidgetSizeState();
}
class _WidgetSizeState extends State<MeasurableWidget> {
var widgetKey = GlobalKey();
Offset? oldPosition;
@override
void initState() {
// TODO: implement initState
super.initState();
}
void postFrameCallback(_) {
final context = widgetKey.currentContext;
if (context == null) return;
final RenderBox? box =
widgetKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null && box.hasSize) {
final Offset position = box.localToGlobal(Offset.zero);
if (oldPosition != null) {
if (oldPosition!.dx == position.dx && oldPosition!.dy == position.dy) {
return;
}
}
oldPosition = position;
final newSize = context.size;
widget.onChange(newSize, position);
}
}
@override
Widget build(BuildContext context) {
SchedulerBinding.instance.addPostFrameCallback(postFrameCallback);
return Container(
key: widgetKey,
child: widget.child,
);
}
}

View file

@ -36,7 +36,6 @@ void chatListHandleSpaceTap(
if (await space.leaveIfFull()) {
throw L10n.of(context)!.roomFull;
}
await space.postLoad();
setActiveSpaceAndCloseChat();
},
onError: (exception) {
@ -72,7 +71,7 @@ void chatListHandleSpaceTap(
throw L10n.of(context)!.roomFull;
}
if (space.isSpace) {
await space.joinAnalyticsRoomsInSpace();
space.joinAnalyticsRoomsInSpace();
}
setActiveSpaceAndCloseChat();
ScaffoldMessenger.of(context).showSnackBar(

View file

@ -122,6 +122,10 @@ class ErrorCopy {
title = l10n.error502504Title;
body = l10n.error502504Desc;
break;
case 520:
title = l10n.error520Title;
body = l10n.error520Desc;
break;
case 404:
title = l10n.error404Title;
body = l10n.error404Desc;

View file

@ -1,5 +1,6 @@
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:get_storage/get_storage.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// Utility to save and read data both in the matrix profile (this is the default
/// behavior) and in the local storage (local needs to be specificied). An
@ -66,6 +67,9 @@ class PStore {
/// Clears the storage by erasing all data in the box.
void clearStorage() {
// this could potenitally be interfering with openning database
// at the start of the session, which is causing auto log outs on iOS
Sentry.addBreadcrumb(Breadcrumb(message: 'Clearing local storage'));
_box.erase();
}
}

View file

@ -0,0 +1,94 @@
import 'dart:async';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/choreographer/widgets/has_error_button.dart';
import 'package:fluffychat/pangea/choreographer/widgets/language_permissions_warning_buttons.dart';
import 'package:fluffychat/pangea/models/space_model.dart';
import 'package:flutter/material.dart';
class ChatFloatingActionButton extends StatefulWidget {
final ChatController controller;
const ChatFloatingActionButton({
super.key,
required this.controller,
});
@override
ChatFloatingActionButtonState createState() =>
ChatFloatingActionButtonState();
}
class ChatFloatingActionButtonState extends State<ChatFloatingActionButton> {
bool showPermissionsError = false;
StreamSubscription? _choreoSub;
@override
void initState() {
final permissionsController =
widget.controller.pangeaController.permissionsController;
final itEnabled = permissionsController.isToolEnabled(
ToolSetting.interactiveTranslator,
widget.controller.room,
);
final igcEnabled = permissionsController.isToolEnabled(
ToolSetting.interactiveGrammar,
widget.controller.room,
);
showPermissionsError = !itEnabled || !igcEnabled;
debugPrint("showPermissionsError: $showPermissionsError");
if (showPermissionsError) {
Future.delayed(
const Duration(seconds: 5),
() {
if (mounted) setState(() => showPermissionsError = false);
},
);
}
// Rebuild the widget each time there's an update from choreo (i.e., an error).
_choreoSub =
widget.controller.choreographer.stateListener.stream.listen((_) {
setState(() {});
});
super.initState();
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.controller.selectedEvents.isNotEmpty) {
return const SizedBox.shrink();
}
if (widget.controller.showScrollDownButton) {
return Padding(
padding: const EdgeInsets.only(bottom: 56.0),
child: FloatingActionButton(
onPressed: widget.controller.scrollDown,
heroTag: null,
mini: true,
child: const Icon(Icons.arrow_downward_outlined),
),
);
}
if (widget.controller.choreographer.errorService.error != null) {
return ChoreographerHasErrorButton(
widget.controller.pangeaController,
widget.controller.choreographer.errorService.error!,
);
}
return showPermissionsError
? LanguagePermissionsButtons(
choreographer: widget.controller.choreographer,
roomID: widget.controller.roomId,
)
: const SizedBox.shrink();
}
}

View file

@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:fluffychat/pages/chat/input_bar.dart';
import 'package:fluffychat/pangea/widgets/igc/pangea_text_controller.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
class InputBarWrapper extends StatefulWidget {
final Room room;
final int? minLines;
final int? maxLines;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
final ValueChanged<Uint8List?>? onSubmitImage;
final FocusNode? focusNode;
final PangeaTextController? controller;
final InputDecoration? decoration;
final ValueChanged<String>? onChanged;
final bool? autofocus;
final bool readOnly;
const InputBarWrapper({
required this.room,
this.minLines,
this.maxLines,
this.keyboardType,
this.onSubmitted,
this.onSubmitImage,
this.focusNode,
this.controller,
this.decoration,
this.onChanged,
this.autofocus,
this.textInputAction,
this.readOnly = false,
super.key,
});
@override
State<InputBarWrapper> createState() => InputBarWrapperState();
}
class InputBarWrapperState extends State<InputBarWrapper> {
StreamSubscription? _choreoSub;
String _currentText = '';
@override
void initState() {
// Rebuild the widget each time there's an update from choreo
_choreoSub =
widget.controller?.choreographer.stateListener.stream.listen((_) {
setState(() {});
});
super.initState();
}
@override
void dispose() {
_choreoSub?.cancel();
super.dispose();
}
void refreshOnChange(String text) {
if (widget.onChanged != null) {
widget.onChanged!(text);
}
final bool decreasedFromMaxLength =
_currentText.length >= PangeaTextController.maxLength &&
text.length < PangeaTextController.maxLength;
final bool reachedMaxLength =
_currentText.length < PangeaTextController.maxLength &&
text.length < PangeaTextController.maxLength;
if (decreasedFromMaxLength || reachedMaxLength) {
setState(() {});
}
_currentText = text;
}
@override
Widget build(BuildContext context) {
return InputBar(
room: widget.room,
minLines: widget.minLines,
maxLines: widget.maxLines,
keyboardType: widget.keyboardType,
onSubmitted: widget.onSubmitted,
onSubmitImage: widget.onSubmitImage,
focusNode: widget.focusNode,
controller: widget.controller,
decoration: widget.decoration,
onChanged: refreshOnChange,
autofocus: widget.autofocus,
textInputAction: widget.textInputAction,
readOnly: widget.readOnly,
);
}
}

View file

@ -1,29 +0,0 @@
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/widgets/chat/text_to_speech_button.dart';
import 'package:flutter/material.dart';
class PangeaMessageActions extends StatelessWidget {
final ChatController chatController;
const PangeaMessageActions({super.key, required this.chatController});
@override
Widget build(BuildContext context) {
return chatController.selectedEvents.length == 1
? Row(
children: <Widget>[
// LanguageToggleSwitch(controller: chatController),
TextToSpeechButton(
controller: chatController,
selectedEvent: chatController.selectedEvents.first,
),
// IconButton(
// icon: Icon(Icons.mic),
// onPressed: chatController.onMicTap,
// ),
// Add more IconButton widgets here
],
)
: const SizedBox();
}
}

View file

@ -58,7 +58,10 @@ class ToolbarDisplayController {
);
}
void showToolbar(BuildContext context, {MessageMode? mode}) {
void showToolbar(
BuildContext context, {
MessageMode? mode,
}) {
bool toolbarUp = true;
if (highlighted) return;
if (controller.selectMode) {
@ -78,8 +81,51 @@ class ToolbarDisplayController {
final Size transformTargetSize = (targetRenderBox as RenderBox).size;
messageWidth = transformTargetSize.width;
final Offset targetOffset = (targetRenderBox).localToGlobal(Offset.zero);
final double screenHeight = MediaQuery.of(context).size.height;
toolbarUp = targetOffset.dy >= screenHeight / 2;
// If there is enough space above, procede as normal
// Else if there is enough space below, show toolbar underneath
if (targetOffset.dy < 320) {
final spaceBeneath = MediaQuery.of(context).size.height -
(targetOffset.dy + transformTargetSize.height);
if (spaceBeneath >= 320) {
toolbarUp = false;
}
// See if it's possible to scroll up to make space
else if (controller.scrollController.offset - targetOffset.dy + 320 >=
controller.scrollController.position.minScrollExtent &&
controller.scrollController.offset - targetOffset.dy + 320 <=
controller.scrollController.position.maxScrollExtent) {
controller.scrollController.animateTo(
controller.scrollController.offset - targetOffset.dy + 320,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
);
}
// See if it's possible to scroll down to make space
else if (controller.scrollController.offset + spaceBeneath - 320 >=
controller.scrollController.position.minScrollExtent &&
controller.scrollController.offset + spaceBeneath - 320 <=
controller.scrollController.position.maxScrollExtent) {
controller.scrollController.animateTo(
controller.scrollController.offset + spaceBeneath - 320,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
);
toolbarUp = false;
}
// If message is too big and can't scroll either way
// Scroll up as much as possible, and show toolbar above
else {
controller.scrollController.animateTo(
controller.scrollController.position.minScrollExtent,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
);
}
}
}
final Widget overlayMessage = OverlayMessage(
@ -106,7 +152,13 @@ class ToolbarDisplayController {
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
toolbarUp ? toolbar! : overlayMessage,
toolbarUp
// Column is limited to screen height
// If message portion is too tall, decrease toolbar height
// as necessary to prevent toolbar from acting strange
// Problems may still occur if toolbar height is decreased too much
? toolbar!
: overlayMessage,
const SizedBox(height: 6),
toolbarUp ? overlayMessage : toolbar!,
],

View file

@ -132,6 +132,22 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
setState(() {});
}
/// Show warning if message's language code is user's L1
/// or if translated text is same as original text.
/// Warning does not show if was previously closed
bool get showWarning {
if (MatrixState.pangeaController.instructions.wereInstructionsTurnedOff(
InlineInstructions.l1Translation.toString(),
)) return false;
final bool isWrittenInL1 =
l1Code != null && widget.messageEvent.originalSent?.langCode == l1Code;
final bool isTextIdentical = selectionTranslation != null &&
widget.messageEvent.originalSent?.text == selectionTranslation;
return isWrittenInL1 || isTextIdentical;
}
@override
Widget build(BuildContext context) {
if (!_fetchingRepresentation &&
@ -140,13 +156,6 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
return const CardErrorWidget();
}
final bool showWarning = l2Code != null &&
!widget.immersionMode &&
widget.messageEvent.originalSent?.langCode != l2Code &&
!MatrixState.pangeaController.instructions.wereInstructionsTurnedOff(
InlineInstructions.l1Translation.toString(),
);
return Container(
child: _fetchingRepresentation
? const ToolbarContentLoadingIndicator()

View file

@ -118,81 +118,85 @@ class OverlayMessage extends StatelessWidget {
ownMessage: ownMessage,
);
return Material(
color: noBubble ? Colors.transparent : color,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
return Flexible(
child: Material(
color: noBubble ? Colors.transparent : color,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
padding: noBubble || noPadding
? EdgeInsets.zero
: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
constraints: BoxConstraints(
maxWidth: width ?? FluffyThemes.columnWidth * 1.25,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MessageContent(
event.getDisplayEvent(timeline),
textColor: textColor,
borderRadius: borderRadius,
selected: selected,
pangeaMessageEvent: pangeaMessageEvent,
immersionMode: immersionMode,
toolbarController: toolbarController,
isOverlay: true,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
) ||
(pangeaMessageEvent.showUseType))
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
padding: noBubble || noPadding
? EdgeInsets.zero
: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (pangeaMessageEvent.showUseType) ...[
pangeaMessageEvent.msgUseType.iconView(
context,
textColor.withAlpha(164),
),
const SizedBox(width: 4),
],
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
)) ...[
Icon(
Icons.edit_outlined,
color: textColor.withAlpha(164),
size: 14,
),
Text(
' - ${event.getDisplayEvent(timeline).originServerTs.localizedTimeShort(context)}',
style: TextStyle(
color: textColor.withAlpha(164),
fontSize: 12,
),
),
],
],
constraints: BoxConstraints(
maxWidth: width ?? FluffyThemes.columnWidth * 1.25,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: MessageContent(
event.getDisplayEvent(timeline),
textColor: textColor,
borderRadius: borderRadius,
selected: selected,
pangeaMessageEvent: pangeaMessageEvent,
immersionMode: immersionMode,
toolbarController: toolbarController,
isOverlay: true,
),
),
],
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
) ||
(pangeaMessageEvent.showUseType))
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (pangeaMessageEvent.showUseType) ...[
pangeaMessageEvent.msgUseType.iconView(
context,
textColor.withAlpha(164),
),
const SizedBox(width: 4),
],
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
)) ...[
Icon(
Icons.edit_outlined,
color: textColor.withAlpha(164),
size: 14,
),
Text(
' - ${event.getDisplayEvent(timeline).originServerTs.localizedTimeShort(context)}',
style: TextStyle(
color: textColor.withAlpha(164),
fontSize: 12,
),
),
],
],
),
),
],
),
),
),
);

View file

@ -1,138 +0,0 @@
import 'dart:developer';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/audio_player.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:just_audio/just_audio.dart';
import 'package:matrix/matrix.dart';
class TextToSpeechButton extends StatefulWidget {
final ChatController controller;
final Event selectedEvent;
const TextToSpeechButton({
super.key,
required this.controller,
required this.selectedEvent,
});
@override
_TextToSpeechButtonState createState() => _TextToSpeechButtonState();
}
class _TextToSpeechButtonState extends State<TextToSpeechButton> {
final AudioPlayer _audioPlayer = AudioPlayer();
late PangeaMessageEvent _pangeaMessageEvent;
bool _isLoading = false;
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_pangeaMessageEvent = PangeaMessageEvent(
event: widget.selectedEvent,
timeline: widget.controller.timeline!,
ownMessage:
widget.selectedEvent.senderId == Matrix.of(context).client.userID,
);
}
Event? get localAudioEvent =>
langCode != null && text != null && text!.isNotEmpty
? _pangeaMessageEvent.getTextToSpeechLocal(langCode!, text!)
: null;
String? get langCode =>
widget.controller.choreographer.messageOptions.selectedDisplayLang
?.langCode ??
widget.controller.choreographer.l2LangCode;
String? get text => langCode != null
? _pangeaMessageEvent.representationByLanguage(langCode!)?.text
: null;
Future<void> _getAudio() async {
try {
if (!mounted) return;
if (text == null || text!.isEmpty) return;
if (langCode == null || langCode!.isEmpty) return;
setState(() => _isLoading = true);
await _pangeaMessageEvent.getTextToSpeechGlobal(langCode!);
setState(() => _isLoading = false);
} catch (e) {
setState(() => _isLoading = false);
debugger(when: kDebugMode);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.errorGettingAudio),
),
);
ErrorHandler.logError(
e: Exception(),
s: StackTrace.current,
m: 'text is null or empty in text_to_speech_button.dart',
data: {'selectedEvent': widget.selectedEvent, 'langCode': langCode},
);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
final playButton = InkWell(
borderRadius: BorderRadius.circular(64),
onTap: text == null || text!.isEmpty ? null : _getAudio,
child: Material(
color: AppConfig.primaryColor.withAlpha(64),
borderRadius: BorderRadius.circular(64),
child: const Icon(
// Change the icon based on some condition. If you have an audio player state, use it here.
Icons.play_arrow_outlined,
color: AppConfig.primaryColor,
),
),
);
return localAudioEvent == null
? Opacity(
opacity: text == null || text!.isEmpty ? 0.5 : 1,
child: SizedBox(
width: 44, // Match the size of the button in AudioPlayerState
height: 36,
child: Padding(
//only left side of the button is padded to match the padding of the AudioPlayerState
padding: const EdgeInsets.only(left: 8),
child: playButton,
),
),
)
: Container(
constraints: const BoxConstraints(
maxWidth: 250,
),
child: Column(
children: [
AudioPlayerWidget(
localAudioEvent!,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
],
),
);
}
}

View file

@ -0,0 +1,47 @@
import 'dart:async';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/chat_list_header.dart';
import 'package:flutter/material.dart';
/// A wrapper around ChatListHeader to allow rebuilding on state changes.
/// Prevents having to rebuild the entire ChatList when a single item changes.
class ChatListHeaderWrapper extends StatefulWidget {
final ChatListController controller;
final bool globalSearch;
const ChatListHeaderWrapper({
super.key,
required this.controller,
this.globalSearch = true,
});
@override
ChatListHeaderWrapperState createState() => ChatListHeaderWrapperState();
}
class ChatListHeaderWrapperState extends State<ChatListHeaderWrapper> {
StreamSubscription? stateSub;
@override
void initState() {
super.initState();
stateSub = widget.controller.selectionsStream.stream.listen((roomID) {
setState(() {});
});
}
@override
void dispose() {
stateSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChatListHeader(
controller: widget.controller,
globalSearch: widget.globalSearch,
);
}
}

View file

@ -0,0 +1,71 @@
import 'dart:async';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
/// A wrapper around ChatListItem to allow rebuilding on state changes.
/// Prevents having to rebuild the entire ChatList when a single item changes.
class ChatListItemWrapper extends StatefulWidget {
final Room room;
final bool activeChat;
final void Function()? onForget;
final String? filter;
final ChatListController controller;
final void Function()? onLongPress;
final void Function()? onTap;
const ChatListItemWrapper(
this.room, {
this.activeChat = false,
this.onForget,
this.filter,
required this.controller,
this.onLongPress,
this.onTap,
super.key,
});
@override
ChatListItemWrapperState createState() => ChatListItemWrapperState();
}
class ChatListItemWrapperState extends State<ChatListItemWrapper> {
StreamSubscription? stateSub;
@override
void initState() {
super.initState();
stateSub = widget.controller.selectionsStream.stream.listen((roomID) {
if (roomID == widget.room.id) {
setState(() {});
}
});
}
@override
void dispose() {
stateSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChatListItem(
widget.room,
activeChat: widget.activeChat,
selected: widget.controller.selectedRoomIds.contains(widget.room.id),
onTap: widget.onTap ??
(widget.controller.selectMode == SelectMode.select
? () => widget.controller.toggleSelection(widget.room.id)
: () => onChatTap(widget.room, context)),
onLongPress: widget.onLongPress ??
() => widget.controller.toggleSelection(widget.room.id),
onForget: widget.onForget,
filter: widget.filter,
);
}
}

View file

@ -76,18 +76,6 @@ class AddToSpaceState extends State<AddToSpaceToggles> {
)
: null;
if (widget.activeSpaceId != null) {
final activeSpace =
Matrix.of(context).client.getRoomById(widget.activeSpaceId!);
if (activeSpace != null && activeSpace.canIAddSpaceChild(null)) {
parent = activeSpace;
} else {
ErrorHandler.logError(
e: Exception('activeSpaceId ${widget.activeSpaceId} not found'),
);
}
}
//sort possibleParents
//if possibleParent in parents, put first
//use sort but use any instead of contains because contains uses == and we want to compare by id
@ -102,6 +90,20 @@ class AddToSpaceState extends State<AddToSpaceToggles> {
});
isOpen = widget.startOpen;
if (widget.activeSpaceId != null) {
final activeSpace =
Matrix.of(context).client.getRoomById(widget.activeSpaceId!);
if (activeSpace == null) {
ErrorHandler.logError(
e: Exception('activeSpaceId ${widget.activeSpaceId} not found'),
);
return;
}
if (activeSpace.canSendEvent(EventTypes.SpaceChild)) {
parent = activeSpace;
}
}
}
Future<void> _addSingleSpace(String roomToAddId, Room newParent) async {

View file

@ -25,6 +25,10 @@ class PangeaTextController extends TextEditingController {
text ??= '';
this.text = text;
}
static const int maxLength = 1000;
bool get isMaxLength => text.length == 1000;
bool forceKeepOpen = false;
setSystemText(String text, EditType type) {

View file

@ -111,12 +111,12 @@ abstract class ClientManager {
// To make room emotes work
'im.ponies.room_emotes',
// #Pangea
PangeaEventTypes.languageSettings,
// The things in this list will be loaded in the first sync, without having
// to postLoad to confirm that these state events are completely loaded
PangeaEventTypes.rules,
PangeaEventTypes.botOptions,
EventTypes.RoomTopic,
EventTypes.RoomAvatar,
PangeaEventTypes.capacity,
EventTypes.RoomPowerLevels,
// Pangea#
},
logLevel: kReleaseMode ? Level.warning : Level.verbose,

View file

@ -1,7 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
@ -67,9 +66,7 @@ extension LocalizedExceptionExtension on Object {
supportedVersions,
);
}
if (this is MatrixConnectionException ||
this is SocketException ||
this is SyncConnectionException) {
if (this is SocketException || this is SyncConnectionException) {
return L10n.of(context)!.noConnectionToTheServer;
}
if (this is String) return toString();

View file

@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:universal_html/html.dart' as html;
@ -80,6 +81,9 @@ Future<MatrixSdkDatabase> _constructDatabase(Client client) async {
}
final cipher = await getDatabaseCipher();
// #Pangea
Sentry.addBreadcrumb(Breadcrumb(message: 'Database cipher: $cipher'));
// Pangea#
Directory? fileStorageLocation;
try {
@ -97,6 +101,9 @@ Future<MatrixSdkDatabase> _constructDatabase(Client client) async {
// import the SQLite / SQLCipher shared objects / dynamic libraries
final factory =
createDatabaseFactoryFfi(ffiInit: SQfLiteEncryptionHelper.ffiInit);
// #Pangea
Sentry.addBreadcrumb(Breadcrumb(message: 'Database path: $path'));
// Pangea#
// migrate from potential previous SQLite database path to current one
await _migrateLegacyLocation(path, client.clientName);
@ -113,6 +120,9 @@ Future<MatrixSdkDatabase> _constructDatabase(Client client) async {
path: path,
cipher: cipher,
);
// #Pangea
Sentry.addBreadcrumb(Breadcrumb(message: 'Database cipher helper: $helper'));
// Pangea#
// check whether the DB is already encrypted and otherwise do so
await helper?.ensureDatabaseFileEncrypted();

View file

@ -5,6 +5,7 @@ import 'package:fluffychat/config/setting_keys.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _passwordStorageKey = 'database_password';
@ -58,6 +59,12 @@ void _sendNoEncryptionWarning(Object exception) async {
// l10n.noDatabaseEncryption,
// exception.toString(),
// );
Sentry.addBreadcrumb(
Breadcrumb(
message: 'No database encryption',
data: {'exception': exception},
),
);
// Pangea#
await store.setBool(SettingKeys.noEncryptionWarningShown, true);

View file

@ -344,4 +344,10 @@ class MatrixLocals extends MatrixLocalizations {
@override
String startedKeyVerification(String senderName) =>
l10n.startedKeyVerification(senderName);
@override
String invitedBy(String senderName) {
// TODO: implement invitedBy
throw UnimplementedError();
}
}

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:badges/badges.dart' as b;
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'matrix.dart';
@ -24,7 +24,10 @@ class UnreadRoomsBadge extends StatelessWidget {
.client
.onSync
.stream
.where((syncUpdate) => syncUpdate.hasRoomUpdate),
.where((syncUpdate) => syncUpdate.hasRoomUpdate)
// #Pangea
.rateLimit(const Duration(seconds: 1)),
// Pangea#
builder: (context, _) {
// #Pangea
// final unreadCount = Matrix.of(context)

View file

@ -1432,11 +1432,12 @@ packages:
matrix:
dependency: "direct main"
description:
name: matrix
sha256: bb6de59d0f69e10bb6893130a967f1ffcbfa3d3ffed3864f0736ce3d968e669c
url: "https://pub.dev"
source: hosted
version: "0.29.12"
path: "."
ref: main
resolved-ref: "0a95cd8f3cfac8c9b0b59d6ee7fdbdb159949ca3"
url: "https://github.com/pangeachat/matrix-dart-sdk.git"
source: git
version: "0.30.0"
meta:
dependency: transitive
description:

View file

@ -70,7 +70,10 @@ dependencies:
keyboard_shortcuts: ^0.1.4
latlong2: ^0.9.1
linkify: ^5.0.0
matrix: ^0.29.12
matrix:
git:
url: https://github.com/pangeachat/matrix-dart-sdk.git # repo
ref: main # branch
native_imaging: ^0.1.1
package_info_plus: ^6.0.0
pasteboard: ^0.2.0