From 1834ae2c5b0ddbd126361e41f6543efb64c8627f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:35:31 -0400 Subject: [PATCH 01/10] fix timeline sorting --- lib/controllers/room_chat_controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 1f0fe2c..2b6810b 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -43,7 +43,7 @@ class RoomChatController extends AsyncNotifier> { } return room.timeline - .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .toEntryIList(compare: (a, b) => (b?.key ?? 0).compareTo(a?.key ?? 0)) .map((entry) { if (entry.value == null) return null; From fd5eaa27258ce4edeb40b92ad048798d1113bc0b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:40:26 -0400 Subject: [PATCH 02/10] fix audio player size --- lib/widgets/players/audio.dart | 65 ++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index a851035..f75afe5 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -61,39 +61,42 @@ class AudioPlayer extends HookConsumerWidget { return "$minutes:$seconds"; } - return Card( - color: Theme.of(context).colorScheme.surfaceContainer, - child: Padding( - padding: EdgeInsetsGeometry.only(left: 8, right: 16), - child: Row( - children: [ - IconButton( - onPressed: player.playOrPause, - icon: Icon( - playing.value ? Icons.pause_circle : Icons.play_circle, + return SizedBox( + height: 60, + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: EdgeInsetsGeometry.only(left: 8, right: 16), + child: Row( + children: [ + IconButton( + onPressed: player.playOrPause, + icon: Icon( + playing.value ? Icons.pause_circle : Icons.play_circle, + ), ), - ), - SizedBox(width: 8), - Text( - format(position.value), - style: Theme.of(context).textTheme.bodySmall, - ), - Expanded( - child: Slider( - min: 0, - max: duration.value.inMilliseconds <= 0 - ? 1 - : duration.value.inMilliseconds.toDouble(), - value: position.value.inMilliseconds.toDouble(), - onChanged: (value) => - player.seek(Duration(milliseconds: value.toInt())), + SizedBox(width: 8), + Text( + format(position.value), + style: Theme.of(context).textTheme.bodySmall, ), - ), - Text( - format(duration.value), - style: Theme.of(context).textTheme.bodySmall, - ), - ], + Expanded( + child: Slider( + min: 0, + max: duration.value.inMilliseconds <= 0 + ? 1 + : duration.value.inMilliseconds.toDouble(), + value: position.value.inMilliseconds.toDouble(), + onChanged: (value) => + player.seek(Duration(milliseconds: value.toInt())), + ), + ), + Text( + format(duration.value), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ), ), ); From 7850117cb6113e4ec9671db3aed258c14106eb1f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:49:01 -0400 Subject: [PATCH 03/10] abstract ColorHash into its own extension --- lib/helpers/extensions/string_to_color.dart | 6 ++++++ lib/widgets/lazy_loading/message_displayname.dart | 8 ++------ lib/widgets/member_list.dart | 8 ++------ lib/widgets/renderers/membership.dart | 8 ++------ 4 files changed, 12 insertions(+), 18 deletions(-) create mode 100644 lib/helpers/extensions/string_to_color.dart diff --git a/lib/helpers/extensions/string_to_color.dart b/lib/helpers/extensions/string_to_color.dart new file mode 100644 index 0000000..8d30e76 --- /dev/null +++ b/lib/helpers/extensions/string_to_color.dart @@ -0,0 +1,6 @@ +import "package:color_hash/color_hash.dart"; +import "package:flutter/material.dart"; + +extension ToColor on String { + Color get colorHash => ColorHash(this, lightness: .7, saturation: .7).color; +} diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index e388fe7..b1c1460 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -1,10 +1,10 @@ -import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/event.dart"; class MessageDisplayname extends ConsumerWidget { @@ -35,11 +35,7 @@ class MessageDisplayname extends ConsumerWidget { style: style ?? TextStyle( - color: ColorHash( - event.sender, - lightness: .7, - saturation: .7, - ).color, + color: event.sender.colorHash, fontWeight: FontWeight.bold, ), maxLines: 1, diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index d943f8a..e5d41d7 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -1,10 +1,10 @@ -import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/members_by_status_controller.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/configs/members_by_status_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; @@ -94,11 +94,7 @@ class MemberList extends HookConsumerWidget { displayName ?? member.stateKey!.localpart, overflow: TextOverflow.ellipsis, style: TextStyle( - color: ColorHash( - member.stateKey!, - lightness: .7, - saturation: .8, - ).color, + color: member.stateKey!.colorHash, fontWeight: FontWeight.bold, ), ), diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index aa0b5d1..8e9e22e 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,7 +1,7 @@ -import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/membership_status.dart"; @@ -41,11 +41,7 @@ class MembershipRenderer extends StatelessWidget { content.displayName ?? event.stateKey!.localpart, maxLines: 1, style: TextStyle( - color: ColorHash( - event.sender, - lightness: .7, - saturation: .7, - ).color, + color: event.sender.colorHash, fontWeight: FontWeight.bold, ), ), From 49d480d1e6ef7f5ebe24bee54f568d559fb842cb Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 13:11:04 -0400 Subject: [PATCH 04/10] remove extra backslash that was breaking link regex --- lib/widgets/renderers/event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 0a9cf73..d2dea9a 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -214,7 +214,7 @@ class EventRenderer extends ConsumerWidget { textStyle: textStyle, formattedBody!.replaceAllMapped( RegExp( - r"(]*>.*?<\/a>)|(\\bhttps?:\/\/[^\s<]+)", + r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", caseSensitive: false, dotAll: true, ), From e4f091cb0f482b1a9b10f5d1add37e0e5da1a1e9 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 14:07:21 -0400 Subject: [PATCH 05/10] Add GenericEventRenderer --- lib/widgets/renderers/event.dart | 20 +++---- lib/widgets/renderers/generic_event.dart | 22 ++++++++ lib/widgets/renderers/membership.dart | 71 ++++++++++-------------- 3 files changed, 60 insertions(+), 53 deletions(-) create mode 100644 lib/widgets/renderers/generic_event.dart diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index d2dea9a..855168e 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -29,6 +29,7 @@ import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; import "package:nexus/widgets/renderers/membership.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/file_card.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; @@ -396,17 +397,14 @@ class EventRenderer extends ConsumerWidget { content.status ? null : MembershipRenderer(event), - AvatarContent() => Row( - spacing: 4, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: Icon(Icons.numbers), - ), - Flexible(child: MessageDisplayname(event)), - Expanded(child: Text("changed the room avatar")), - ], - ), + AvatarContent() => GenericEventRenderer(Icons.numbers, [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.numbers), + ), + Flexible(child: MessageDisplayname(event)), + Expanded(child: Text("changed the room avatar")), + ]), _ => null, }; diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart new file mode 100644 index 0000000..0046e33 --- /dev/null +++ b/lib/widgets/renderers/generic_event.dart @@ -0,0 +1,22 @@ +import "package:flutter/material.dart"; + +class GenericEventRenderer extends StatelessWidget { + final IconData icon; + final List children; + const GenericEventRenderer(this.icon, this.children, {super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only(bottom: 8), + child: Row( + spacing: 8, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.people), + ), + Expanded(child: Wrap(spacing: 4, children: children)), + ], + ), + ); +} diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index 8e9e22e..9012ba2 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -6,6 +6,7 @@ import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; class MembershipRenderer extends StatelessWidget { final Event event; @@ -19,51 +20,37 @@ class MembershipRenderer extends StatelessWidget { ); return switch (event.content) { - MembershipContent content => Row( - spacing: 8, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: Icon(Icons.people), + MembershipContent content => GenericEventRenderer(Icons.people, [ + InkWell( + onTapUp: (details) => context.showUserPopover( + content, + event.stateKey!, + globalPosition: details.globalPosition, ), - Expanded( - child: Wrap( - spacing: 4, - children: [ - InkWell( - onTapUp: (details) => context.showUserPopover( - content, - event.stateKey!, - globalPosition: details.globalPosition, - ), - child: Text( - overflow: TextOverflow.ellipsis, - content.displayName ?? event.stateKey!.localpart, - maxLines: 1, - style: TextStyle( - color: event.sender.colorHash, - fontWeight: FontWeight.bold, - ), - ), - ), - Text( - overflow: TextOverflow.ellipsis, - maxLines: 1, - "${switch (content.status) { - MembershipStatus.invite => "was invited to", - MembershipStatus.join => "joined", - MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - MembershipStatus.ban => "was banned from", - MembershipStatus.knock => "asked to join", - }} the room${event.sender == event.stateKey ? "" : " by "}", - ), - if (event.sender != event.stateKey) MessageDisplayname(event), - if (content.reason != null) Text("for \"${content.reason}\""), - ], + child: Text( + overflow: TextOverflow.ellipsis, + content.displayName ?? event.stateKey!.localpart, + maxLines: 1, + style: TextStyle( + color: event.sender.colorHash, + fontWeight: FontWeight.bold, ), ), - ], - ), + ), + Text( + overflow: TextOverflow.ellipsis, + maxLines: 1, + "${switch (content.status) { + MembershipStatus.invite => "was invited to", + MembershipStatus.join => "joined", + MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + MembershipStatus.ban => "was banned from", + MembershipStatus.knock => "asked to join", + }} the room${event.sender == event.stateKey ? "" : " by "}", + ), + if (event.sender != event.stateKey) MessageDisplayname(event), + if (content.reason != null) Text("for \"${content.reason}\""), + ]), _ => SizedBox.shrink(), }; } From 7016cc4205797166e9cfd6f164becd2c09cadb07 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 14:17:01 -0400 Subject: [PATCH 06/10] change wording on verify page message -> event --- lib/pages/verify_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/verify_page.dart b/lib/pages/verify_page.dart index 962701c..387c640 100644 --- a/lib/pages/verify_page.dart +++ b/lib/pages/verify_page.dart @@ -21,7 +21,7 @@ class VerifyPage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.", + "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", ), SizedBox(height: 12), FormTextInput( From a28592d11e74d9841cb72330ab43c6f359844782 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 14:19:51 -0400 Subject: [PATCH 07/10] change algorithm for deciding when to load more messages --- lib/controllers/room_chat_controller.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 2b6810b..a750e53 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -6,6 +6,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_related_events_request.dart"; @@ -37,8 +38,14 @@ class RoomChatController extends AsyncNotifier> { await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - // While there are under 30 messages, try up to load more messages until there's no more or we have 20 messages. - if (room.hasMore && room.timeline.length < 30) { + // While there are under 5 messages or under 20 events, try to load + // more messages until there's no more or the conditions are met. + if (room.hasMore && + (room.events.values + .where((event) => event.content is MessageContent) + .length < + 5 || + room.timeline.length < 20)) { loadOlder(); } From 2451555479123d0ed7017ff7c3792256e291a4c8 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 16:17:21 -0400 Subject: [PATCH 08/10] add reaction support --- lib/controllers/reactions_controller.dart | 56 ++++++ lib/main.dart | 4 +- lib/models/configs/reactions_config.dart | 14 ++ lib/widgets/event_wrapper.dart | 45 ----- lib/widgets/flash_wrapper.dart | 20 ++ lib/widgets/reaction_row.dart | 189 +++++++++--------- lib/widgets/renderers/event.dart | 59 ++++-- lib/widgets/room_chat.dart | 5 +- .../{link_preview.dart => url_preview.dart} | 4 +- 9 files changed, 233 insertions(+), 163 deletions(-) create mode 100644 lib/controllers/reactions_controller.dart create mode 100644 lib/models/configs/reactions_config.dart delete mode 100644 lib/widgets/event_wrapper.dart create mode 100644 lib/widgets/flash_wrapper.dart rename lib/widgets/{link_preview.dart => url_preview.dart} (96%) diff --git a/lib/controllers/reactions_controller.dart b/lib/controllers/reactions_controller.dart new file mode 100644 index 0000000..8c199a9 --- /dev/null +++ b/lib/controllers/reactions_controller.dart @@ -0,0 +1,56 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/models/configs/reactions_config.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/requests/get_related_events_request.dart"; + +class ReactionsController extends AsyncNotifier>> { + final ReactionsConfig config; + ReactionsController(this.config); + + @override + Future>> build() async { + final eventInfo = ref.watch( + RoomsController.provider.select((value) { + final event = value[config.roomId]?.events[config.eventRowId]; + return event == null ? null : (event.eventId, event.reactions); + }), + ); + + final reactionEvents = eventInfo?.$2.isNotEmpty == true + ? await ref + .watch(ClientController.provider.notifier) + .getRelatedEvents( + GetRelatedEventsRequest( + roomId: config.roomId, + eventId: eventInfo!.$1, + relationType: "m.annotation", + ), + ) + : null; + + return reactionEvents + ?.where((event) => event.redactedBy == null) + .fold>>(IMap(), (acc, event) { + if (event.content case ReactionContent(:final key?)) { + return acc.update( + key, + (list) => list.add(event.sender), + ifAbsent: () => IList([event.sender]), + ); + } + + return acc; + }) ?? + const IMap.empty(); + } + + static final provider = + AsyncNotifierProvider.family< + ReactionsController, + IMap>, + ReactionsConfig + >(ReactionsController.new); +} diff --git a/lib/main.dart b/lib/main.dart index 834aeef..b687ebd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -67,8 +67,8 @@ void main() async { await windowManager.setMinimumSize(Size.square(500)); } - // FlutterError.onError = (FlutterErrorDetails details) => - // showError(details.exception.toString(), details.stack); + FlutterError.onError = (FlutterErrorDetails details) => + showError(details.exception.toString(), details.stack); runApp( ProviderScope( diff --git a/lib/models/configs/reactions_config.dart b/lib/models/configs/reactions_config.dart new file mode 100644 index 0000000..5cae859 --- /dev/null +++ b/lib/models/configs/reactions_config.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "reactions_config.freezed.dart"; +part "reactions_config.g.dart"; + +@freezed +abstract class ReactionsConfig with _$ReactionsConfig { + const factory ReactionsConfig({ + required String roomId, + required int eventRowId, + }) = _ReactionsConfig; + + factory ReactionsConfig.fromJson(Map json) => + _$ReactionsConfigFromJson(json); +} diff --git a/lib/widgets/event_wrapper.dart b/lib/widgets/event_wrapper.dart deleted file mode 100644 index ba26e6a..0000000 --- a/lib/widgets/event_wrapper.dart +++ /dev/null @@ -1,45 +0,0 @@ -import "package:flutter/material.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/widgets/reaction_row.dart"; - -class EventWrapper extends StatelessWidget { - final Event event; - final Widget child; - final bool isFlashing; - const EventWrapper( - this.event, - this.child, { - this.isFlashing = false, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: AnimatedContainer( - padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0), - color: isFlashing - ? Theme.of(context).colorScheme.onSurface.withAlpha(50) - : Colors.transparent, - duration: Duration(milliseconds: 250), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - child, - if (event.sendError != null && event.sendError != "not sent") - Text( - event.sendError!, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ReactionRow(event), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/flash_wrapper.dart b/lib/widgets/flash_wrapper.dart new file mode 100644 index 0000000..f52ea25 --- /dev/null +++ b/lib/widgets/flash_wrapper.dart @@ -0,0 +1,20 @@ +import "package:flutter/material.dart"; + +class FlashWrapper extends StatelessWidget { + final Widget child; + final bool isFlashing; + const FlashWrapper(this.child, {this.isFlashing = false, super.key}); + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: AnimatedContainer( + padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0), + color: isFlashing + ? Theme.of(context).colorScheme.onSurface.withAlpha(50) + : Colors.transparent, + duration: Duration(milliseconds: 250), + child: child, + ), + ); +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index 30ebf1c..4935ed7 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -4,11 +4,15 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/reactions_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/main.dart"; +import "package:nexus/models/configs/reactions_config.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/main.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; class ReactionRow extends ConsumerWidget { final Event event; @@ -18,100 +22,99 @@ class ReactionRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final clientState = ref.watch(ClientStateController.provider); - return SizedBox.shrink(); + return switch (ref.watch( + ReactionsController.provider( + ReactionsConfig(roomId: event.roomId, eventRowId: event.rowId), + ), + )) { + AsyncData(value: final IMap>? reactors) || + AsyncLoading(value: final reactors) => Wrap( + spacing: 4, + runSpacing: 4, + children: event.reactions + .where((_, value) => value != 0) + .mapTo( + (reaction, count) => HookBuilder( + builder: (context) { + final enabled = useState(true); - // TODO: IMPL - // return Wrap( - // spacing: 4, - // runSpacing: 4, - // children: clientState?.homeserverUrl == null - // ? [] - // : event.reactions - // .mapTo( - // (reaction, reactors) => HookBuilder( - // builder: (context) { - // final enabled = useState(true); - // final selected = reactors.contains(clientState!.userId); - // return Tooltip( - // message: reactors.join(", "), - // child: ChoiceChip( - // showCheckmark: false, - // selected: selected, - // label: Row( - // mainAxisSize: MainAxisSize.min, - // spacing: 8, - // children: [ - // Flexible( - // child: reaction.startsWith("mxc://") - // ? Image( - // height: 20, - // image: CachedNetworkImage( - // headers: ref.headers, - // Uri.parse(reaction) - // .mxcToHttps( - // clientState.homeserverUrl!, - // ) - // .toString(), - // ref.watch( - // CrossCacheController.provider, - // ), - // ), - // ) - // : Text( - // reaction, - // overflow: TextOverflow.ellipsis, - // ), - // ), - // Text( - // reactors.length.toString(), - // overflow: TextOverflow.ellipsis, - // ), - // ], - // ), - // onSelected: enabled.value - // ? (value) async { - // enabled.value = false; - // try { - // final roomId = ref.watch( - // SelectedRoomController.provider.select( - // (value) => value?.metadata?.id, - // ), - // ); - // if (roomId == null || - // clientState.userId == null) { - // return; - // } + final selected = + reactors?[reaction]?.contains(clientState!.userId) ?? + false; + return Tooltip( + message: reactors?[reaction]?.join(", ") ?? "", + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Flexible( + child: reaction.startsWith("mxc://") + ? Image( + height: 20, + image: CachedNetworkImage( + headers: ref.headers, + Uri.parse(reaction) + .mxcToHttps( + clientState!.homeserverUrl!, + ) + .toString(), + ref.watch(CrossCacheController.provider), + ), + ) + : Text( + reaction, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + count.toString(), + overflow: TextOverflow.ellipsis, + ), + ], + ), + onSelected: enabled.value + ? (value) async { + enabled.value = false; + try { + final controller = ref.watch( + RoomChatController.provider( + event.roomId, + ).notifier, + ); - // final controller = ref.watch( - // RoomChatController.provider( - // roomId, - // ).notifier, - // ); + if (selected) { + await controller + .removeReaction( + reaction, + event, + clientState!.userId!, + ) + .onError(showError); + } else { + await controller + .sendReaction(reaction, event) + .onError(showError); + } + } finally { + enabled.value = true; + } + } + : null, + ), + ); + }, + ), + ) + .toList(), + ), - // if (selected) { - // await controller - // .removeReaction( - // reaction, - // event, - // clientState.userId!, - // ) - // .onError(showError); - // } else { - // await controller - // .sendReaction(reaction, event) - // .onError(showError); - // } - // } finally { - // enabled.value = true; - // } - // } - // : null, - // ), - // ); - // }, - // ), - // ) - // .toList(), - // ); + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }; } } diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 855168e..611f7ee 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -24,10 +24,11 @@ import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/html/html.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; -import "package:nexus/widgets/link_preview.dart"; +import "package:nexus/widgets/url_preview.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; +import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/file_card.dart"; @@ -356,16 +357,21 @@ class EventRenderer extends ConsumerWidget { style: errorStyle, ), }, + if (event.lastEditRowId != 0) Text( "(edited)", style: theme.textTheme.labelSmall, ), + if (linkify(body).firstWhereOrNull( (element) => element is UrlElement, ) case final UrlElement link?) - LinkPreview(link.url), + UrlPreview(link.url), + + SizedBox(height: 4), + ReactionRow(event), ], ], ), @@ -408,21 +414,38 @@ class EventRenderer extends ConsumerWidget { _ => null, }; - return child == null - ? textOnly - ? Text("Unknown event type", style: errorStyle) - : SizedBox.shrink() - : (textOnly - ? child - : GestureDetector( - onSecondaryTapUp: contextMenuCallback, - onLongPressStart: contextMenuCallback, - child: Padding( - padding: isGrouped - ? EdgeInsets.zero - : EdgeInsets.only(top: 8), - child: child, - ), - )); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (child != null) ...[ + if (textOnly) + child + else + GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: Padding( + padding: isGrouped ? EdgeInsets.zero : EdgeInsets.only(top: 8), + child: child, + ), + ), + + if (event.content is! MessageContent) + Padding( + padding: EdgeInsetsGeometry.only(left: 12), + child: ReactionRow(event), + ), + + if (event.sendError != null && event.sendError != "not sent") + Text( + event.sendError!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ] else if (textOnly) + Text("Unknown event type", style: errorStyle), + ], + ); } } diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 1bcb1ac..1bff9fa 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -21,7 +21,7 @@ import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; -import "package:nexus/widgets/event_wrapper.dart"; +import "package:nexus/widgets/flash_wrapper.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/main.dart"; @@ -352,8 +352,7 @@ class RoomChat extends HookConsumerWidget { itemBuilder: (_, index) { final event = value[index]; final previousEvent = value.getOrNull(index + 1); - return EventWrapper( - event, + return FlashWrapper( EventRenderer( event, onTapReply: () async { diff --git a/lib/widgets/link_preview.dart b/lib/widgets/url_preview.dart similarity index 96% rename from lib/widgets/link_preview.dart rename to lib/widgets/url_preview.dart index e20e955..83c6604 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/url_preview.dart @@ -7,9 +7,9 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/launch_helper.dart"; -class LinkPreview extends ConsumerWidget { +class UrlPreview extends ConsumerWidget { final String link; - const LinkPreview(this.link, {super.key}); + const UrlPreview(this.link, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox( From 228ff1051f54ab34793ee2765fbf1497bee8066f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 16:28:47 -0400 Subject: [PATCH 09/10] Add load more button --- lib/controllers/room_chat_controller.dart | 12 +++--------- lib/widgets/room_chat.dart | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index a750e53..5a6741e 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -6,7 +6,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_related_events_request.dart"; @@ -38,14 +37,9 @@ class RoomChatController extends AsyncNotifier> { await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - // While there are under 5 messages or under 20 events, try to load - // more messages until there's no more or the conditions are met. - if (room.hasMore && - (room.events.values - .where((event) => event.content is MessageContent) - .length < - 5 || - room.timeline.length < 20)) { + // While there are under 20 events, try to load more + // until there's no more or the conditions are met. + if (room.hasMore && room.timeline.length < 20) { loadOlder(); } diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 1bff9fa..249f2d2 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -319,6 +319,8 @@ class RoomChat extends HookConsumerWidget { ].toIList(); } + final controllerData = ref.watch(controllerProvider); + return Scaffold( appBar: RoomAppbar( roomId: roomId, @@ -337,7 +339,7 @@ class RoomChat extends HookConsumerWidget { Positioned.fill( child: Padding( padding: EdgeInsets.symmetric(horizontal: 12), - child: switch (ref.watch(controllerProvider)) { + child: switch (controllerData) { AsyncData(:final value) || AsyncLoading(:final value?) => CustomScrollView( reverse: true, @@ -346,6 +348,7 @@ class RoomChat extends HookConsumerWidget { SliverPadding( padding: EdgeInsetsGeometry.only(bottom: 64), ), + SuperSliverList.builder( listController: listController.value, itemCount: value.length, @@ -391,6 +394,20 @@ class RoomChat extends HookConsumerWidget { ); }, ), + + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(bottom: 36), + child: Center( + child: controllerData is AsyncLoading + ? Loading() + : ElevatedButton( + onPressed: notifier.loadOlder, + child: Text("Load More"), + ), + ), + ), + ), ], ), AsyncLoading() => Loading(), From a08c200d659f17546e190ee81eda15ee49f3072f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 23 May 2026 12:05:43 -0400 Subject: [PATCH 10/10] Add a code style guide to DEVELOPMENT.md --- DEVELOPMENT.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5e527e3..c770a29 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,3 +1,5 @@ +# Development Documentation + ## Build instructions CBuild instructions can be found in [README.md](./README.md#build-it-yourself). @@ -9,3 +11,54 @@ You can run the following command to update the Gomuks submodule: ```sh git submodule update --remote ``` + +## Code Style + +### Controllers and Helpers ([Riverpod](https://pub.dev/packages/riverpod)) + +Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier: + +```dart +ref.watch(MyController.provider.notifier).helperMethod() +``` + +We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g. + +```dart +class MyController extends AsyncNotifier { + final SomeInputType input; + MyController(this.input); + + @override + Future build() async { + return input.foo; + } + + static final provider = + AsyncNotifierProvider.family( + AuthorController.new, + ); +} +``` + +Providers which are not controllers, e.g. they expose no data, only methods, should instead live in `lib/helpers/`. For an example, see `lib/helpers/launch_helper.dart`. Other, non-provider helpers, like extensions or helper methods can also go in `lib/helpers/`. + +### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks)) + +This project uses Flutter Hooks to help with boilerplate that StatefulWidgets create. Instead of using a StatefulWidget, we just use hooks like `useState` or `useEffect` in the build method of a `HookWidget`, which is a drop in replacement for `StatelessWidget`. If you need both a `WidgetRef` to watch providers, and access to hooks, use `HookConsumerWidget`. + +### Models ([Freezed](https://pub.dev/packages/freezed)) + +We use Freezed for our models to avoid boilerplate and enforce an immutable style of state and data modeling throughout the code. See their documentation for more info, or see our existing models in `lib/models/`. + +### Immutable Data Collections ([Fast Immutable Collections](https://pub.dev/packages/fast_immutable_collections)) + +When possible, use immutable collections instead of the mutable equivalent. For example, use `IMap` over `Map`, `IList` over `List`, `ISet` over `Set`. This matches the immutable style of Riverpod and Freezed. + +### Don't create globals + +When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method. + +## Code of Conduct + +All contributions must follow the [Federated Nexus Code of Conduct](https://federated.nexus/code/).