diff --git a/commet/lib/client/components/push_notification/android/firebase_push_notifier.dart b/commet/lib/client/components/push_notification/android/firebase_push_notifier.dart index 9c0e6161..86d3fc87 100644 --- a/commet/lib/client/components/push_notification/android/firebase_push_notifier.dart +++ b/commet/lib/client/components/push_notification/android/firebase_push_notifier.dart @@ -32,7 +32,7 @@ Future onForegroundMessage(dynamic message) async { var room = client.getRoom(roomId); var event = await room!.getEvent(eventId); - var user = room.getMemberOrFallback(event!.senderId)!; + var user = room.getMemberOrFallback(event!.senderId); Log.i("Dispatching notification"); diff --git a/commet/lib/client/components/push_notification/android/unified_push_notifier.dart b/commet/lib/client/components/push_notification/android/unified_push_notifier.dart index bc280fad..162c3420 100644 --- a/commet/lib/client/components/push_notification/android/unified_push_notifier.dart +++ b/commet/lib/client/components/push_notification/android/unified_push_notifier.dart @@ -99,7 +99,7 @@ class UnifiedPushNotifier implements Notifier { var room = client.getRoom(roomId); var event = await room!.getEvent(eventId); - var user = room.getMemberOrFallback(event!.senderId)!; + var user = room.getMemberOrFallback(event!.senderId); NotificationManager.notify(MessageNotificationContent( senderName: user.displayName, diff --git a/commet/lib/client/matrix/components/url_preview/matrix_url_preview_component.dart b/commet/lib/client/matrix/components/url_preview/matrix_url_preview_component.dart index 46bdf9ca..e908b13d 100644 --- a/commet/lib/client/matrix/components/url_preview/matrix_url_preview_component.dart +++ b/commet/lib/client/matrix/components/url_preview/matrix_url_preview_component.dart @@ -6,9 +6,11 @@ import 'package:commet/client/room.dart'; import 'package:commet/client/timeline.dart'; import 'package:commet/debug/log.dart'; import 'package:commet/main.dart'; +import 'package:commet/utils/mime.dart'; import 'package:flutter/widgets.dart'; import 'package:matrix/matrix.dart' as matrix; import 'package:encrypted_url_preview/encrypted_url_preview.dart'; +import 'package:matrix/matrix_api_lite.dart'; class MatrixUrlPreviewComponent implements UrlPreviewComponent { @override @@ -20,6 +22,8 @@ class MatrixUrlPreviewComponent implements UrlPreviewComponent { EncryptedUrlPreview? privatePreviewGetter; + bool? serverSupportsUrlPreview; + void createPrivatePreviewGetter() { privatePreviewGetter = EncryptedUrlPreview( proxyServerUrl: Uri.https("telescope.commet.chat"), @@ -62,7 +66,10 @@ pQIDAQAB return null; } - cache[uri.toString()] = data!; + if (data != null) { + cache[uri.toString()] = data; + } + return data; } @@ -82,6 +89,10 @@ pQIDAQAB return false; } + if (serverSupportsUrlPreview == false) { + return false; + } + return event.links?.isNotEmpty == true; } @@ -137,20 +148,46 @@ pQIDAQAB Future fetchPreviewData( matrix.Client client, Uri url) async { - var response = await client.request( - matrix.RequestType.GET, "/media/v3/preview_url", - query: {"url": url.toString()}); + late Map response; + try { + response = await client.request( + matrix.RequestType.GET, "/media/v3/preview_url", + query: {"url": url.toString()}); + } catch (e, s) { + if (e is MatrixException) { + if (e.error == MatrixError.M_UNRECOGNIZED) { + serverSupportsUrlPreview = false; + } + } + + Log.onError(e, s); + + return null; + } + serverSupportsUrlPreview = true; var title = response['og:title'] as String?; var siteName = response['og:site_name'] as String?; var imageUrl = response['og:image'] as String?; var description = response['og:description'] as String?; + var type = response["og:image:type"] as String?; + if (type != null) { + if (Mime.displayableTypes.contains(type) == false) { + imageUrl = null; + } + } + ImageProvider? image; if (imageUrl != null) { var imageUri = Uri.parse(imageUrl); if (imageUri.scheme == "mxc") { - image = MatrixMxcImage(imageUri, client, doThumbnail: false); + try { + image = MatrixMxcImage(imageUri, client, doThumbnail: false); + } catch (exception, stack) { + Log.onError(exception, stack); + Log.w("Failed to get mxc image"); + } } } diff --git a/commet/lib/client/matrix/matrix_client.dart b/commet/lib/client/matrix/matrix_client.dart index dbdef4f3..b00230fb 100644 --- a/commet/lib/client/matrix/matrix_client.dart +++ b/commet/lib/client/matrix/matrix_client.dart @@ -65,10 +65,6 @@ class MatrixClient extends Client { : matrix.NativeImplementationsIsolate(compute); MatrixClient({required String identifier}) { - if (preferences.developerMode) { - matrix.Logs().level = matrix.Level.verbose; - } - _id = identifier; _matrixClient = _createMatrixClient(identifier); _matrixClient.onSync.stream.listen(onMatrixClientSync); @@ -231,7 +227,6 @@ class MatrixClient extends Client { } void onMatrixClientSync(matrix.SyncUpdate update) { - Log.d("On Matrix Sync!"); _onSync.add(null); _updateRoomslist(); _updateSpacesList(); @@ -257,8 +252,6 @@ class MatrixClient extends Client { legacyDatabaseBuilder: (client) => getLegacyMatrixDatabase(client.clientName), databaseBuilder: (client) => getMatrixDatabase(client.clientName), - logLevel: - BuildConfig.RELEASE ? matrix.Level.warning : matrix.Level.verbose, ); client.onSyncStatus.stream.listen(onSyncStatusChanged); diff --git a/commet/lib/client/matrix/matrix_room.dart b/commet/lib/client/matrix/matrix_room.dart index a849e94d..22db4e41 100644 --- a/commet/lib/client/matrix/matrix_room.dart +++ b/commet/lib/client/matrix/matrix_room.dart @@ -179,10 +179,13 @@ class MatrixRoom extends Room { } } - _onUpdateSubscription = - _matrixRoom.onUpdate.stream.listen(onMatrixRoomUpdate); + _onUpdateSubscription = _matrixRoom.client.onRoomState.stream + .where((event) => event.roomId == _matrixRoom.id) + .listen(onRoomStateUpdated); - _matrixRoom.client.onEvent.stream.listen(onEvent); + _matrixRoom.client.onEvent.stream + .where((event) => event.roomID == _matrixRoom.id) + .listen(onEvent); _permissions = MatrixRoomPermissions(_matrixRoom); } @@ -238,10 +241,6 @@ class MatrixRoom extends Room { var sender = getMemberOrFallback(event.senderId); - if (sender == null) { - return; - } - var notification = MessageNotificationContent( senderName: sender.displayName, senderId: sender.identifier, @@ -371,9 +370,11 @@ class MatrixRoom extends Room { await _matrixRoom.enableEncryption(); } - void onMatrixRoomUpdate(String event) async { + void onRoomStateUpdated(matrix.Event event) async { _displayName = _matrixRoom.getLocalizedDisplayname(); - _onUpdate.add(null); + if (event.type == "m.room.name") { + _onUpdate.add(null); + } } @override @@ -509,7 +510,7 @@ class MatrixRoom extends Room { bool get isMembersListComplete => _matrixRoom.participantListComplete; @override - Member? getMemberOrFallback(String id) { + Member getMemberOrFallback(String id) { return MatrixMember( _matrixRoom.client, _matrixRoom.unsafeGetUserFromMemoryOrFallback(id)); } @@ -522,9 +523,8 @@ class MatrixRoom extends Room { var roles = (state.content["users"] as Map); var ids = roles.keys; - var result = ids - .map((e) => (getMemberOrFallback(e)!, MatrixRole(roles[e]))) - .toList(); + var result = + ids.map((e) => (getMemberOrFallback(e), MatrixRole(roles[e]))).toList(); result.removeWhere((element) => element.$2.rank == 0); diff --git a/commet/lib/client/matrix/matrix_timeline_event.dart b/commet/lib/client/matrix/matrix_timeline_event.dart index 60ea06df..c87fd5f3 100644 --- a/commet/lib/client/matrix/matrix_timeline_event.dart +++ b/commet/lib/client/matrix/matrix_timeline_event.dart @@ -256,7 +256,11 @@ class MatrixTimelineEvent implements TimelineEvent { } @override - Widget buildFormattedContent() { + Widget? buildFormattedContent() { + if (formattedBody == null) { + return null; + } + return MatrixHtmlParser.parse(formattedBody!, event.room.client); } diff --git a/commet/lib/client/room.dart b/commet/lib/client/room.dart index b458b5d0..59d35b90 100644 --- a/commet/lib/client/room.dart +++ b/commet/lib/client/room.dart @@ -142,7 +142,7 @@ abstract class Room { Future getEvent(String eventId); - Member? getMemberOrFallback(String id); + Member getMemberOrFallback(String id); @override bool operator ==(Object other) { diff --git a/commet/lib/client/timeline.dart b/commet/lib/client/timeline.dart index 24469ca4..e1d162fd 100644 --- a/commet/lib/client/timeline.dart +++ b/commet/lib/client/timeline.dart @@ -106,7 +106,7 @@ abstract class TimelineEvent { /// If you want to display the same message twice, use `buildFormattedContent()` to create a new widget Widget? get formattedContent; - Widget buildFormattedContent(); + Widget? buildFormattedContent(); String? get relatedEventId; String? get stateKey; diff --git a/commet/lib/service/background_service_notifications/background_service_task_notification.dart b/commet/lib/service/background_service_notifications/background_service_task_notification.dart index a1eabef3..c3da3ef3 100644 --- a/commet/lib/service/background_service_notifications/background_service_task_notification.dart +++ b/commet/lib/service/background_service_notifications/background_service_task_notification.dart @@ -102,7 +102,7 @@ class BackgroundNotificationsManager { Log.i("Found room: ${room?.displayName}"); var event = await room!.getEvent(eventId); - var user = room.getMemberOrFallback(event!.senderId)!; + var user = room.getMemberOrFallback(event!.senderId); Log.i("Got user: $user ($user)"); Log.i("Got event: ${event.body}"); diff --git a/commet/lib/ui/atoms/emoji_reaction.dart b/commet/lib/ui/atoms/emoji_reaction.dart index fdaaf784..2b3c52fc 100644 --- a/commet/lib/ui/atoms/emoji_reaction.dart +++ b/commet/lib/ui/atoms/emoji_reaction.dart @@ -20,7 +20,7 @@ class EmojiReaction extends StatelessWidget { final int numReactions; final bool highlighted; - BorderRadius get borderRadius => BorderRadius.circular(12); + BorderRadius get borderRadius => BorderRadius.circular(8); @override Widget build(BuildContext context) { diff --git a/commet/lib/ui/atoms/generic_room_event.dart b/commet/lib/ui/atoms/generic_room_event.dart deleted file mode 100644 index a3283029..00000000 --- a/commet/lib/ui/atoms/generic_room_event.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:tiamat/atoms/avatar.dart'; -import 'package:tiamat/tiamat.dart' as tiamat; -import 'package:flutter/material.dart' as m; - -class GenericRoomEvent extends StatelessWidget { - const GenericRoomEvent(this.text, {this.icon, this.senderImage, super.key}); - final String text; - final IconData? icon; - final ImageProvider? senderImage; - - @override - Widget build(BuildContext context) { - return m.Material( - color: m.Colors.transparent, - child: Padding( - padding: const EdgeInsets.all(2.0), - child: Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - if (icon != null) - Padding( - padding: const EdgeInsets.fromLTRB(44, 0, 8, 0), - child: Icon( - icon, - size: 20, - ), - ), - if (senderImage != null) - Padding( - padding: const EdgeInsets.fromLTRB(44, 0, 8, 0), - child: Avatar( - image: senderImage, - radius: 10, - ), - ), - Flexible( - child: Row( - children: [ - Flexible(child: tiamat.Text.labelLow(text)), - ], - ), - ) - ], - ), - ), - ), - ), - ); - } -} diff --git a/commet/lib/ui/molecules/emoji_picker.dart b/commet/lib/ui/molecules/emoji_picker.dart index 16a17b62..8229f3a3 100644 --- a/commet/lib/ui/molecules/emoji_picker.dart +++ b/commet/lib/ui/molecules/emoji_picker.dart @@ -16,6 +16,7 @@ class EmojiPicker extends StatelessWidget { this.onlyEmoji = false, this.onlyStickers = false, this.staggered = false, + this.preferredTooltipDirection = AxisDirection.right, this.packListAxis = Axis.vertical}); final void Function(Emoticon emoticon)? onEmoticonPressed; final List packs; @@ -25,6 +26,7 @@ class EmojiPicker extends StatelessWidget { final bool staggered; final bool onlyStickers; final bool onlyEmoji; + final AxisDirection preferredTooltipDirection; final ItemScrollController itemScrollController = ItemScrollController(); @@ -112,7 +114,7 @@ class EmojiPicker extends StatelessWidget { return SizedBox( child: tiamat.Tooltip( text: packs[index].displayName, - preferredDirection: AxisDirection.right, + preferredDirection: preferredTooltipDirection, child: ImageButton( size: packButtonSize, iconSize: packButtonSize - 8, diff --git a/commet/lib/ui/molecules/message_popup_menu/message_popup_menu.dart b/commet/lib/ui/molecules/message_popup_menu/message_popup_menu.dart deleted file mode 100644 index 8c98202f..00000000 --- a/commet/lib/ui/molecules/message_popup_menu/message_popup_menu.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'dart:async'; - -import 'package:commet/client/components/emoticon/emoticon.dart'; -import 'package:commet/client/components/emoticon/emoticon_component.dart'; -import 'package:commet/client/timeline.dart'; -import 'package:commet/ui/atoms/code_block.dart'; -import 'package:commet/ui/molecules/message_popup_menu/message_popup_menu_view_overlay.dart'; -import 'package:commet/ui/molecules/message_popup_menu/message_popup_menu_view_dialog.dart'; -import 'package:commet/ui/navigation/adaptive_dialog.dart'; -import 'package:commet/utils/download_utils.dart'; -import 'package:flutter/material.dart'; -import '../../../client/client.dart'; -import 'package:flutter/services.dart' as services; - -class MessagePopupMenu extends StatefulWidget { - final TimelineEvent event; - final Timeline timeline; - final bool isEditable; - final bool isDeletable; - final Stream? onMessageChanged; - final bool asDialog; - final bool canSaveAttachment; - const MessagePopupMenu(this.event, this.timeline, - {super.key, - this.setEditingEvent, - this.onMessageChanged, - this.setReplyingEvent, - this.isDeletable = false, - this.addReaction, - this.onPopupStateChanged, - this.canSaveAttachment = false, - this.asDialog = false, - this.isEditable = false}); - - final Function(TimelineEvent? event)? setReplyingEvent; - final Function(TimelineEvent? event)? setEditingEvent; - final Function(TimelineEvent event, Emoticon emoticon)? addReaction; - final Function(bool state)? onPopupStateChanged; - - @override - State createState() => MessagePopupMenuState(); -} - -class MessagePopupMenuState extends State { - bool get isEditable => widget.isEditable; - bool get isDeletable => widget.isDeletable; - bool get canSaveAttachment => widget.canSaveAttachment; - Timeline get timeline => widget.timeline; - TimelineEvent get event => widget.event; - Stream? get onMessageChanged => widget.onMessageChanged; - RoomEmoticonComponent? emoticons; - @override - void initState() { - emoticons = widget.timeline.room.getComponent(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - if (widget.asDialog) return MessagePopupMenuViewDialog(this); - - return MessagePopupMenuViewOverlay(this); - } - - void deleteEvent() { - AdaptiveDialog.confirmation(context).then((value) { - if (value == true) { - widget.timeline.deleteEvent(event); - } - }); - } - - void setReplyingEvent() { - widget.setReplyingEvent?.call(event); - } - - void setEditingEvent() { - widget.setEditingEvent?.call(event); - } - - void addReaction(Emoticon emoticon) { - widget.addReaction?.call(event, emoticon); - } - - void onPopupStateChanged(bool state) { - widget.onPopupStateChanged?.call(state); - } - - void copyToClipboard() { - services.Clipboard.setData(services.ClipboardData(text: event.body!)); - } - - void showSource(BuildContext context) { - AdaptiveDialog.show( - context, - title: "Source", - builder: (context) { - return SelectionArea( - child: Codeblock(text: event.rawContent, language: "json"), - ); - }, - ); - } - - void saveAttachment() async { - var attachment = widget.event.attachments?.firstOrNull; - if (attachment != null) { - DownloadUtils.downloadAttachment(attachment); - } - } -} diff --git a/commet/lib/ui/molecules/message_popup_menu/message_popup_menu_view_dialog.dart b/commet/lib/ui/molecules/message_popup_menu/message_popup_menu_view_dialog.dart deleted file mode 100644 index 2bdee64b..00000000 --- a/commet/lib/ui/molecules/message_popup_menu/message_popup_menu_view_dialog.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'package:commet/client/components/push_notification/notification_content.dart'; -import 'package:commet/client/components/push_notification/notification_manager.dart'; -import 'package:commet/client/timeline.dart'; -import 'package:commet/main.dart'; -import 'package:commet/ui/atoms/scaled_safe_area.dart'; -import 'package:commet/ui/molecules/emoji_picker.dart'; -import 'package:commet/ui/molecules/message_popup_menu/message_popup_menu.dart'; -import 'package:commet/ui/molecules/timeline_event.dart'; -import 'package:commet/utils/common_strings.dart'; -import 'package:flutter/material.dart'; -import 'package:tiamat/atoms/seperator.dart'; -import 'package:tiamat/tiamat.dart' as tiamat; - -class MessagePopupMenuViewDialog extends StatelessWidget { - final MessagePopupMenuState state; - - const MessagePopupMenuViewDialog(this.state, {super.key}); - - @override - Widget build(BuildContext context) { - return buildMessageMenu(context, state.event); - } - - Widget buildMessageMenu(BuildContext context, TimelineEvent event) { - return Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), - child: ScaledSafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IgnorePointer( - ignoring: true, - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 4), - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) { - return const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.white, - Colors.transparent, - ], - stops: [0.80, 1.0], - ).createShader(bounds); - }, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 100), - child: SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), - child: SizedBox( - child: TimelineEventView( - hovered: true, - event: event, - timeline: state.timeline), - ), - ), - ), - ), - ), - ), - SizedBox( - height: 50, - child: tiamat.TextButton( - CommonStrings.promptReply, - icon: Icons.reply, - onTap: () { - state.setReplyingEvent(); - Navigator.pop(context); - }, - ), - ), - if (state.emoticons != null) - SizedBox( - height: 50, - child: tiamat.TextButton( - CommonStrings.promptAddReaction, - icon: Icons.add_reaction_rounded, - onTap: () { - Navigator.pop(context); - showReactionMenu(event, context); - }, - ), - ), - if (state.isEditable) - SizedBox( - height: 50, - child: tiamat.TextButton( - CommonStrings.promptEdit, - icon: Icons.edit, - onTap: () { - state.setEditingEvent(); - Navigator.pop(context); - }, - ), - ), - if (state.isDeletable) - SizedBox( - height: 50, - child: tiamat.TextButton( - CommonStrings.promptDelete, - icon: Icons.delete_forever, - onTap: () { - Navigator.pop(context); - state.deleteEvent(); - }, - ), - ), - if (state.canSaveAttachment) - SizedBox( - height: 50, - child: tiamat.TextButton( - CommonStrings.promptDownload, - icon: Icons.download, - onTap: () { - Navigator.pop(context); - state.saveAttachment(); - }, - ), - ), - SizedBox( - height: 50, - child: tiamat.TextButton( - CommonStrings.promptCopy, - icon: Icons.copy, - onTap: () { - state.copyToClipboard(); - Navigator.pop(context); - }, - ), - ), - const Seperator(), - SizedBox( - height: 50, - child: tiamat.TextButton( - "Show Source", - icon: Icons.code, - onTap: () { - Navigator.pop(context); - state.showSource(context); - }, - ), - ), - if (preferences.developerMode) - SizedBox( - height: 50, - child: tiamat.TextButton( - "Send Notification", - icon: Icons.notification_add, - onTap: () async { - var room = state.timeline.room; - var user = - await room.client.getProfile(state.event.senderId); - var content = MessageNotificationContent( - senderName: user!.displayName, - senderImage: user.avatar, - senderId: user.identifier, - roomName: room.displayName, - roomId: room.identifier, - roomImage: await room.getShortcutImage(), - content: event.body ?? "Sent a message", - clientId: room.client.identifier, - eventId: event.eventId, - isDirectMessage: room.isDirectMessage, - ); - - NotificationManager.notify(content, bypassModifiers: true); - }, - ), - ), - ], - ), - ), - ); - } - - void showReactionMenu(TimelineEvent event, BuildContext context) { - showModalBottomSheet( - context: context, - builder: (context) { - return DraggableScrollableSheet( - expand: false, - initialChildSize: 0.6, - minChildSize: 0.6, - builder: (context, scrollController) { - return SizedBox( - height: 700, - child: EmojiPicker(state.emoticons!.availableEmoji, - size: 48, - packButtonSize: 40, onEmoticonPressed: (emoticon) { - state.addReaction(emoticon); - Navigator.pop(context); - })); - }, - ); - }, - ); - } -} diff --git a/commet/lib/ui/molecules/message_popup_menu/message_popup_menu_view_overlay.dart b/commet/lib/ui/molecules/message_popup_menu/message_popup_menu_view_overlay.dart deleted file mode 100644 index 467a0f77..00000000 --- a/commet/lib/ui/molecules/message_popup_menu/message_popup_menu_view_overlay.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'package:commet/ui/molecules/emoji_picker.dart'; -import 'package:commet/ui/molecules/message_popup_menu/message_popup_menu.dart'; -import 'package:flutter/material.dart'; -import 'package:just_the_tooltip/just_the_tooltip.dart'; -import 'dart:async'; - -import 'package:commet/client/components/emoticon/emoticon.dart'; -import 'package:commet/utils/common_strings.dart'; - -import 'package:flutter/material.dart' as m; -import 'package:tiamat/atoms/context_menu.dart'; -import 'package:tiamat/config/style/theme_extensions.dart'; - -import 'package:tiamat/tiamat.dart' as tiamat; - -class MessagePopupMenuViewOverlay extends StatefulWidget { - final MessagePopupMenuState state; - - const MessagePopupMenuViewOverlay(this.state, {super.key}); - - @override - State createState() => - _MessagePopupMenuViewOverlayState(); -} - -class _MessagePopupMenuViewOverlayState - extends State { - JustTheController controller = JustTheController(); - PageStorageBucket storage = PageStorageBucket(); - StreamSubscription? sub; - - @override - void initState() { - super.initState(); - sub = widget.state.onMessageChanged?.listen(onMessageChanged); - controller.addListener(onTooltipControllerStateChanged); - } - - @override - void dispose() { - sub?.cancel(); - controller.removeListener(onTooltipControllerStateChanged); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return JustTheTooltip( - tailLength: 0, - tailBaseWidth: 0, - isModal: true, - controller: controller, - shadow: const Shadow(color: Colors.transparent), - content: PageStorage( - bucket: storage, - child: DecoratedBox( - decoration: BoxDecoration(boxShadow: [ - BoxShadow( - blurRadius: 10, - spreadRadius: 2, - color: Theme.of(context).shadowColor), - ]), - child: ClipRRect( - borderRadius: BorderRadius.circular(5), - child: Container( - color: Theme.of(context).colorScheme.surface, - child: SizedBox( - width: 300, - height: 300, - child: widget.state.emoticons != null - ? EmojiPicker( - widget.state.emoticons!.availableEmoji, - onEmoticonPressed: onEmoticonPicked, - ) - : Container(), - ), - ), - ), - ), - ), - preferredDirection: AxisDirection.up, - child: buildMenu(context)); - } - - void toggleTooltipMenu() { - if (controller.value == TooltipStatus.isHidden) { - controller.showTooltip(); - } else { - controller.hideTooltip(); - } - } - - Widget buildMenu(BuildContext context) { - return m.Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: m.Theme.of(context).colorScheme.surface, - border: Border.all( - color: - m.Theme.of(context).extension()!.surfaceLow2, - width: 1)), - child: m.Padding( - padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), - child: Row( - children: [ - buildMenuEntry(m.Icons.reply, CommonStrings.promptReply, - callback: () { - widget.state.setReplyingEvent(); - }), - buildMenuEntry( - m.Icons.add_reaction, CommonStrings.promptAddReaction, - callback: toggleTooltipMenu), - if (widget.state.isEditable) - buildMenuEntry(m.Icons.edit, CommonStrings.promptEdit, - callback: () { - widget.state.setEditingEvent(); - }), - if (widget.state.isDeletable) - buildMenuEntry(m.Icons.delete, CommonStrings.promptDelete, - callback: () { - widget.state.deleteEvent(); - }), - if (widget.state.canSaveAttachment) - buildMenuEntry(m.Icons.download, CommonStrings.promptDownload, - callback: () { - widget.state.saveAttachment(); - }), - buildMenuEntry(m.Icons.more_vert, CommonStrings.promptOptions, - items: [ - ContextMenuItem( - text: "Show Source", - icon: Icons.code, - onPressed: () => widget.state.showSource(context)) - ]), - ], - ), - ), - ), - ); - } - - Widget buildMenuEntry(IconData icon, String label, - {Function()? callback, List? items}) { - const double size = 32; - var pad = const EdgeInsets.all(2); - return tiamat.Tooltip( - text: label, - child: m.Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(size / 2), - child: Material( - color: Colors.transparent, - child: m.SizedBox( - width: size, - height: size, - child: items != null - ? ContextMenu( - modal: true, - items: items, - child: Padding( - padding: pad, - child: Icon( - icon, - size: size / 1.5, - ), - ), - ) - : InkWell( - onTap: callback, - child: Padding( - padding: pad, - child: Icon( - icon, - size: size / 1.5, - )), - ), - ), - ), - ), - ), - ); - } - - void onMessageChanged(int event) { - controller.hideTooltip(); - } - - void onEmoticonPicked(Emoticon emoticon) { - controller.hideTooltip(); - widget.state.addReaction(emoticon); - } - - void onTooltipControllerStateChanged() { - widget.state - .onPopupStateChanged(controller.value == TooltipStatus.isShowing); - } -} diff --git a/commet/lib/ui/molecules/read_indicator.dart b/commet/lib/ui/molecules/read_indicator.dart index bd83779a..2562005c 100644 --- a/commet/lib/ui/molecules/read_indicator.dart +++ b/commet/lib/ui/molecules/read_indicator.dart @@ -86,7 +86,7 @@ class _SingleUserReadIndicatorState extends State { late Member member; @override void initState() { - member = widget.room.getMemberOrFallback(widget.identifier)!; + member = widget.room.getMemberOrFallback(widget.identifier); super.initState(); } diff --git a/commet/lib/ui/molecules/room_timeline_widget/room_timeline_overlay.dart b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_overlay.dart new file mode 100644 index 00000000..c25807bc --- /dev/null +++ b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_overlay.dart @@ -0,0 +1,236 @@ +import 'package:commet/main.dart'; +import 'package:commet/ui/molecules/room_timeline_widget/room_timeline_overlay_button.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_menu.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:just_the_tooltip/just_the_tooltip.dart'; +import 'package:tiamat/atoms/context_menu.dart'; +import 'package:tiamat/atoms/tile.dart'; +import 'package:tiamat/config/style/theme_extensions.dart'; + +import 'package:tiamat/tiamat.dart' as tiamat; + +import 'package:flutter/material.dart' as m; + +class TimelineOverlay extends StatefulWidget { + const TimelineOverlay( + {required this.link, + this.showMessageMenu = true, + this.jumpToLatest, + super.key}); + final LayerLink link; + final bool showMessageMenu; + final Function()? jumpToLatest; + + @override + State createState() => TimelineOverlayState(); +} + +class TimelineOverlayState extends State { + TimelineEventMenu? currentMenu; + JustTheController controller = JustTheController(); + PageStorageBucket storage = PageStorageBucket(); + + TimelineEventMenuEntry? selectedEntry; + GlobalKey menuKey = GlobalKey(); + + bool? openDownwards; + + final double tooltipHeight = 300; + + bool isAttatchedToBottom = true; + + String get labelJumpToLatest => Intl.message("Jump to latest", + desc: + "Label for the button which jumps the room timeline view to the latest message", + name: "labelJumpToLatest"); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + if (widget.showMessageMenu) + Positioned( + right: 0, + top: 0, + child: CompositedTransformFollower( + targetAnchor: Alignment.topRight, + followerAnchor: openDownwards == true + ? Alignment.topRight + : Alignment.bottomRight, + showWhenUnlinked: false, + offset: Offset(-20, openDownwards == true ? -50 : 0), + link: widget.link, + child: MouseRegion( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: buildTooltipMenu(child: buildPrimaryMenu(context)), + )))), + Align( + alignment: Alignment.bottomCenter, + child: AnimatedSlide( + offset: Offset(0, !isAttatchedToBottom ? 0 : 1), + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOutCubic, + child: RoomTimelineOverlayButton( + text: labelJumpToLatest, + onTap: widget.jumpToLatest, + ), + ), + ) + ], + ); + } + + Widget buildTooltipMenu({required Widget child}) { + return Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + verticalDirection: openDownwards == true + ? VerticalDirection.up + : VerticalDirection.down, + children: [ + if (selectedEntry != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Tile.low1( + child: SizedBox( + width: tooltipHeight, + height: tooltipHeight, + child: MouseRegion( + child: selectedEntry!.secondaryMenuBuilder + ?.call(context, clearSelection))), + ), + ), + ), + Flexible(child: child), + ], + ), + ); + } + + Widget buildPrimaryMenu(BuildContext context) { + return MouseRegion( + child: DecoratedBox( + key: menuKey, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: m.Theme.of(context).colorScheme.surface, + border: Border.all( + color: + m.Theme.of(context).extension()!.surfaceLow2, + width: 1)), + child: currentMenu != null + ? Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), + child: Row(children: [ + for (var e in currentMenu!.primaryActions) + buildAction( + name: e.name, + icon: e.icon, + onTap: e.secondaryMenuBuilder != null + ? () => togglePopupMenu(e) + : () => e.action?.call(context)), + buildAction( + name: "Options", + icon: m.Icons.more_vert, + contextMenuItems: currentMenu!.secondaryActions + .map((e) => ContextMenuItem( + text: e.name, + icon: e.icon, + onPressed: () => e.action?.call(context))) + .toList()) + ]), + ) + : Container()), + ); + } + + void clearSelection() { + setState(() { + openDownwards = null; + selectedEntry = null; + }); + } + + void togglePopupMenu(TimelineEventMenuEntry entry) { + if (selectedEntry == entry) { + setState(() { + selectedEntry = null; + }); + } else { + var obj = menuKey.currentContext?.findRenderObject() as RenderBox; + var pos = obj.localToGlobal(Offset.zero) * preferences.appScale; + openDownwards = pos.dy < (tooltipHeight + 100); + setState(() { + selectedEntry = entry; + }); + } + } + + void setMenu(TimelineEventMenu menu) { + setState(() { + currentMenu = menu; + selectedEntry = null; + }); + } + + void setAttachedToBottom(bool value) { + if (value != isAttatchedToBottom) { + setState(() { + isAttatchedToBottom = value; + }); + } + } + + Widget buildAction( + {required String name, + required IconData icon, + Function()? onTap, + List? contextMenuItems}) { + const double size = 30; + var pad = const EdgeInsets.all(2); + return tiamat.Tooltip( + text: name, + child: m.Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), + child: ClipRRect( + borderRadius: BorderRadius.circular(size / 2), + child: Material( + color: Colors.transparent, + child: m.SizedBox( + width: size, + height: size, + child: contextMenuItems != null + ? ContextMenu( + modal: true, + items: contextMenuItems, + child: Padding( + padding: pad, + child: Icon( + icon, + size: size / 1.5, + ), + ), + ) + : InkWell( + onTap: onTap, + child: Padding( + padding: pad, + child: Icon( + icon, + size: size / 1.5, + )), + ), + ), + ), + ), + ), + ); + } +} diff --git a/commet/lib/ui/molecules/room_timeline_widget/room_timeline_overlay_button.dart b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_overlay_button.dart new file mode 100644 index 00000000..b6bf0655 --- /dev/null +++ b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_overlay_button.dart @@ -0,0 +1,50 @@ +import 'package:commet/config/layout_config.dart'; +import 'package:flutter/material.dart'; +import 'package:tiamat/config/style/theme_extensions.dart'; +import 'package:tiamat/tiamat.dart' as tiamat; + +class RoomTimelineOverlayButton extends StatelessWidget { + const RoomTimelineOverlayButton( + {this.onTap, this.text = "Hello, World!", super.key}); + final void Function()? onTap; + final String text; + + @override + Widget build(BuildContext context) { + var padding = Layout.mobile + ? const EdgeInsets.fromLTRB(18, 12, 18, 12) + : const EdgeInsets.fromLTRB(12, 4, 12, 4); + return Padding( + padding: const EdgeInsets.all(8.0), + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor, + blurRadius: 5, + ) + ], + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).extension()!.highlight, + width: 1), + color: Theme.of(context).extension()!.surfaceHigh1), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: padding, + child: tiamat.Text.labelLow( + text, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ), + ), + )); + } +} diff --git a/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget.dart b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget.dart new file mode 100644 index 00000000..0d69b0c3 --- /dev/null +++ b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:commet/client/timeline.dart'; +import 'package:commet/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart'; +import 'package:flutter/material.dart'; + +class RoomTimelineWidget extends StatefulWidget { + const RoomTimelineWidget( + {required this.timeline, + this.setEditingEvent, + this.setReplyingEvent, + super.key}); + final Timeline timeline; + final Function(TimelineEvent? event)? setReplyingEvent; + final Function(TimelineEvent? event)? setEditingEvent; + + @override + State createState() => _RoomTimelineWidgetState(); +} + +class _RoomTimelineWidgetState extends State { + Future? loadingHistory; + + GlobalKey timelineViewKey = GlobalKey(); + + StreamSubscription? sub; + + @override + void initState() { + sub = widget.timeline.onEventAdded.stream.listen(onEventReceived); + + super.initState(); + } + + @override + void dispose() { + sub?.cancel(); + super.dispose(); + } + + void onEventReceived(int index) { + if (index == 0) { + var state = timelineViewKey.currentState as RoomTimelineWidgetViewState?; + if (state?.attachedToBottom == true) { + widget.timeline.markAsRead(widget.timeline.events[index]); + } + } + } + + @override + Widget build(BuildContext context) { + return RoomTimelineWidgetView( + key: timelineViewKey, + timeline: widget.timeline, + onViewScrolled: onViewScrolled, + onAttachedToBottom: onAttachedToBottom, + setReplyingEvent: widget.setReplyingEvent, + setEditingEvent: widget.setEditingEvent, + ); + } + + void onViewScrolled( + {required double offset, required double maxScrollExtent}) { + double loadingThreshold = 500; + var state = timelineViewKey.currentState as RoomTimelineWidgetViewState?; + + // When the history items are empty, the sliver takes up exactly the height of the viewport, so we should use that height instead + if (state?.historyItemsCount == 0) { + var renderBox = + timelineViewKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + loadingThreshold = renderBox.size.height; + } + } + + if (offset > maxScrollExtent - loadingThreshold && loadingHistory == null) { + loadMoreHistory(); + } + + if (state?.attachedToBottom == true) {} + } + + void onAttachedToBottom() { + if (widget.timeline.events.isNotEmpty) { + widget.timeline.markAsRead(widget.timeline.events.first); + } + } + + void loadMoreHistory() async { + loadingHistory = widget.timeline.loadMoreHistory(); + await loadingHistory; + loadingHistory = null; + } +} diff --git a/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart new file mode 100644 index 00000000..0b124b96 --- /dev/null +++ b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart @@ -0,0 +1,358 @@ +import 'dart:async'; + +import 'package:commet/client/timeline.dart'; +import 'package:commet/config/build_config.dart'; +import 'package:commet/config/layout_config.dart'; +import 'package:commet/debug/log.dart'; +import 'package:commet/main.dart'; +import 'package:commet/ui/molecules/room_timeline_widget/room_timeline_overlay.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_menu.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_view_entry.dart'; +import 'package:flutter/material.dart'; + +class RoomTimelineWidgetView extends StatefulWidget { + const RoomTimelineWidgetView( + {required this.timeline, + this.markAsRead, + this.onViewScrolled, + this.setEditingEvent, + this.setReplyingEvent, + this.onAttachedToBottom, + super.key}); + final Timeline timeline; + final Function(TimelineEvent event)? markAsRead; + final Function(TimelineEvent? event)? setReplyingEvent; + final Function(TimelineEvent? event)? setEditingEvent; + final Function()? onAttachedToBottom; + + final Function({required double offset, required double maxScrollExtent})? + onViewScrolled; + + @override + State createState() => RoomTimelineWidgetViewState(); +} + +class RoomTimelineWidgetViewState extends State { + int numBuilds = 0; + + int recentItemsCount = 0; + int historyItemsCount = 0; + bool firstFrame = true; + + late ScrollController controller; + late List<(GlobalKey, String)> eventKeys; + bool animatingToBottom = false; + + GlobalKey firstFrameScrollViewKey = GlobalKey(); + GlobalKey scrollViewKey = GlobalKey(); + GlobalKey centerKey = GlobalKey(); + GlobalKey recentItemsKey = GlobalKey(); + GlobalKey overlayKey = GlobalKey(); + + LayerLink selectedEventLayerLink = LayerLink(); + SelectableEventViewWidget? selectedEventView; + + late List subscriptions; + + bool wasLastScrollAttachedToBottom = false; + + bool get attachedToBottom => controller.hasClients + ? controller.offset - controller.positions.first.minScrollExtent < 50 || + animatingToBottom + : true; + + @override + void initState() { + recentItemsCount = widget.timeline.events.length; + + subscriptions = [ + widget.timeline.onEventAdded.stream.listen(onEventAdded), + widget.timeline.onChange.stream.listen(onEventChanged), + widget.timeline.onRemove.stream.listen(onEventRemoved), + ]; + + controller = ScrollController(initialScrollOffset: -999999); + WidgetsBinding.instance.addPostFrameCallback(onAfterFirstFrame); + + eventKeys = List.from( + widget.timeline.events + .map((e) => (GlobalKey(debugLabel: e.eventId), e.eventId)), + growable: true); + super.initState(); + } + + @override + void dispose() { + for (var element in subscriptions) { + element.cancel(); + } + + super.dispose(); + } + + void onEventAdded(int index) { + eventKeys.insert(index, ( + GlobalKey(debugLabel: widget.timeline.events[index].eventId), + widget.timeline.events[index].eventId + )); + + if (index == 0 || index < recentItemsCount) { + recentItemsCount += 1; + } else { + historyItemsCount = widget.timeline.events.length - recentItemsCount; + } + + if (index == 0) { + if (attachedToBottom || animatingToBottom) { + WidgetsBinding.instance.addPostFrameCallback((_) { + animateAndSnapToBottom(); + }); + + widget.markAsRead?.call(widget.timeline.events[0]); + } + } + + setState(() {}); + } + + void onEventChanged(int index) { + var event = widget.timeline.events[index]; + var existing = eventKeys[index]; + eventKeys[index] = (existing.$1, event.eventId); + + var key = eventKeys.firstWhere( + (element) => element.$2 == event.eventId, + ); + + assert(event.eventId == key.$2); + + var state = key.$1.currentState; + + if (state is TimelineEventViewWidget) { + (state as TimelineEventViewWidget).update(index); + } else { + Log.w("Failed to get state"); + } + } + + void onEventRemoved(int index) { + var removed = eventKeys.removeAt(index); + assert(widget.timeline.events[index].eventId == removed.$2); + } + + void onAfterFirstFrame(_) { + if (widget.timeline.events.isNotEmpty) { + widget.markAsRead?.call(widget.timeline.events.first); + } + + if (controller.hasClients) { + double extent = controller.position.minScrollExtent; + controller = ScrollController(initialScrollOffset: extent); + controller.addListener(onScroll); + widget.onAttachedToBottom?.call(); + setState(() { + firstFrame = false; + }); + } + } + + void onScroll() { + widget.onViewScrolled?.call( + offset: controller.offset, + maxScrollExtent: controller.position.maxScrollExtent); + + var overlayState = overlayKey.currentState as TimelineOverlayState?; + overlayState?.setAttachedToBottom(attachedToBottom); + + if (wasLastScrollAttachedToBottom == false && attachedToBottom) { + widget.onAttachedToBottom?.call(); + } + + wasLastScrollAttachedToBottom = attachedToBottom; + } + + void animateAndSnapToBottom() { + controller.position.hold(() {}); + + var overlayState = overlayKey.currentState as TimelineOverlayState?; + overlayState?.setAttachedToBottom(attachedToBottom); + widget.onAttachedToBottom?.call(); + + animatingToBottom = true; + + int lastEvent = recentItemsCount; + + controller + .animateTo(controller.position.minScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutExpo) + .then((value) { + if (recentItemsCount == lastEvent) { + controller.jumpTo(controller.position.minScrollExtent); + + animatingToBottom = false; + } + }); + } + + void eventHovered(String eventId) { + var key = eventKeys.firstWhere( + (element) => element.$2 == eventId, + ); + + assert(eventId == key.$2); + + var state = key.$1.currentState; + + if (state is SelectableEventViewWidget) { + var selectable = state as SelectableEventViewWidget; + + if (selectable != selectedEventView) { + deselectEvent(); + + selectable.select(selectedEventLayerLink); + selectedEventView = selectable; + + var overlayState = overlayKey.currentState as TimelineOverlayState?; + var event = widget.timeline.tryGetEvent(eventId)!; + overlayState?.setMenu(TimelineEventMenu( + timeline: widget.timeline, + event: event, + setEditingEvent: (event) => widget.setEditingEvent?.call(event), + setReplyingEvent: (event) => widget.setReplyingEvent?.call(event), + )); + } + } else { + Log.w("Failed to get selectable state"); + } + } + + void deselectEvent() { + var overlayState = overlayKey.currentState as TimelineOverlayState?; + overlayState?.clearSelection(); + + selectedEventView?.deselect(); + selectedEventView = null; + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: MouseRegion( + // onExit: (_) => deselectEvent(), + child: ClipRect( + child: Stack( + children: [ + Offstage( + offstage: firstFrame, + child: CustomScrollView( + key: firstFrame ? firstFrameScrollViewKey : scrollViewKey, + controller: controller, + reverse: true, + center: centerKey, + slivers: [ + SliverList( + key: recentItemsKey, + // Recent Items + delegate: SliverChildBuilderDelegate( + childCount: recentItemsCount, + addAutomaticKeepAlives: false, + (BuildContext context, int sliverIndex) { + int timelineIndex = + recentItemsCount - sliverIndex - 1; + numBuilds += 1; + + var key = eventKeys[timelineIndex]; + assert(key.$2 == + widget.timeline.events[timelineIndex].eventId); + + return Container( + alignment: Alignment.center, + color: + preferences.developerMode && BuildConfig.DEBUG + ? Colors.blue[200 + sliverIndex % 4 * 100]! + .withAlpha(30) + : null, + child: TimelineViewEntry( + key: key.$1, + timeline: widget.timeline, + onEventHovered: eventHovered, + setEditingEvent: widget.setEditingEvent, + setReplyingEvent: widget.setReplyingEvent, + initialIndex: timelineIndex), + ); + }, + findChildIndexCallback: (key) { + var timelineIndex = eventKeys + .indexWhere((element) => element.$1 == key); + if (timelineIndex == -1) { + Log.w( + "Failed to get timeline index for key: $timelineIndex"); + return null; + } + + return recentItemsCount - timelineIndex - 1; + }, + ), + ), + SliverList( + key: centerKey, + // History Items + delegate: SliverChildBuilderDelegate( + addAutomaticKeepAlives: false, + childCount: historyItemsCount, + (BuildContext context, int sliverIndex) { + numBuilds += 1; + // ignore: avoid_print + var timelineIndex = recentItemsCount + sliverIndex; + + var key = eventKeys[timelineIndex]; + assert(key.$2 == + widget.timeline.events[timelineIndex].eventId); + + return Container( + alignment: Alignment.center, + color: + preferences.developerMode && BuildConfig.DEBUG + ? Colors.red[200 + sliverIndex % 4 * 100]! + .withAlpha(30) + : null, + child: TimelineViewEntry( + key: key.$1, + onEventHovered: eventHovered, + timeline: widget.timeline, + setEditingEvent: widget.setEditingEvent, + setReplyingEvent: widget.setReplyingEvent, + initialIndex: timelineIndex), + ); + }, + findChildIndexCallback: (key) { + var timelineIndex = eventKeys + .indexWhere((element) => element.$1 == key); + if (timelineIndex == -1) { + Log.w( + "Failed to get timeline index for key: $timelineIndex"); + return null; + } + + return timelineIndex - recentItemsCount; + }, + ), + ), + ], + ), + ), + TimelineOverlay( + key: overlayKey, + showMessageMenu: Layout.desktop, + jumpToLatest: animateAndSnapToBottom, + link: selectedEventLayerLink) + ], + ), + ), + ), + ); + } +} diff --git a/commet/lib/ui/molecules/timeline_event.dart b/commet/lib/ui/molecules/timeline_event.dart deleted file mode 100644 index 931c8e43..00000000 --- a/commet/lib/ui/molecules/timeline_event.dart +++ /dev/null @@ -1,413 +0,0 @@ -import 'package:commet/client/components/url_preview/url_preview_component.dart'; -import 'package:commet/config/build_config.dart'; -import 'package:commet/diagnostic/benchmark_values.dart'; -import 'package:commet/main.dart'; -import 'package:commet/ui/atoms/generic_room_event.dart'; -import 'package:commet/ui/molecules/message.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart' as m; -import 'package:flutter/widgets.dart'; -import 'package:intl/intl.dart'; -import 'package:tiamat/atoms/icon_button.dart'; -import 'package:tiamat/tiamat.dart' as tiamat; - -import '../../client/client.dart'; -import '../../client/components/emoticon/emoticon.dart'; -import '../atoms/message_attachment.dart'; - -class TimelineEventView extends StatefulWidget { - const TimelineEventView( - {required this.event, - required this.timeline, - super.key, - this.onDelete, - this.hovered = false, - this.showSender = true, - this.setReplyingEvent, - this.setEditingEvent, - this.onDoubleTap, - this.onReactionTapped, - this.onLongPress, - this.deleteEvent, - this.canDeleteEvent = false, - this.useCachedFormat = false, - this.debugInfo}); - final TimelineEvent event; - final bool hovered; - final Function? onDelete; - final bool showSender; - final String? debugInfo; - final Timeline timeline; - final bool useCachedFormat; - final Function()? onDoubleTap; - final Function()? onLongPress; - final Function()? setReplyingEvent; - final Function()? setEditingEvent; - final Function()? deleteEvent; - final bool canDeleteEvent; - final Function(Emoticon emote)? onReactionTapped; - - static int timelineEventBuildsCount = 0; - - @override - State createState() => _TimelineEventState(); -} - -class _TimelineEventState extends State { - TimelineEvent? relatedEvent; - - String messagePlaceholderSticker(String user) => - Intl.message("$user sent a sticker", - desc: "Message body for when a user sends a sticker", - args: [user], - name: "messagePlaceholderSticker"); - - String get messageFailedToDecrypt => Intl.message("Failed to decrypt event", - desc: "Placeholde text for when a message fails to decrypt", - name: "messageFailedToDecrypt"); - - String messagePlaceholderUserCreatedRoom(String user) => - Intl.message("$user created the room!", - desc: "Message body for when a user created the room", - args: [user], - name: "messagePlaceholderUserCreatedRoom"); - - String messagePlaceholderUserJoinedRoom(String user) => - Intl.message("$user joined the room!", - desc: "Message body for when a user joins the room", - args: [user], - name: "messagePlaceholderUserJoinedRoom"); - - String messagePlaceholderUserLeftRoom(String user) => - Intl.message("$user left the room", - desc: "Message body for when a user leaves the room", - args: [user], - name: "messagePlaceholderUserLeftRoom"); - - String messagePlaceholderUserUpdatedAvatar(String user) => - Intl.message("$user updated their avatar", - desc: "Message body for when a user updates their avatar", - args: [user], - name: "messagePlaceholderUserUpdatedAvatar"); - - String messagePlaceholderUserUpdatedName(String user) => - Intl.message("$user updated their display name", - desc: "Message body for when a user updates their display name", - args: [user], - name: "messagePlaceholderUserUpdatedName"); - - String messagePlaceholderUserInvited(String sender, String invitedUser) => - Intl.message("$sender invited $invitedUser", - desc: "Message body for when a user invites another user to the room", - args: [sender, invitedUser], - name: "messagePlaceholderUserInvited"); - - String messagePlaceholderUserRejectedInvite(String user) => - Intl.message("$user rejected the invitation", - desc: "Message body for when a user rejected an invitation to a room", - args: [user], - name: "messagePlaceholderUserRejectedInvite"); - - String messageUserEmote(String user, String emote) => - Intl.message("*$user $emote", - desc: "Message to display when a user does a custom emote (/me)", - args: [user, emote], - name: "messageUserEmote"); - - String get errorMessageFailedToSend => Intl.message("Failed to send", - desc: - "Text that is placed below a message when the message fails to send", - name: "errorMessageFailedToSend"); - - String get displayName => widget.timeline.room - .getMemberOrFallback(widget.event.senderId)! - .displayName; - - ImageProvider? get avatar => - widget.timeline.room.getMemberOrFallback(widget.event.senderId)!.avatar; - - Color get color => widget.timeline.room.getColorOfUser(widget.event.senderId); - - Color get replyColor => relatedEvent == null - ? m.Theme.of(context).colorScheme.onPrimary - : widget.timeline.room.getColorOfUser(relatedEvent!.senderId); - - String? get relatedEventDisplayName => relatedEvent == null - ? null - : widget.timeline.room - .getMemberOrFallback(relatedEvent!.senderId)! - .displayName; - - UrlPreviewData? urlPreviews; - bool loadingUrlPreviews = false; - - @override - void initState() { - if (widget.event.relatedEventId != null) { - relatedEvent = widget.timeline.tryGetEvent(widget.event.relatedEventId!); - if (relatedEvent == null) { - fetchRelatedEvent(); - } - } - - var component = - widget.timeline.room.client.getComponent(); - if (component?.shouldGetPreviewData(widget.timeline.room, widget.event) == - true) { - getCachedUrlPreview(); - if (urlPreviews == null) { - loadingUrlPreviews = true; - fetchUrlPreviews(); - } - } - - super.initState(); - } - - void fetchRelatedEvent() async { - var event = - await widget.timeline.fetchEventById(widget.event.relatedEventId!); - if (mounted) - setState(() { - relatedEvent = event; - }); - } - - void getCachedUrlPreview() { - var component = - widget.timeline.room.client.getComponent(); - - if (component == null) { - return; - } - - var cached = component.getCachedPreview(widget.timeline.room, widget.event); - urlPreviews = cached; - } - - void fetchUrlPreviews() async { - if (widget.event.links == null) { - return; - } - - var component = - widget.timeline.room.client.getComponent(); - - if (component == null) { - return; - } - - var data = await component.getPreview(widget.timeline.room, widget.event); - - if (data?.image != null) { - if (mounted) { - await precacheImage(data!.image!, context); - } - } - - if (mounted) { - setState(() { - urlPreviews = data; - loadingUrlPreviews = false; - }); - } - } - - @override - Widget build(BuildContext context) { - BenchmarkValues.numTimelineEventsBuilt += 1; - - return eventToWidget(widget.event) ?? Container(); - } - - Widget? eventToWidget(TimelineEvent event) { - if (event.status == TimelineEventStatus.removed) return const SizedBox(); - switch (widget.event.type) { - case EventType.message: - case EventType.sticker: - return Message( - senderName: displayName, - senderColor: color, - senderAvatar: avatar, - sentTimeStamp: widget.event.originServerTs, - showDetailed: widget.hovered, - onDoubleTap: widget.onDoubleTap, - onLongPress: widget.onLongPress, - showSender: widget.showSender, - reactions: widget.event.reactions, - currentUserIdentifier: widget.timeline.room.client.self!.identifier, - replyBody: relatedEvent?.body ?? - (relatedEvent?.type == EventType.sticker - ? messagePlaceholderSticker(displayName) - : relatedEvent?.attachments?.firstOrNull?.name), - replySenderName: relatedEventDisplayName, - replySenderColor: replyColor, - isInReply: widget.event.relatedEventId != null, - edited: widget.event.edited, - onReactionTapped: widget.onReactionTapped, - links: urlPreviews, - loadingUrlPreviews: loadingUrlPreviews, - body: buildBody(), - child: event.status == TimelineEventStatus.error - ? tiamat.Text.error(errorMessageFailedToSend) - : null, - ); - case EventType.encrypted: - return Message( - senderName: displayName, - senderColor: color, - senderAvatar: avatar, - showSender: widget.showSender, - body: tiamat.Text.error(messageFailedToDecrypt), - currentUserIdentifier: widget.timeline.room.client.self!.identifier, - sentTimeStamp: widget.event.originServerTs); - case EventType.roomCreated: - return GenericRoomEvent(messagePlaceholderUserCreatedRoom(displayName), - icon: m.Icons.room_preferences_outlined); - case EventType.memberJoined: - return GenericRoomEvent(messagePlaceholderUserJoinedRoom(displayName), - icon: m.Icons.waving_hand_rounded); - case EventType.memberLeft: - return GenericRoomEvent(messagePlaceholderUserLeftRoom(displayName), - icon: m.Icons.subdirectory_arrow_left_rounded); - case EventType.memberAvatar: - return GenericRoomEvent( - messagePlaceholderUserUpdatedAvatar(displayName), - icon: m.Icons.person); - case EventType.memberDisplayName: - return GenericRoomEvent(messagePlaceholderUserUpdatedName(displayName), - icon: m.Icons.edit); - case EventType.memberInvited: - return GenericRoomEvent( - messagePlaceholderUserInvited(displayName, event.stateKey!), - icon: m.Icons.person_add); - case EventType.memberInvitationRejected: - return GenericRoomEvent( - messagePlaceholderUserRejectedInvite(displayName), - icon: m.Icons.subdirectory_arrow_left_rounded); - case EventType.emote: - return GenericRoomEvent( - messageUserEmote(displayName, event.body ?? ""), - senderImage: avatar, - ); - default: - break; - } - - if (BuildConfig.DEBUG && preferences.developerMode) { - return m.Padding( - padding: const EdgeInsets.all(8.0), - child: Placeholder( - child: event.source != null - ? tiamat.Text.tiny(event.source!) - : const Placeholder( - fallbackHeight: 20, - )), - ); - } - return null; - } - - Widget buildMenuEntry(IconData icon, String label, Function()? callback) { - const double size = 32; - return tiamat.Tooltip( - text: label, - child: m.Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), - child: m.SizedBox( - width: size, - height: size, - child: IconButton( - size: 20, - icon: icon, - onPressed: () => callback?.call(), - ), - ), - ), - ); - } - - bool canUserEditEvent() { - return widget.timeline.room.permissions.canUserEditMessages && - widget.event.senderId == widget.timeline.room.client.self!.identifier; - } - - Widget buildBody() { - BenchmarkValues.numTimelineMessageBodyBuilt += 1; - switch (widget.event.type) { - case EventType.message: - return buildMessageBody(); - case EventType.sticker: - return buildStickerBody(); - default: - return const Placeholder( - fallbackHeight: 50, - ); - } - } - - Widget buildMessageBody() { - return m.Material( - color: m.Colors.transparent, - child: Column( - children: [ - buildMessageText(), - if (widget.event.attachments != null) buildMessageAttachments() - ], - ), - ); - } - - Widget buildMessageAttachments() { - return Wrap( - children: widget.event.attachments! - .map((e) => Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 8, 8), - child: MessageAttachment( - e, - ), - )) - .toList(), - ); - } - - Widget buildMessageText() { - if (widget.event.bodyFormat != null) { - var formatted = widget.useCachedFormat - ? widget.event.formattedContent - : widget.event.buildFormattedContent(); - - // if the cache didnt have anything lets just build new content. This should really never happen though - formatted ??= widget.event.buildFormattedContent(); - - return formatted; - } - - if (widget.event.body != null) - return tiamat.Text.body("${widget.event.body}\n"); - - return const SizedBox(); - } - - Widget buildStickerBody() { - return m.Material( - color: m.Colors.transparent, - child: Column( - children: [ - if (widget.event.attachments != null) - Wrap( - children: widget.event.attachments! - .map((e) => Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 8, 8), - child: MessageAttachment( - e, - ignorePointer: widget.event.type == EventType.sticker, - ), - )) - .toList(), - ), - ], - ), - ); - } -} diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_attachments.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_attachments.dart new file mode 100644 index 00000000..6036a94a --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_attachments.dart @@ -0,0 +1,23 @@ +import 'package:commet/client/attachment.dart'; +import 'package:commet/ui/atoms/message_attachment.dart'; +import 'package:flutter/material.dart'; + +class TimelineEventViewAttachments extends StatelessWidget { + const TimelineEventViewAttachments({required this.attachments, super.key}); + final List attachments; + @override + Widget build(BuildContext context) { + return Wrap( + children: attachments + .map((e) => Padding( + padding: const EdgeInsets.fromLTRB(0, 2, 2, 2), + child: RepaintBoundary( + child: MessageAttachment( + e, + ), + ), + )) + .toList(), + ); + } +} diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_generic.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_generic.dart new file mode 100644 index 00000000..37fbf873 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_generic.dart @@ -0,0 +1,184 @@ +import 'package:commet/client/timeline.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:flutter/material.dart' as m; + +import 'package:tiamat/tiamat.dart' as tiamat; + +import 'package:tiamat/atoms/avatar.dart'; + +class TimelineEventViewGeneric extends StatefulWidget { + const TimelineEventViewGeneric( + {required this.timeline, required this.initialIndex, super.key}); + final Timeline timeline; + final int initialIndex; + @override + State createState() => + _TimelineEventViewGenericState(); +} + +class _TimelineEventViewGenericState extends State + implements TimelineEventViewWidget { + late String? text; + late IconData? icon; + late ImageProvider? senderAvatar; + + String messagePlaceholderSticker(String user) => + Intl.message("$user sent a sticker", + desc: "Message body for when a user sends a sticker", + args: [user], + name: "messagePlaceholderSticker"); + + String messagePlaceholderUserCreatedRoom(String user) => + Intl.message("$user created the room!", + desc: "Message body for when a user created the room", + args: [user], + name: "messagePlaceholderUserCreatedRoom"); + + String messagePlaceholderUserJoinedRoom(String user) => + Intl.message("$user joined the room!", + desc: "Message body for when a user joins the room", + args: [user], + name: "messagePlaceholderUserJoinedRoom"); + + String messagePlaceholderUserLeftRoom(String user) => + Intl.message("$user left the room", + desc: "Message body for when a user leaves the room", + args: [user], + name: "messagePlaceholderUserLeftRoom"); + + String messagePlaceholderUserUpdatedAvatar(String user) => + Intl.message("$user updated their avatar", + desc: "Message body for when a user updates their avatar", + args: [user], + name: "messagePlaceholderUserUpdatedAvatar"); + + String messagePlaceholderUserUpdatedName(String user) => + Intl.message("$user updated their display name", + desc: "Message body for when a user updates their display name", + args: [user], + name: "messagePlaceholderUserUpdatedName"); + + String messagePlaceholderUserInvited(String sender, String invitedUser) => + Intl.message("$sender invited $invitedUser", + desc: "Message body for when a user invites another user to the room", + args: [sender, invitedUser], + name: "messagePlaceholderUserInvited"); + + String messagePlaceholderUserRejectedInvite(String user) => + Intl.message("$user rejected the invitation", + desc: "Message body for when a user rejected an invitation to a room", + args: [user], + name: "messagePlaceholderUserRejectedInvite"); + + String messageUserEmote(String user, String emote) => + Intl.message("*$user $emote", + desc: "Message to display when a user does a custom emote (/me)", + args: [user, emote], + name: "messageUserEmote"); + + String get errorMessageFailedToSend => Intl.message("Failed to send", + desc: + "Text that is placed below a message when the message fails to send", + name: "errorMessageFailedToSend"); + + @override + void initState() { + setStateFromindex(widget.initialIndex); + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (text == null) { + return Container(); + } + + return m.Material( + color: m.Colors.transparent, + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.fromLTRB(44, 0, 8, 0), + child: Icon( + icon, + size: 20, + ), + ), + if (senderAvatar != null) + Padding( + padding: const EdgeInsets.fromLTRB(44, 0, 8, 0), + child: Avatar( + image: senderAvatar, + radius: 10, + ), + ), + Flexible( + child: Row( + children: [ + Flexible(child: tiamat.Text.labelLow(text!)), + ], + ), + ) + ], + ), + ), + ), + ), + ); + } + + @override + void update(int newIndex) { + setStateFromindex(newIndex); + } + + void setStateFromindex(int index) { + var event = widget.timeline.events[index]; + var sender = widget.timeline.room.getMemberOrFallback(event.senderId); + var displayName = sender.displayName; + + if ([EventType.emote].contains(event.type)) { + senderAvatar = sender.avatar; + } else { + senderAvatar = null; + } + + text = switch (event.type) { + EventType.roomCreated => messagePlaceholderUserCreatedRoom(displayName), + EventType.memberJoined => messagePlaceholderUserJoinedRoom(displayName), + EventType.memberLeft => messagePlaceholderUserLeftRoom(displayName), + EventType.memberAvatar => + messagePlaceholderUserUpdatedAvatar(displayName), + EventType.memberDisplayName => + messagePlaceholderUserUpdatedName(displayName), + EventType.memberInvited => + messagePlaceholderUserInvited(displayName, event.stateKey!), + EventType.memberInvitationRejected => + messagePlaceholderUserRejectedInvite(displayName), + EventType.emote => messageUserEmote(displayName, event.body ?? ""), + _ => "$displayName: ${event.body}" + }; + + icon = switch (event.type) { + EventType.roomCreated => m.Icons.room_preferences_outlined, + EventType.memberJoined => m.Icons.waving_hand_rounded, + EventType.memberLeft => m.Icons.subdirectory_arrow_left_rounded, + EventType.memberAvatar => m.Icons.person, + EventType.memberDisplayName => m.Icons.edit, + EventType.memberInvited => m.Icons.person_add, + EventType.memberInvitationRejected => + m.Icons.subdirectory_arrow_left_rounded, + _ => null + }; + } +} diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_message.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_message.dart new file mode 100644 index 00000000..bf94afff --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_message.dart @@ -0,0 +1,199 @@ +import 'package:commet/client/attachment.dart'; +import 'package:commet/client/client.dart'; +import 'package:commet/client/components/url_preview/url_preview_component.dart'; +import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_attachments.dart'; +import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_reactions.dart'; +import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_reply.dart'; +import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_url_previews.dart'; +import 'package:commet/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:tiamat/tiamat.dart' as tiamat; +import 'package:intl/intl.dart' as intl; +import 'package:intl/intl.dart'; + +class TimelineEventViewMessage extends StatefulWidget { + const TimelineEventViewMessage( + {super.key, + required this.timeline, + this.overrideShowSender = false, + this.detailed = false, + required this.initialIndex}); + + final Timeline timeline; + final int initialIndex; + final bool overrideShowSender; + final bool detailed; + + @override + State createState() => + _TimelineEventViewMessageState(); +} + +class _TimelineEventViewMessageState extends State + implements TimelineEventViewWidget { + late String senderName; + late Color senderColor; + + String get messageFailedToDecrypt => Intl.message("Failed to decrypt event", + desc: "Placeholde text for when a message fails to decrypt", + name: "messageFailedToDecrypt"); + + GlobalKey reactionsKey = GlobalKey(); + GlobalKey urlPreviewsKey = GlobalKey(); + + Widget? formattedContent; + ImageProvider? senderAvatar; + List? attachments; + bool hasReactions = false; + bool isInResponse = false; + bool showSender = false; + late String currentUserIdentifier; + late DateTime sentTime; + + UrlPreviewComponent? previewComponent; + bool doUrlPreview = false; + + int index = 0; + + late bool edited; + + @override + void initState() { + currentUserIdentifier = widget.timeline.client.self!.identifier; + previewComponent = + widget.timeline.room.client.getComponent(); + loadEventState(widget.initialIndex); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return TimelineEventLayoutMessage( + senderName: senderName, + senderColor: senderColor, + senderAvatar: senderAvatar, + showSender: showSender, + formattedContent: formattedContent, + timestamp: timestampToString(sentTime), + edited: edited, + attachments: attachments != null + ? TimelineEventViewAttachments(attachments: attachments!) + : null, + inResponseTo: isInResponse + ? TimelineEventViewReply( + timeline: widget.timeline, + index: index, + ) + : null, + reactions: hasReactions + ? TimelineEventViewReactions( + key: reactionsKey, timeline: widget.timeline, initialIndex: index) + : null, + urlPreviews: previewComponent != null && doUrlPreview + ? TimelineEventViewUrlPreviews( + initialIndex: index, + timeline: widget.timeline, + component: previewComponent!, + key: urlPreviewsKey, + ) + : null, + ); + } + + @override + void update(int newIndex) { + setState(() { + loadEventState(newIndex); + }); + + for (var key in [reactionsKey, urlPreviewsKey]) { + if (key.currentState is TimelineEventViewWidget) { + (key.currentState as TimelineEventViewWidget).update(newIndex); + } + } + } + + void loadEventState(var eventIndex) { + index = eventIndex; + var event = widget.timeline.events[eventIndex]; + var sender = widget.timeline.room.getMemberOrFallback(event.senderId); + + senderName = sender.displayName; + senderAvatar = sender.avatar; + senderColor = sender.defaultColor; + + showSender = shouldShowSender(eventIndex); + + edited = event.edited; + if (event.type == EventType.encrypted) { + formattedContent = tiamat.Text.error(messageFailedToDecrypt); + } + + if (event.bodyFormat != null) { + formattedContent = + Container(key: GlobalKey(), child: event.buildFormattedContent()!); + } + + hasReactions = event.reactions != null && event.reactions!.isNotEmpty; + + attachments = event.attachments; + isInResponse = event.relatedEventId != null && + event.relationshipType == EventRelationshipType.reply; + + sentTime = event.originServerTs; + + doUrlPreview = + previewComponent?.shouldGetPreviewData(widget.timeline.room, event) == + true && + event.links?.isNotEmpty == true; + } + + String timestampToString(DateTime time) { + var use24 = MediaQuery.of(context).alwaysUse24HourFormat; + + if (widget.detailed) { + if (use24) { + return intl.DateFormat.yMMMMd().add_Hms().format(time.toLocal()); + } else { + return intl.DateFormat.yMMMMd().add_jms().format(time.toLocal()); + } + } else { + if (use24) { + return intl.DateFormat.Hm().format(time.toLocal()); + } else { + return intl.DateFormat.jm().format(time.toLocal()); + } + } + } + + bool shouldShowSender(int index) { + if (widget.overrideShowSender) return true; + + if (widget.timeline.events.length <= index + 1) { + return true; + } + + if (widget.timeline.events[index].relationshipType == + EventRelationshipType.reply) return true; + + if (![EventType.message, EventType.encrypted] + .contains(widget.timeline.events[index + 1].type)) return true; + + if (widget.timeline.events[index + 1].status == + TimelineEventStatus.removed) { + return true; + } + + if (widget.timeline.events[index].originServerTs + .difference(widget.timeline.events[index + 1].originServerTs) + .inMinutes > + 1) return true; + + return widget.timeline.events[index].senderId != + widget.timeline.events[index + 1].senderId; + } +} diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reactions.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reactions.dart new file mode 100644 index 00000000..98d93511 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reactions.dart @@ -0,0 +1,70 @@ +import 'package:commet/client/components/emoticon/emoticon.dart'; +import 'package:commet/client/timeline.dart'; +import 'package:commet/ui/atoms/emoji_reaction.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; +import 'package:flutter/material.dart'; + +import 'package:flutter/material.dart' as material; + +class TimelineEventViewReactions extends StatefulWidget { + const TimelineEventViewReactions( + {required this.initialIndex, required this.timeline, super.key}); + + final int initialIndex; + final Timeline timeline; + + @override + State createState() => + _TimelineEventViewReactionsState(); +} + +class _TimelineEventViewReactionsState extends State + implements TimelineEventViewWidget { + late Map> reactions; + + late final String? currentUserIdentifier; + late final TimelineEvent event; + + @override + void initState() { + event = widget.timeline.events[widget.initialIndex]; + currentUserIdentifier = widget.timeline.client.self?.identifier; + setStateFromIndex(widget.initialIndex); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 3, + runSpacing: 3, + direction: material.Axis.horizontal, + children: reactions.keys.map((key) { + var value = reactions[key]!; + return EmojiReaction( + emoji: key, + onTapped: onReactionTapped, + numReactions: value.length, + highlighted: value.contains(currentUserIdentifier)); + }).toList()); + } + + onReactionTapped(Emoticon emote) { + if (reactions[emote]?.contains(currentUserIdentifier) == true) { + widget.timeline.room.removeReaction(event, emote); + } else { + widget.timeline.room.addReaction(event, emote); + } + } + + @override + void update(int newIndex) { + setStateFromIndex(newIndex); + } + + void setStateFromIndex(int index) { + setState(() { + reactions = widget.timeline.events[index].reactions!; + }); + } +} diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reply.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reply.dart new file mode 100644 index 00000000..76d1156d --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reply.dart @@ -0,0 +1,136 @@ +import 'package:commet/client/timeline.dart'; +import 'package:commet/diagnostic/benchmark_values.dart'; +import 'package:flutter/material.dart'; +import 'package:tiamat/tiamat.dart' as tiamat; +import 'package:flutter/material.dart' as material; + +class TimelineEventViewReply extends StatefulWidget { + const TimelineEventViewReply( + {super.key, + required this.timeline, + required this.index, + this.avatarSize = 32}); + final Timeline timeline; + final int index; + final double avatarSize; + + @override + State createState() => _TimelineEventViewReplyState(); +} + +class _TimelineEventViewReplyState extends State { + String? senderName; + String? body; + Color? senderColor; + + bool loading = false; + + @override + void initState() { + getStateFromIndex(widget.index); + super.initState(); + } + + void getStateFromIndex(int index) { + var event = widget.timeline.events[index]; + var replyEvent = widget.timeline.tryGetEvent(event.relatedEventId!); + + if (replyEvent == null) { + loading = true; + widget.timeline.room.getEvent(event.relatedEventId!).then((value) { + if (mounted && value != null) { + setStateFromEvent(value); + } + }); + } else { + setStateFromEvent(replyEvent); + } + } + + void setStateFromEvent(TimelineEvent event) { + setState(() { + var sender = widget.timeline.room.getMemberOrFallback(event.senderId); + senderName = sender.displayName; + senderColor = sender.defaultColor; + body = event.body ?? ""; + loading = false; + }); + } + + @override + Widget build(BuildContext context) { + BenchmarkValues.numTimelineReplyBodyBuilt += 1; + return IntrinsicHeight( + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox( + width: 45, + child: SizedBox.expand( + child: CustomPaint( + painter: ReplyLinePainter2( + pathColor: material.Theme.of(context).colorScheme.secondary, + avatarSize: widget.avatarSize), + ), + ), + ), + Column( + children: [ + tiamat.Text( + senderName ?? "Loading", + color: senderColor, + autoAdjustBrightness: true, + ), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), + child: tiamat.Text( + body ?? "Unknown", + maxLines: 2, + overflow: TextOverflow.ellipsis, + color: material.Theme.of(context).colorScheme.secondary, + ), + ), + ), + ]), + ); + } +} + +class ReplyLinePainter2 extends CustomPainter { + Color pathColor; + double strokeWidth; + double radius; + double padding; + double avatarSize; + ReplyLinePainter2( + {this.pathColor = Colors.white, + this.strokeWidth = 1.5, + this.radius = 5, + this.avatarSize = 32, + this.padding = 4}) { + _paint = Paint() + ..color = pathColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + } + + late Paint _paint; + + @override + void paint(Canvas canvas, Size size) { + Path path = Path(); + path.moveTo(avatarSize / 2, size.height - padding); + path.relativeLineTo(0, (-size.height + 11 + padding) + radius); + path.relativeArcToPoint(Offset(radius, -radius), + radius: Radius.circular(radius)); + path.relativeLineTo(size.width - (avatarSize / 2) - radius - padding, 0); + canvas.drawPath(path, _paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } +} diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_url_previews.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_url_previews.dart new file mode 100644 index 00000000..8e48395d --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_url_previews.dart @@ -0,0 +1,95 @@ +import 'package:commet/client/components/url_preview/url_preview_component.dart'; +import 'package:commet/client/timeline.dart'; +import 'package:commet/diagnostic/benchmark_values.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; +import 'package:commet/ui/molecules/url_preview_widget.dart'; +import 'package:commet/utils/link_utils.dart'; +import 'package:flutter/material.dart'; + +class TimelineEventViewUrlPreviews extends StatefulWidget { + const TimelineEventViewUrlPreviews( + {required this.initialIndex, + required this.timeline, + required this.component, + super.key}); + + final int initialIndex; + final Timeline timeline; + final UrlPreviewComponent component; + + @override + State createState() => + _TimelineEventViewUrlPreviewsState(); +} + +class _TimelineEventViewUrlPreviewsState + extends State + implements TimelineEventViewWidget { + UrlPreviewData? data; + bool loading = false; + + GlobalKey key = GlobalKey(); + + @override + Widget build(BuildContext context) { + BenchmarkValues.numTimelineUrlPreviewBuilt += 1; + return Padding( + padding: const EdgeInsets.fromLTRB(0, 2, 40, 2), + child: (loading || data != null) + ? UrlPreviewWidget( + key: key, + data, + onTap: () { + LinkUtils.open(data!.uri); + }, + ) + : Container(), + ); + } + + @override + void update(int newIndex) { + setStateFromIndex(newIndex); + } + + @override + void initState() { + setStateFromIndex(widget.initialIndex); + super.initState(); + } + + void setStateFromIndex(int index) { + var event = widget.timeline.events[index]; + var cachedData = + widget.component.getCachedPreview(widget.timeline.room, event); + + setState(() { + data = cachedData; + key = GlobalKey(); + }); + + if (cachedData == null) { + setState(() { + loading = true; + }); + widget.component.getPreview(widget.timeline.room, event).then( + (value) async { + if (mounted) { + final image = value?.image; + if (image != null) { + if (context.mounted) { + await precacheImage(image, context); + } + } + + setState(() { + loading = false; + data = value; + key = GlobalKey(); + }); + } + }, + ); + } + } +} diff --git a/commet/lib/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart b/commet/lib/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart new file mode 100644 index 00000000..19191298 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart @@ -0,0 +1,108 @@ +import 'package:commet/diagnostic/benchmark_values.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:tiamat/tiamat.dart' as tiamat; + +class TimelineEventLayoutMessage extends StatelessWidget { + const TimelineEventLayoutMessage( + {super.key, + required this.senderName, + required this.senderColor, + this.senderAvatar, + this.formattedContent, + this.attachments, + this.inResponseTo, + this.reactions, + this.timestamp, + this.urlPreviews, + this.edited = false, + this.avatarSize = 32, + this.showSender = true}); + final String senderName; + final Color senderColor; + final ImageProvider? senderAvatar; + final Widget? formattedContent; + final Widget? attachments; + final Widget? inResponseTo; + final Widget? reactions; + final Widget? urlPreviews; + final bool showSender; + final bool edited; + final String? timestamp; + + final double avatarSize; + + String get messageEditedMarker => Intl.message("(Edited)", + name: "messageEditedMarker", + desc: "Short text to mark that a message has been edited"); + + @override + Widget build(BuildContext context) { + BenchmarkValues.numTimelineMessageBodyBuilt += 1; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 8, 2), + child: Column( + children: [ + if (inResponseTo != null) inResponseTo!, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + avatar(), + Flexible( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 0, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSender) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + name(), + if (timestamp != null) + tiamat.Text.labelLow(timestamp!), + ], + ), + if (formattedContent != null) + RepaintBoundary(child: formattedContent!), + if (edited) tiamat.Text.labelLow(messageEditedMarker), + if (attachments != null) attachments!, + if (urlPreviews != null) urlPreviews!, + if (reactions != null) + Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 0), + child: reactions!, + ), + ], + ), + ), + ) + ], + ) + ], + ), + ); + } + + tiamat.Text name() { + return tiamat.Text.name( + senderName, + color: senderColor, + ); + } + + SizedBox avatar() { + return SizedBox( + width: avatarSize, + child: tiamat.Avatar( + radius: avatarSize / 2, + image: senderAvatar, + placeholderText: senderName, + placeholderColor: senderColor, + isPadding: showSender == false, + ), + ); + } +} diff --git a/commet/lib/ui/molecules/timeline_events/timeline_event_date_time_marker.dart b/commet/lib/ui/molecules/timeline_events/timeline_event_date_time_marker.dart new file mode 100644 index 00000000..3d4918e2 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/timeline_event_date_time_marker.dart @@ -0,0 +1,39 @@ +import 'package:commet/utils/text_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:tiamat/config/style/theme_extensions.dart'; +import 'package:tiamat/tiamat.dart' as tiamat; + +class TimelineEventDateTimeMarker extends StatelessWidget { + const TimelineEventDateTimeMarker({required this.time, super.key}); + final DateTime time; + + @override + Widget build(BuildContext context) { + var color = Theme.of(context).extension()!.surfaceLow2; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Divider( + height: 1, + color: Theme.of(context).extension()!.surfaceLow2, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 0), + child: tiamat.Text.labelLow(TextUtils.timestampToLocalizedTime( + time, MediaQuery.of(context).alwaysUse24HourFormat)), + ), + Expanded( + child: Divider( + height: 1, + color: color, + ), + ), + ], + ), + ); + } +} diff --git a/commet/lib/ui/molecules/timeline_events/timeline_event_layout.dart b/commet/lib/ui/molecules/timeline_events/timeline_event_layout.dart new file mode 100644 index 00000000..0c293db0 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/timeline_event_layout.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; + +abstract class TimelineEventViewWidget { + void update(int newIndex); +} + +abstract class SelectableEventViewWidget { + void select(LayerLink link); + + void deselect(); +} diff --git a/commet/lib/ui/molecules/timeline_events/timeline_event_menu.dart b/commet/lib/ui/molecules/timeline_events/timeline_event_menu.dart new file mode 100644 index 00000000..c9bb2f82 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/timeline_event_menu.dart @@ -0,0 +1,161 @@ +import 'package:commet/client/components/emoticon/emoticon_component.dart'; +import 'package:commet/client/components/push_notification/notification_content.dart'; +import 'package:commet/client/components/push_notification/notification_manager.dart'; +import 'package:commet/client/timeline.dart'; +import 'package:commet/main.dart'; +import 'package:commet/ui/atoms/code_block.dart'; +import 'package:commet/ui/molecules/emoji_picker.dart'; +import 'package:commet/ui/navigation/adaptive_dialog.dart'; +import 'package:commet/utils/common_strings.dart'; +import 'package:commet/utils/download_utils.dart'; +import 'package:flutter/material.dart'; + +class TimelineEventMenu { + final Timeline timeline; + final TimelineEvent event; + + late final List primaryActions; + late final List secondaryActions; + + final Function(TimelineEvent event)? setEditingEvent; + final Function(TimelineEvent event)? setReplyingEvent; + final Function()? onActionFinished; + + TimelineEventMenu({ + required this.timeline, + required this.event, + this.setEditingEvent, + this.setReplyingEvent, + this.onActionFinished, + }) { + bool canEditEvent = event.type == EventType.message && + timeline.room.permissions.canUserEditMessages && + event.senderId == timeline.room.client.self!.identifier && + setEditingEvent != null; + + bool canDeleteEvent = timeline.canDeleteEvent(event); + + bool canReply = [EventType.message, EventType.emote, EventType.sticker] + .contains(event.type) && + setReplyingEvent != null; + + bool canSaveAttachment = event.attachments?.isNotEmpty ?? false; + + var emoticons = timeline.room.getComponent(); + bool canAddReaction = + [EventType.message, EventType.sticker].contains(event.type) && + emoticons != null; + + primaryActions = [ + if (canEditEvent) + TimelineEventMenuEntry( + name: CommonStrings.promptEdit, + icon: Icons.edit, + action: (BuildContext context) { + setEditingEvent?.call(event); + onActionFinished?.call(); + }), + if (canReply) + TimelineEventMenuEntry( + name: CommonStrings.promptReply, + icon: Icons.reply, + action: (BuildContext context) { + setReplyingEvent?.call(event); + onActionFinished?.call(); + }), + if (canSaveAttachment) + TimelineEventMenuEntry( + name: CommonStrings.promptDownload, + icon: Icons.download, + action: (BuildContext context) { + var attachment = event.attachments?.firstOrNull; + if (attachment != null) { + DownloadUtils.downloadAttachment(attachment); + } + onActionFinished?.call(); + }), + if (canAddReaction) + TimelineEventMenuEntry( + name: CommonStrings.promptAddReaction, + icon: Icons.add_reaction, + secondaryMenuBuilder: (context, dismissSecondaryMenu) { + return EmojiPicker(emoticons.availableEmoji, + preferredTooltipDirection: AxisDirection.left, + onEmoticonPressed: (emote) async { + timeline.room.addReaction(event, emote); + await Future.delayed(const Duration(milliseconds: 100)); + dismissSecondaryMenu(); + }); + }, + ), + if (canDeleteEvent) + TimelineEventMenuEntry( + name: CommonStrings.promptDelete, + icon: Icons.delete, + action: (BuildContext context) => { + AdaptiveDialog.confirmation(context).then((value) { + if (value == true) { + timeline.deleteEvent(event); + } + onActionFinished?.call(); + }) + }), + ]; + + secondaryActions = [ + TimelineEventMenuEntry( + name: "Show Source", + icon: Icons.code, + action: (BuildContext context) { + onActionFinished?.call(); + AdaptiveDialog.show( + context, + title: "Source", + builder: (context) { + return SelectionArea( + child: Codeblock(text: event.rawContent, language: "json"), + ); + }, + ); + }), + if (preferences.developerMode && event.type == EventType.message) + TimelineEventMenuEntry( + name: "Show Notification", + icon: Icons.notification_add, + action: (BuildContext context) async { + var room = timeline.room; + var user = await room.client.getProfile(event.senderId); + var content = MessageNotificationContent( + senderName: user!.displayName, + senderImage: user.avatar, + senderId: user.identifier, + roomName: room.displayName, + roomId: room.identifier, + roomImage: await room.getShortcutImage(), + content: event.body ?? "Sent a message", + clientId: room.client.identifier, + eventId: event.eventId, + isDirectMessage: room.isDirectMessage, + ); + + NotificationManager.notify(content, bypassModifiers: true); + onActionFinished?.call(); + }), + ]; + } +} + +class TimelineEventMenuEntry { + final String name; + final Function(BuildContext context)? action; + final IconData icon; + + final Widget Function(BuildContext context, Function() dismissMenu)? + secondaryMenuBuilder; + + TimelineEventMenuEntry( + {required this.name, + required this.icon, + this.action, + this.secondaryMenuBuilder}); +} diff --git a/commet/lib/ui/molecules/timeline_events/timeline_event_menu_dialog.dart b/commet/lib/ui/molecules/timeline_events/timeline_event_menu_dialog.dart new file mode 100644 index 00000000..7bc37cf9 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/timeline_event_menu_dialog.dart @@ -0,0 +1,105 @@ +import 'package:commet/client/timeline.dart'; +import 'package:commet/ui/atoms/scaled_safe_area.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_menu.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_view_entry.dart'; +import 'package:flutter/material.dart'; +import 'package:tiamat/atoms/seperator.dart'; +import 'package:tiamat/tiamat.dart' as tiamat; + +class TimelineEventMenuDialog extends StatelessWidget { + const TimelineEventMenuDialog( + {required this.event, + required this.timeline, + required this.menu, + super.key}); + + final TimelineEvent event; + final Timeline timeline; + + final TimelineEventMenu menu; + + @override + Widget build(BuildContext context) { + return buildMessageMenu(context, event); + } + + Widget buildMessageMenu(BuildContext context, TimelineEvent event) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + child: ScaledSafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IgnorePointer( + ignoring: true, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 4), + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white, + Colors.transparent, + ], + stops: [0.80, 1.0], + ).createShader(bounds); + }, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 100), + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: SizedBox( + child: TimelineViewEntry( + timeline: timeline, + singleEvent: true, + initialIndex: timeline.events.indexOf(event), + showDetailed: true, + ), + ), + ), + ), + ), + ), + ), + for (var action in menu.primaryActions) + SizedBox( + height: 50, + child: tiamat.TextButton(action.name, + icon: action.icon, onTap: () => doAction(action, context)), + ), + const Seperator(), + for (var action in menu.secondaryActions) + SizedBox( + height: 50, + child: tiamat.TextButton(action.name, + icon: action.icon, onTap: () => doAction(action, context)), + ), + ], + ), + ), + ); + } + + void doAction(TimelineEventMenuEntry entry, BuildContext context) async { + if (entry.action != null) { + entry.action?.call(context); + return; + } + + if (entry.secondaryMenuBuilder != null) { + await showModalBottomSheet( + context: context, + builder: (newContext) { + return entry.secondaryMenuBuilder!.call(newContext, () { + Navigator.of(newContext).pop(); + }); + }, + ); + + if (context.mounted) Navigator.of(context).pop(); + } + } +} diff --git a/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart b/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart new file mode 100644 index 00000000..9ea75307 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart @@ -0,0 +1,243 @@ +import 'package:commet/client/client.dart'; +import 'package:commet/config/layout_config.dart'; +import 'package:commet/debug/log.dart'; +import 'package:commet/diagnostic/benchmark_values.dart'; +import 'package:commet/main.dart'; +import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_generic.dart'; +import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_message.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_date_time_marker.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_menu.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_menu_dialog.dart'; +import 'package:flutter/material.dart'; + +class TimelineViewEntry extends StatefulWidget { + const TimelineViewEntry( + {required this.timeline, + required this.initialIndex, + this.onEventHovered, + this.setEditingEvent, + this.setReplyingEvent, + this.showDetailed = false, + this.singleEvent = false, + super.key}); + final Timeline timeline; + final int initialIndex; + final Function(String eventId)? onEventHovered; + final Function(TimelineEvent? event)? setReplyingEvent; + final Function(TimelineEvent? event)? setEditingEvent; + final bool showDetailed; + + // Should be true if we are showing this event on its own, and not as part of a timeline + final bool singleEvent; + + @override + State createState() => TimelineViewEntryState(); +} + +class TimelineViewEntryState extends State + implements TimelineEventViewWidget, SelectableEventViewWidget { + late String eventId; + late EventType eventType; + late TimelineEventStatus status; + + // Note that this index is only reliable on builds - if an item is inserted in to the list, this index will be out of sync until its updated. + // If you need to get the event which this widget represents, use the ID + late int index; + + GlobalKey eventKey = GlobalKey(); + + bool selected = false; + LayerLink? timelineLayerLink; + + late DateTime time; + bool showDate = false; + + @override + void initState() { + loadState(widget.initialIndex); + super.initState(); + } + + void loadState(int eventIndex) { + var event = widget.timeline.events[eventIndex]; + eventId = event.eventId; + eventType = event.type; + status = event.status; + index = eventIndex; + time = event.originServerTs; + showDate = shouldEventShowDate(eventIndex); + } + + bool shouldEventShowDate(int index) { + if (widget.singleEvent) { + return false; + } + + var offsetIndex = index + 1; + + if (widget.timeline.events.length <= offsetIndex) { + return false; + } + + if ([ + EventType.emote, + EventType.message, + ].contains(widget.timeline.events[index].type) == + false) { + return false; + } + + if (widget.timeline.events[index].originServerTs.toLocal().day != + widget.timeline.events[offsetIndex].originServerTs.toLocal().day) { + return true; + } + + if (widget.timeline.events[index].originServerTs + .difference(widget.timeline.events[offsetIndex].originServerTs) + .inHours > + 2) return true; + + return false; + } + + @override + void update(int newIndex) { + index = newIndex; + // setState(() { + loadState(newIndex); + + if (eventKey.currentState is TimelineEventViewWidget) { + (eventKey.currentState as TimelineEventViewWidget).update(newIndex); + } else { + Log.w("Failed to get state from event key"); + } + } + + @override + Widget build(BuildContext context) { + BenchmarkValues.numTimelineEventsBuilt += 1; + + if (status == TimelineEventStatus.removed) return Container(); + + var result = buildEvent(); + + if (Layout.desktop) { + result = MouseRegion( + onEnter: (_) => widget.onEventHovered?.call(eventId), + child: result, + ); + } + + if (Layout.mobile) { + result = InkWell( + onLongPress: () { + var event = widget.timeline.tryGetEvent(eventId); + if (event == null) { + return; + } + + showModalBottomSheet( + showDragHandle: true, + isScrollControlled: true, + elevation: 0, + context: context, + builder: (context) => TimelineEventMenuDialog( + event: event, + timeline: widget.timeline, + menu: TimelineEventMenu( + timeline: widget.timeline, + event: event, + setEditingEvent: widget.setEditingEvent, + setReplyingEvent: widget.setReplyingEvent, + onActionFinished: () => Navigator.of(context).pop(), + ), + )); + }, + child: result, + ); + } + + if (selected) { + result = Container( + color: Theme.of(context).hoverColor, + child: result, + ); + } + + if (timelineLayerLink != null) { + result = Stack( + alignment: Alignment.topRight, + children: [ + CompositedTransformTarget( + link: timelineLayerLink!, child: const SizedBox()), + result ?? Container() + ], + ); + } + + if (showDate) { + result = Column( + children: [ + TimelineEventDateTimeMarker(time: time), + result ?? Container() + ], + ); + } + + return result ?? Container(); + } + + Widget? buildEvent() { + switch (eventType) { + case EventType.message: + case EventType.sticker: + case EventType.encrypted: + return TimelineEventViewMessage( + key: eventKey, + timeline: widget.timeline, + detailed: widget.showDetailed || selected, + overrideShowSender: widget.singleEvent, + initialIndex: widget.initialIndex); + case EventType.roomCreated: + case EventType.memberJoined: + case EventType.memberLeft: + case EventType.memberAvatar: + case EventType.memberDisplayName: + case EventType.memberInvited: + case EventType.memberInvitationRejected: + case EventType.emote: + return TimelineEventViewGeneric( + timeline: widget.timeline, + initialIndex: widget.initialIndex, + key: eventKey, + ); + default: + return preferences.developerMode + ? TimelineEventViewGeneric( + timeline: widget.timeline, + initialIndex: widget.initialIndex, + key: eventKey, + ) + : Container( + key: eventKey, + ); + } + } + + @override + void deselect() { + setState(() { + selected = false; + timelineLayerLink = null; + }); + } + + @override + void select(LayerLink link) { + setState(() { + selected = true; + timelineLayerLink = link; + }); + } +} diff --git a/commet/lib/ui/molecules/timeline_viewer.dart b/commet/lib/ui/molecules/timeline_viewer.dart deleted file mode 100644 index 2b819195..00000000 --- a/commet/lib/ui/molecules/timeline_viewer.dart +++ /dev/null @@ -1,535 +0,0 @@ -import 'dart:async'; -import 'package:commet/config/build_config.dart'; -import 'package:commet/config/layout_config.dart'; -import 'package:commet/main.dart'; -import 'package:commet/ui/molecules/message_popup_menu/message_popup_menu.dart'; -import 'package:commet/ui/molecules/timeline_event.dart'; -import 'package:commet/utils/text_utils.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:tiamat/config/config.dart'; -import '../../client/client.dart'; -import '../../client/components/emoticon/emoticon.dart'; -import 'package:tiamat/tiamat.dart' as tiamat; -/* Note: This aint your mother's timeline viewer... -This file contains some unusual hacks in order to achieve a smoother experience - -Hack #1: Split Timeline -This timeline renders elements as part of two seperate slivers -one for new events, and one for previous events -this allows us to center the scroll view on the 'middle' of the two slivers/ -This prevents the view from jumping around when new items are added / removed from -the top and bottom of the timeline - ---- - -Hack #2: First frame offscreen render -Due to the previous hack, when we initially open the viewer, the recent events are offscreen -because the scrollviewer starts with an offset of 0, and all our recent events are in an offset < 0 -To work around this, on the first frame we render the scroll view offstage, so we can grab the -minScrollExtent of the scroll view - -Then after this frame is rendered, we construct a new scroll controller and set the initial offset -to the minScrollExtent we just grabbed, which places timeline right where we would expect it! - ---- - -I know its weird but its necessary! -*/ - -class TimelineViewer extends StatefulWidget { - const TimelineViewer( - {required this.timeline, - this.markAsRead, - this.setReplyingEvent, - this.onEventDoubleTap, - this.setEditingEvent, - this.onEventLongPress, - this.onAddReaction, - this.onReactionTapped, - this.doMessageOverlayMenu = true, - Key? key}) - : super(key: key); - - final bool doMessageOverlayMenu; - final Timeline timeline; - final Function(TimelineEvent event)? markAsRead; - final Function(TimelineEvent? event)? setReplyingEvent; - final Function(TimelineEvent? event)? setEditingEvent; - final Function(TimelineEvent event)? onEventDoubleTap; - final Function(TimelineEvent event)? onEventLongPress; - final Function(TimelineEvent event, Emoticon emote)? onAddReaction; - final Function(TimelineEvent event, Emoticon emote)? onReactionTapped; - - @override - State createState() => TimelineViewerState(); -} - -class TimelineViewerState extends State - with WidgetsBindingObserver { - Key historyEventsKey = GlobalKey(); - ScrollController controller = ScrollController(initialScrollOffset: -999999); - bool firstFrame = true; - - int recentItemsCount = 0; - int historyItemsCount = 0; - int hoveredIndex = -1; - double height = -1; - bool animatingToBottom = false; - bool get attachedToBottom => controller.hasClients - ? controller.offset - controller.positions.first.minScrollExtent < 50 || - animatingToBottom - : true; - - Future? loadingHistory; - bool toBeDisposed = false; - - bool showJumpToBottom = false; - - StreamController onHoveredMessageChanged = StreamController.broadcast(); - - late StreamSubscription eventAdded; - late StreamSubscription eventChanged; - late StreamSubscription eventRemoved; - final LayerLink messageLayerLink = LayerLink(); - - bool messagePopupIsBeingInteracted = false; - - String get labelJumpToLatest => Intl.message("Jump to latest", - desc: - "Label for the button which jumps the room timeline view to the latest message", - name: "labelJumpToLatest"); - - @override - void initState() { - recentItemsCount = widget.timeline.events.length; - eventAdded = widget.timeline.onEventAdded.stream.listen(onEventAdded); - eventChanged = widget.timeline.onChange.stream.listen(onEventChanged); - eventRemoved = widget.timeline.onRemove.stream.listen(onEventRemoved); - WidgetsBinding.instance.addPostFrameCallback(onAfterFirstFrame); - WidgetsBinding.instance.addObserver(this); - super.initState(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) async { - super.didChangeAppLifecycleState(state); - - if (state == AppLifecycleState.resumed) { - if (attachedToBottom) { - widget.markAsRead?.call(widget.timeline.events.first); - } - } - } - - @override - void dispose() { - eventAdded.cancel(); - eventChanged.cancel(); - eventRemoved.cancel(); - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - Widget buildOverlay() { - if (hoveredIndex == -1) { - return Container(); - } - - var event = widget.timeline.events[hoveredIndex]; - return Positioned( - height: 50, - child: CompositedTransformFollower( - targetAnchor: Alignment.topRight, - followerAnchor: Alignment.topRight, - showWhenUnlinked: false, - offset: const Offset(-20, -48), - link: messageLayerLink, - child: SizedBox(child: buildPopupMenu(event, false)))); - } - - onEventLongPress(TimelineEvent event) { - if (BuildConfig.MOBILE) { - showModalBottomSheet( - showDragHandle: true, - isScrollControlled: true, - elevation: 0, - context: context, - builder: (context) => buildPopupMenu(event, true)); - } - } - - Widget buildPopupMenu(TimelineEvent event, bool asDialog) { - return SingleChildScrollView( - child: MessagePopupMenu( - event, - widget.timeline, - isEditable: canUserEditEvent(event), - asDialog: asDialog, - isDeletable: widget.timeline.canDeleteEvent(event), - setEditingEvent: widget.setEditingEvent, - setReplyingEvent: widget.setReplyingEvent, - canSaveAttachment: event.attachments?.isNotEmpty ?? false, - addReaction: widget.onAddReaction, - onPopupStateChanged: (state) => messagePopupIsBeingInteracted = state, - ), - ); - } - - bool canUserEditEvent(TimelineEvent event) { - if (event.attachments != null) return false; - - return widget.timeline.room.permissions.canUserEditMessages && - event.senderId == widget.timeline.room.client.self!.identifier; - } - - void onAfterFirstFrame(_) { - if (widget.timeline.events.isNotEmpty) { - widget.markAsRead?.call(widget.timeline.events.first); - } - - if (controller.hasClients) { - double extent = controller.position.minScrollExtent; - controller = ScrollController(initialScrollOffset: extent); - controller.addListener(onScroll); - setState(() { - firstFrame = false; - }); - } - } - - void loadMoreHistory() async { - loadingHistory = widget.timeline.loadMoreHistory(); - await loadingHistory; - loadingHistory = null; - } - - bool shouldScrollPositionLoadHistory() { - return controller.offset > controller.position.maxScrollExtent - 500; - } - - void onScroll() { - if (shouldScrollPositionLoadHistory()) { - if (loadingHistory == null) loadMoreHistory(); - } - - setState(() { - showJumpToBottom = !attachedToBottom; - }); - } - - void animateAndSnapToBottom() { - if (toBeDisposed) return; - controller.position.hold(() {}); - - setState(() { - animatingToBottom = true; - showJumpToBottom = false; - }); - int lastEvent = recentItemsCount; - - controller - .animateTo(controller.position.minScrollExtent, - duration: const Duration(milliseconds: 500), - curve: Curves.easeOutExpo) - .then((value) { - if (recentItemsCount == lastEvent) { - controller.jumpTo(controller.position.minScrollExtent); - - setState(() { - animatingToBottom = false; - showJumpToBottom = false; - }); - } - }); - } - - void onEventAdded(int index) { - setState(() {}); - if (index == 0 || index < recentItemsCount) { - recentItemsCount += 1; - } else { - historyItemsCount = widget.timeline.events.length - recentItemsCount; - } - - if (index == 0) { - if (attachedToBottom || animatingToBottom) { - WidgetsBinding.instance.addPostFrameCallback((_) { - animateAndSnapToBottom(); - }); - - widget.markAsRead?.call(widget.timeline.events[0]); - } - } - } - - void onEventChanged(int index) { - setState(() {}); - } - - void onEventRemoved(int index) { - setState(() {}); - } - - void prepareForDisposal() { - toBeDisposed = true; - controller.position.hold(() {}); - } - - @override - Widget build(BuildContext context) { - if (firstFrame) { - return Offstage( - child: buildScrollView(), - ); - } - - return NotificationListener( - onNotification: (SizeChangedLayoutNotification notification) { - var prevHeight = height; - height = MediaQuery.of(context).size.height; - if (prevHeight == -1) return true; - - var diff = prevHeight - height; - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - controller.jumpTo(controller.offset + diff); - }); - - return true; - }, - child: buildScrollView()); - } - - Widget buildScrollView() { - var view = CustomScrollView( - center: historyEventsKey, - reverse: true, - controller: controller, - anchor: 0, - slivers: [ - //Beware, these are in reverse order - SliverList( - delegate: SliverChildBuilderDelegate(buildRecentItem, - childCount: recentItemsCount)), - SliverList( - key: historyEventsKey, - delegate: SliverChildBuilderDelegate(buildHistoryItem, - childCount: historyItemsCount)), - ], - ); - - return ClipRect( - child: Stack( - children: [ - if (Layout.mobile) view, - if (Layout.desktop) SelectionArea(child: view), - if (Layout.desktop) buildOverlay(), - Align( - alignment: Alignment.bottomCenter, - child: AnimatedSlide( - offset: Offset(0, showJumpToBottom ? 0 : 1), - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOutCubic, - child: buildJumpToLatestButton())) - ], - ), - ); - } - - Widget buildJumpToLatestButton() { - var padding = BuildConfig.MOBILE - ? const EdgeInsets.fromLTRB(18, 12, 18, 12) - : const EdgeInsets.fromLTRB(12, 4, 12, 4); - return Padding( - padding: const EdgeInsets.all(8.0), - child: DecoratedBox( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Theme.of(context).shadowColor, - blurRadius: 5, - ) - ], - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Theme.of(context).extension()!.highlight, - width: 1), - color: Theme.of(context).extension()!.surfaceHigh1), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: animateAndSnapToBottom, - child: Padding( - padding: padding, - child: tiamat.Text.labelLow( - labelJumpToLatest, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ), - ), - )); - } - - Widget? buildHistoryItem(BuildContext context, int index) { - var displayIndex = recentItemsCount + index; - return Container( - color: BuildConfig.DEBUG && preferences.developerMode - ? Colors.red.withAlpha(15) - : null, - child: buildEvent(displayIndex, index)); - } - - Widget? buildRecentItem(BuildContext context, int index) { - int displayIndex = recentItemsCount - index - 1; - return Container( - color: BuildConfig.DEBUG && preferences.developerMode - ? Colors.blue.withAlpha(15) - : null, - child: buildEvent(displayIndex, index)); - } - - Widget buildEvent(int displayIndex, int actualIndex) { - var event = Stack(alignment: Alignment.topRight, children: [ - buildTimelineEvent(displayIndex), - if (displayIndex == hoveredIndex) - CompositedTransformTarget( - link: messageLayerLink, - child: const SizedBox( - height: 1, - width: 1, - ), - ) - ]); - - if (shouldShowDate(displayIndex)) { - return Column( - children: [ - dateTimeMarker(displayIndex), - event, - ], - ); - } - - return event; - } - - Widget dateTimeMarker(int actualIndex) { - var color = Theme.of(context).extension()!.surfaceLow2; - return Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Divider( - height: 1, - color: Theme.of(context).extension()!.surfaceLow2, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 0), - child: tiamat.Text.labelLow(TextUtils.timestampToLocalizedTime( - widget.timeline.events[actualIndex].originServerTs, - MediaQuery.of(context).alwaysUse24HourFormat)), - ), - Expanded( - child: Divider( - height: 1, - color: color, - ), - ), - ], - ), - ); - } - - Widget buildTimelineEvent(int index) { - return MouseRegion( - onEnter: (event) { - if (!widget.doMessageOverlayMenu) return; - if (index == hoveredIndex) return; - if (messagePopupIsBeingInteracted) return; - - setState(() { - hoveredIndex = index; - }); - }, - child: Container( - color: hoveredIndex == index - ? Theme.of(context).hoverColor - : Colors.transparent, - child: TimelineEventView( - event: widget.timeline.events[index], - timeline: widget.timeline, - hovered: hoveredIndex == index, - onReactionTapped: (emote) => - widget.onAddReaction?.call(widget.timeline.events[index], emote), - showSender: shouldShowSender(index), - setEditingEvent: () => - widget.setEditingEvent?.call(widget.timeline.events[index]), - setReplyingEvent: () => - widget.setReplyingEvent?.call(widget.timeline.events[index]), - onLongPress: () => onEventLongPress(widget.timeline.events[index]), - useCachedFormat: true, - ), - ), - ); - } - - bool shouldShowDate(int index) { - var offsetIndex = index + 1; - - if (widget.timeline.events.length <= offsetIndex) { - return false; - } - - if ([ - EventType.emote, - EventType.message, - ].contains(widget.timeline.events[index].type) == - false) { - return false; - } - - if (widget.timeline.events[index].originServerTs.toLocal().day != - widget.timeline.events[offsetIndex].originServerTs.toLocal().day) { - return true; - } - - if (widget.timeline.events[index].originServerTs - .difference(widget.timeline.events[offsetIndex].originServerTs) - .inHours > - 2) return true; - - return false; - } - - bool shouldShowSender(int index) { - if (widget.timeline.events.length <= index + 1) { - return true; - } - - if (widget.timeline.events[index].relationshipType == - EventRelationshipType.reply) return true; - - if (![EventType.message, EventType.encrypted] - .contains(widget.timeline.events[index + 1].type)) return true; - - if (widget.timeline.events[index + 1].status == - TimelineEventStatus.removed) { - return true; - } - - if (widget.timeline.events[index].originServerTs - .difference(widget.timeline.events[index + 1].originServerTs) - .inMinutes > - 1) return true; - - return widget.timeline.events[index].senderId != - widget.timeline.events[index + 1].senderId; - } -} diff --git a/commet/lib/ui/molecules/url_preview_widget.dart b/commet/lib/ui/molecules/url_preview_widget.dart index 5d90cef8..5d4138d3 100644 --- a/commet/lib/ui/molecules/url_preview_widget.dart +++ b/commet/lib/ui/molecules/url_preview_widget.dart @@ -31,6 +31,12 @@ class _UrlPreviewWidgetState extends State { super.initState(); } + @override + void didChangeDependencies() { + setState(() {}); + super.didChangeDependencies(); + } + @override Widget build(BuildContext context) { return ClipRRect( diff --git a/commet/lib/ui/organisms/chat/chat_view.dart b/commet/lib/ui/organisms/chat/chat_view.dart index 4d0c1f35..2e40b186 100644 --- a/commet/lib/ui/organisms/chat/chat_view.dart +++ b/commet/lib/ui/organisms/chat/chat_view.dart @@ -2,7 +2,7 @@ import 'package:commet/client/timeline.dart'; import 'package:commet/config/layout_config.dart'; import 'package:commet/ui/molecules/message_input.dart'; import 'package:commet/ui/molecules/read_indicator.dart'; -import 'package:commet/ui/molecules/timeline_viewer.dart'; +import 'package:commet/ui/molecules/room_timeline_widget/room_timeline_widget.dart'; import 'package:commet/ui/organisms/chat/chat.dart'; import 'package:commet/utils/autofill_utils.dart'; import 'package:flutter/material.dart'; @@ -30,7 +30,7 @@ class ChatView extends StatelessWidget { String? get relatedEventSenderName => state.interactingEvent == null ? null : state.room - .getMemberOrFallback(state.interactingEvent!.senderId)! + .getMemberOrFallback(state.interactingEvent!.senderId) .displayName; Color? get relatedEventSenderColor => state.interactingEvent == null @@ -53,14 +53,15 @@ class ChatView extends StatelessWidget { ? const Center( child: CircularProgressIndicator(), ) - : TimelineViewer( + : RoomTimelineWidget( + key: ValueKey("${state.room.identifier}-timeline"), timeline: state.timeline!, - markAsRead: handleMarkAsRead, + // markAsRead: handleMarkAsRead, setReplyingEvent: (event) => state.setInteractingEvent(event, type: EventInteractionType.reply), setEditingEvent: (event) => state.setInteractingEvent(event, type: EventInteractionType.edit), - onAddReaction: state.addReaction, + // onAddReaction: state.addReaction, ); } diff --git a/commet/lib/ui/organisms/home_screen/home_screen_view.dart b/commet/lib/ui/organisms/home_screen/home_screen_view.dart index 5c4ab46e..ecf86b4d 100644 --- a/commet/lib/ui/organisms/home_screen/home_screen_view.dart +++ b/commet/lib/ui/organisms/home_screen/home_screen_view.dart @@ -95,7 +95,7 @@ class HomeScreenView extends StatelessWidget { body: room.lastEvent?.body, recentEventSender: room.lastEvent != null ? room - .getMemberOrFallback(room.lastEvent!.senderId)! + .getMemberOrFallback(room.lastEvent!.senderId) .displayName : null, recentEventSenderColor: room.lastEvent != null @@ -134,7 +134,7 @@ class HomeScreenView extends StatelessWidget { body: room.lastEvent?.body, recentEventSender: room.lastEvent != null ? room - .getMemberOrFallback(room.lastEvent!.senderId)! + .getMemberOrFallback(room.lastEvent!.senderId) .displayName : null, recentEventSenderColor: room.lastEvent != null diff --git a/commet/lib/ui/organisms/space_summary/space_summary_view.dart b/commet/lib/ui/organisms/space_summary/space_summary_view.dart index a96de65a..4ea90e63 100644 --- a/commet/lib/ui/organisms/space_summary/space_summary_view.dart +++ b/commet/lib/ui/organisms/space_summary/space_summary_view.dart @@ -248,7 +248,7 @@ class SpaceSummaryViewState extends State { : null, body: room.lastEvent?.body, recentEventSender: room.lastEvent != null - ? room.getMemberOrFallback(room.lastEvent!.senderId)!.displayName + ? room.getMemberOrFallback(room.lastEvent!.senderId).displayName : null, recentEventSenderColor: room.lastEvent != null ? room.getColorOfUser(room.lastEvent!.senderId) diff --git a/commet/lib/ui/pages/developer/benchmarks/timeline_viewer_benchmark.dart b/commet/lib/ui/pages/developer/benchmarks/timeline_viewer_benchmark.dart index 7e1e9caa..607af6fe 100644 --- a/commet/lib/ui/pages/developer/benchmarks/timeline_viewer_benchmark.dart +++ b/commet/lib/ui/pages/developer/benchmarks/timeline_viewer_benchmark.dart @@ -2,7 +2,7 @@ import 'package:commet/client/client.dart'; import 'package:commet/client/matrix/matrix_client.dart'; import 'package:commet/client/matrix/matrix_profile.dart'; import 'package:commet/diagnostic/mocks/matrix_client_component_mocks.dart'; -import 'package:commet/ui/molecules/timeline_viewer.dart'; +import 'package:commet/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart'; import 'package:commet/ui/pages/developer/benchmarks/benchmark_utils.dart'; import 'package:flutter/material.dart'; @@ -38,10 +38,10 @@ class _BenchmarkTimelineViewerState extends State { onPressed: () => Navigator.of(context).pop(), child: const Icon(Icons.chevron_left), ), - body: TimelineViewer( + body: RoomTimelineWidgetView( key: const ValueKey("timeline-viewer-benchmark"), timeline: timeline, - doMessageOverlayMenu: false, + // doMessageOverlayMenu: false, ), ); } diff --git a/commet/lib/ui/pages/main/main_page.dart b/commet/lib/ui/pages/main/main_page.dart index 1a5b7c12..04fae154 100644 --- a/commet/lib/ui/pages/main/main_page.dart +++ b/commet/lib/ui/pages/main/main_page.dart @@ -3,7 +3,6 @@ import 'package:commet/client/client.dart'; import 'package:commet/client/client_manager.dart'; import 'package:commet/client/profile.dart'; import 'package:commet/config/layout_config.dart'; -import 'package:commet/main.dart'; import 'package:commet/ui/pages/setup/setup_page.dart'; import 'package:commet/utils/event_bus.dart'; import 'package:commet/ui/navigation/navigation_utils.dart'; @@ -69,9 +68,10 @@ class MainPageState extends State { selectRoom(room); } } - backgroundTaskManager.onListUpdate.listen((event) { - setState(() {}); - }); + + // backgroundTaskManager.onListUpdate.listen((event) { + // setState(() {}); + // }); EventBus.openRoom.stream.listen(onOpenRoomSignal); SchedulerBinding.instance.scheduleFrameCallback(onFirstFrame); @@ -119,7 +119,6 @@ class MainPageState extends State { clearRoomSelection(); onSpaceUpdateSubscription?.cancel(); - onSpaceUpdateSubscription = space?.onUpdate.listen(onSpaceUpdated); setState(() { _previousSpace = _currentSpace; _currentSpace = space; @@ -131,7 +130,6 @@ class MainPageState extends State { if (room == currentRoom) return; onRoomUpdateSubscription?.cancel(); - onRoomUpdateSubscription = room.onUpdate.listen(onRoomUpdated); setState(() { _previousRoom = currentRoom; @@ -173,14 +171,6 @@ class MainPageState extends State { }); } - void onSpaceUpdated(void _) { - setState(() {}); - } - - void onRoomUpdated(void _) { - setState(() {}); - } - void onOpenRoomSignal((String, String?) strings) { var roomId = strings.$1; var clientId = strings.$2; diff --git a/commet/lib/utils/mime.dart b/commet/lib/utils/mime.dart index 7f95fb5c..dcbd36a3 100644 --- a/commet/lib/utils/mime.dart +++ b/commet/lib/utils/mime.dart @@ -54,6 +54,7 @@ class Mime { static String? lookupType(String filepath, {Uint8List? data}) { var resolver = mime.MimeTypeResolver(); resolver.addMagicNumber([0x42, 0x4d], "image/bmp"); + resolver.addMagicNumber([0x3c, 0x73, 0x76, 0x67], "image/svg+xml"); // '