Remove flutter chat #26

Manually merged
Henry-Hiles merged 108 commits from remove-flutter-chat into main 2026-05-22 15:26:28 -04:00
9 changed files with 233 additions and 163 deletions
Showing only changes of commit 2451555479 - Show all commits

add reaction support

Henry Hiles 2026-05-21 16:17:21 -04:00
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs

View file

@ -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<IMap<String, IList<String>>> {
final ReactionsConfig config;
ReactionsController(this.config);
@override
Future<IMap<String, IList<String>>> 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<String, IList<String>>>(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<String, IList<String>>,
ReactionsConfig
>(ReactionsController.new);
}

View file

@ -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(

View file

@ -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<String, Object?> json) =>
_$ReactionsConfigFromJson(json);
}

View file

@ -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),
],
),
),
);
}
}

View file

@ -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,
),
);
}

View file

@ -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<String, IList<String>>? 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,
),
};
}
}

View file

@ -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(
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),
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),
],
);
}
}

View file

@ -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 {

View file

@ -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(