Remove flutter chat (#26)

Had to squash merge manually as Forgejo was erroring
This commit is contained in:
Henry Hiles 2026-05-21 16:58:22 -04:00
commit 16cf126df4
38 changed files with 3162 additions and 2366 deletions

View file

@ -2,8 +2,10 @@ import "package:color_hash/color_hash.dart";
import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.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/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
class AvatarOrHash extends ConsumerWidget {
final Uri? avatar;
@ -28,6 +30,14 @@ class AvatarOrHash extends ConsumerWidget {
color: ColorHash(title).color,
child: Center(child: Text(title.isEmpty ? "" : title[0])),
);
final parsedAvatar = avatar?.mxcToHttps(
ref.watch(
ClientStateController.provider.select(
(value) => value?.homeserverUrl,
),
) ??
"",
);
return SizedBox(
width: height,
height: height,
@ -42,11 +52,11 @@ class AvatarOrHash extends ConsumerWidget {
child: SizedBox(
width: height,
height: height,
child: avatar == null
child: parsedAvatar == null
? fallback ?? box
: Image(
image: CachedNetworkImage(
avatar.toString(),
parsedAvatar.toString(),
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),

View file

@ -1,35 +0,0 @@
import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flyer_chat_image_message/flyer_chat_image_message.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/widgets/chat_page/expandable_image.dart";
class ExpandableImageMessage extends ConsumerWidget {
final ImageMessage message;
final int index;
const ExpandableImageMessage(this.message, {required this.index, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ExpandableImage(
message.source,
child: FlyerChatImageMessage(
customImageProvider: CachedNetworkImage(
message.source,
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),
errorBuilder: (context, error, stackTrace) => Center(
child: Text(
"Image Failed to Load",
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
message: message,
index: index,
),
);
}

View file

@ -1,44 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/user_controller.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
class MentionChip extends ConsumerWidget {
final String content;
const MentionChip(this.content, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final membership = content.mention!.startsWith("@") == true
? ref
.watch(UserController.provider(content.mention!))
.whenOrNull(data: (data) => data)
: null;
return InkWell(
onTapUp: (details) {
content.mention;
if (membership != null) {
context.showUserPopover(
membership,
globalPosition: details.globalPosition,
);
}
},
child: IgnorePointer(
child: Chip(
label: Text(
(membership == null ? null : "@${membership.displayName}") ??
content.mention!,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimary,
),
),
backgroundColor: Theme.of(context).colorScheme.primary,
),
),
);
}
}

View file

@ -1,94 +0,0 @@
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_type_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/membership_status.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MemberList extends HookConsumerWidget {
const MemberList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = useState(MembershipStatus.join);
final membersProvider = ref.watch(
MembersByTypeController.provider(status.value),
);
return Drawer(
shape: Border(),
child: Column(
spacing: 8,
children: [
AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.people),
title: Text("Members"),
actionsPadding: EdgeInsets.only(right: 4),
actions: [
if (Scaffold.of(context).hasEndDrawer)
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
tooltip: "Close member list",
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
FilterChip(
label: Text("Joined"),
onSelected: (value) => status.value = MembershipStatus.join,
selected: status.value == MembershipStatus.join,
),
FilterChip(
label: Text("Invited"),
onSelected: (value) => status.value = MembershipStatus.invite,
selected: status.value == MembershipStatus.invite,
),
FilterChip(
label: Text("Banned"),
onSelected: (value) => status.value = MembershipStatus.ban,
selected: status.value == MembershipStatus.ban,
),
],
),
membersProvider.betterWhen(
data: (members) => Expanded(
child: ListView(
children: members
.map(
(member) => InkWell(
onTapUp: (details) => context.showUserPopover(
member,
globalPosition: details.globalPosition,
),
child: ListTile(
leading: AvatarOrHash(
member.avatarUrl,
member.displayName,
),
title: Text(
member.displayName,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
member.userId,
overflow: TextOverflow.ellipsis,
),
),
),
)
.toList(),
),
),
),
],
),
);
}
}

View file

@ -1,101 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/event_controller.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/widgets/chat_page/html/quoted.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
typedef OnTapReply = void Function(Message message)?;
class ReplyWidget extends ConsumerWidget {
final Message message;
final bool alwaysShow;
final MessageGroupStatus? groupStatus;
final OnTapReply onTapReply;
const ReplyWidget(
this.message, {
required this.groupStatus,
this.onTapReply,
this.alwaysShow = false,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final room = ref.watch(SelectedRoomController.provider);
return message.replyToMessageId == null || room == null
? SizedBox.shrink()
: Padding(
padding: EdgeInsets.only(bottom: 12),
child: Quoted(
ref
.watch(
EventController.provider(
GetEventRequest(
room: room,
eventId: message.replyToMessageId!,
),
),
)
.betterWhen(
loading: () => Text("Fetching event..."),
data: (event) => event == null
? SizedBox.shrink()
: ref
.watch(
MessageController.provider(
MessageConfig(room: room, event: event),
),
)
.betterWhen(
loading: () => Text("Parsing message..."),
data: (replyMessage) {
if (replyMessage == null) {
return SizedBox.shrink();
}
return InkWell(
onTap: () => onTapReply?.call(replyMessage),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
MessageAvatar(replyMessage),
Flexible(
child: MessageDisplayname(
replyMessage,
clickable: false,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Flexible(
child: Text(
replyMessage.metadata!["body"],
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelMedium,
maxLines: 1,
),
),
],
),
);
},
),
),
),
);
}
}

View file

@ -1,492 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flyer_chat_file_message/flyer_chat_file_message.dart";
import "package:flyer_chat_system_message/flyer_chat_system_message.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/account_data_controller.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/power_level_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/controllers/via_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/configs/power_level_config.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/report_request.dart";
import "package:nexus/widgets/chat_page/composer/chat_box.dart";
import "package:nexus/widgets/chat_page/emoji_picker_button.dart";
import "package:nexus/widgets/chat_page/expandable_image_message.dart";
import "package:nexus/widgets/chat_page/member_list.dart";
import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
import "package:nexus/widgets/chat_page/room_appbar.dart";
import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart";
import "package:nexus/widgets/chat_page/reply_widget.dart";
import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/main.dart";
class RoomChat extends HookConsumerWidget {
final bool isDesktop;
final bool showMembersByDefault;
const RoomChat({
required this.isDesktop,
required this.showMembersByDefault,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(ClientController.provider.notifier);
final relatedMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(showMembersByDefault);
final relationType = useState(RelationType.reply);
final userId = ref.watch(ClientStateController.provider)?.userId;
final roomId = ref.watch(
SelectedRoomController.provider.select((value) => value?.metadata?.id),
);
final theme = Theme.of(context);
final danger = theme.colorScheme.error;
if (roomId == null || userId == null) {
return Scaffold(
appBar: RoomAppbar(
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: null,
),
body: Center(
child: Text(
"Nothing to see here...",
style: theme.textTheme.headlineMedium,
),
),
);
}
final controllerProvider = RoomChatController.provider(roomId);
final notifier = ref.watch(controllerProvider.notifier);
final composerNode = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
relatedMessage.value = null;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
List<PopupMenuEntry> getMessageOptions(Message message) {
final isSentByMe = message.authorId == userId;
return [
if (ref.watch(
PowerLevelController.provider(
PowerLevelConfig(eventType: "m.reaction"),
),
))
PopupMenuItem(
child: Row(
children: [
...{
...ref.watch(
AccountDataController.provider.select(
(value) => IList(
value["m.recent_emoji"]?.content["recent_emoji"] ??
[],
).map((entry) => entry["emoji"]),
),
),
"👍",
"🤣",
"😭",
"🤔",
}
.toIList()
.sublist(0, 4)
.map(
(emoji) => IconButton(
onPressed: () async {
Navigator.of(context).pop();
await notifier
.sendReaction(emoji, message)
.onError(showError);
},
icon: Text(emoji),
),
),
EmojiPickerButton(
context: context,
onPressed: Navigator.of(context).pop,
onSelection: (emoji) =>
notifier.sendReaction(emoji, message).onError(showError),
),
],
),
),
if (ref.watch(
PowerLevelController.provider(
PowerLevelConfig(eventType: "m.room.message"),
),
))
PopupMenuItem(
onTap: () {
relatedMessage.value = message;
relationType.value = RelationType.reply;
composerNode.requestFocus();
},
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
),
if (message is TextMessage && isSentByMe)
PopupMenuItem(
onTap: () {
relatedMessage.value = message;
relationType.value = RelationType.edit;
composerNode.requestFocus();
},
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
),
PopupMenuItem(
onTap: () async {
final room = ref.watch(SelectedRoomController.provider);
if (room == null) return;
final vias = ref.watch(ViaController.provider(room));
await Clipboard.setData(
ClipboardData(
text:
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${message.id}$vias)",
),
);
},
child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
),
if (ref.watch(
PowerLevelController.provider(
PowerLevelConfig(eventType: "m.room.redaction"),
),
))
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (_) {
final deleteReasonController = useTextEditingController();
return AlertDialog(
title: Text("Delete Message"),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Are you sure you want to delete this message? This can not be reversed.",
),
SizedBox(height: 12),
FormTextInput(
required: false,
capitalize: true,
controller: deleteReasonController,
title: "Reason for deletion (optional)",
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await notifier
.deleteMessage(
message,
reason: deleteReasonController.text,
)
.onError(showError);
},
child: Text("Delete"),
),
],
);
},
),
),
child: ListTile(
leading: Icon(Icons.delete, color: danger),
title: Text("Delete", style: TextStyle(color: danger)),
),
),
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (_) {
final reasonController = useTextEditingController();
return AlertDialog(
title: Text("Report"),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Report this event to your server administrators, who can take action like banning this server or room.",
),
SizedBox(height: 12),
FormTextInput(
required: false,
capitalize: true,
controller: reasonController,
title: "Reason for report (optional)",
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () {
client.reportEvent(
ReportRequest(
roomId: roomId,
eventId: message.id,
reason: reasonController.text.isEmpty
? null
: reasonController.text,
),
);
Navigator.of(context).pop();
},
child: Text("Report"),
),
],
);
},
),
),
child: ListTile(
leading: Icon(Icons.report, color: danger),
title: Text("Report", style: TextStyle(color: danger)),
),
),
];
}
final chatTheme = ChatTheme.fromThemeData(theme).copyWith(
colors: ChatColors.fromThemeData(theme).copyWith(
primary: theme.colorScheme.primaryContainer,
onPrimary: theme.colorScheme.onPrimaryContainer,
),
);
return Scaffold(
appBar: RoomAppbar(
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: (thisContext) {
memberListOpened.value = !memberListOpened.value;
Scaffold.of(thisContext).openEndDrawer();
},
),
body: Row(
children: [
Expanded(
child: Column(
children: [
Expanded(
child: ref
.watch(controllerProvider)
.betterWhen(
data: (controller) => Chat(
currentUserId: userId,
theme: chatTheme,
onMessageSecondaryTap:
(
context,
message, {
required index,
TapUpDetails? details,
}) => details?.globalPosition == null
? null
: context.showContextMenu(
globalPosition: details!.globalPosition,
children: getMessageOptions(message),
),
onMessageLongPress:
(
context,
message, {
required details,
required index,
}) => context.showContextMenu(
globalPosition: details.globalPosition,
children: getMessageOptions(message),
),
builders: Builders(
loadMoreBuilder: (_) => SizedBox.shrink(),
chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList(
itemBuilder: itemBuilder,
onEndReached:
ref.watch(
SelectedRoomController.provider.select(
(room) => room?.hasMore == true,
),
)
? notifier.loadOlder
: null,
onStartReached: () async {
final room = ref.watch(
SelectedRoomController.provider,
);
return room == null
? null
: await client.markRead(room);
},
bottomPadding: 72,
),
composerBuilder: (_) => ChatBox(
node: composerNode,
onSend:
(
text, {
required shouldMention,
required tags,
}) => notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relation: relatedMessage.value,
)
.onError(showError),
relationType: relationType.value,
relatedMessage: relatedMessage.value,
onDismiss: () => relatedMessage.value = null,
),
textMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => TextMessageWrapper(
message,
content: message.text,
groupStatus: groupStatus,
onTapReply: notifier.scrollToMessage,
updateMessage: controller.updateMessage,
isSentByMe: isSentByMe,
),
imageMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => TextMessageWrapper(
message,
content: message.text,
groupStatus: groupStatus,
onTapReply: notifier.scrollToMessage,
updateMessage: controller.updateMessage,
isSentByMe: isSentByMe,
extra: ExpandableImageMessage(
message,
index: index,
),
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => MessageWrapper(
message,
InkWell(
onTap: () => showDialog(
context: context,
builder: (_) => Dialog(
child: Text(
"TODO: Download Attachments",
),
),
),
child: FlyerChatFileMessage(
topWidget: ReplyWidget(
message,
onTapReply: notifier.scrollToMessage,
groupStatus: groupStatus,
),
message: message,
index: index,
),
),
groupStatus,
),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
unsupportedMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => Text(
"${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
),
resolveUser: (_) async => null,
chatController: controller,
),
),
),
],
),
),
if (memberListOpened.value == true && showMembersByDefault)
MemberList(),
],
),
endDrawer: showMembersByDefault ? null : MemberList(),
);
}
}

View file

@ -1,83 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart";
import "package:timeago/timeago.dart";
class MessageWrapper extends StatelessWidget {
final Message message;
final Widget child;
final MessageGroupStatus? groupStatus;
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final error = message.metadata?["error"];
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)),
child: AnimatedContainer(
padding: message.metadata?["flashing"] == true
? EdgeInsets.all(8)
: EdgeInsets.all(0),
color: message.metadata?["flashing"] == true
? Theme.of(context).colorScheme.onSurface.withAlpha(50)
: Colors.transparent,
duration: Duration(milliseconds: 250),
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
groupStatus?.isFirst != false
? MessageAvatar(message, height: 40)
: SizedBox(width: 40),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (groupStatus?.isFirst != false)
Row(
spacing: 4,
children: [
Flexible(
child: MessageDisplayname(
message,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (message.deliveredAt != null &&
groupStatus?.isFirst != false)
Tooltip(
message: message.deliveredAt!.toString(),
child: Text(
format(message.deliveredAt!),
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
),
],
),
child,
if (error != null && error != "not sent")
Text(
error,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
),
),
ReactionRow(message),
],
),
),
],
),
),
);
}
}

View file

@ -1,116 +0,0 @@
import "package:cross_cache/cross_cache.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
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/room_chat_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/main.dart";
class ReactionRow extends ConsumerWidget {
final Message message;
const ReactionRow(this.message, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientState = ref.watch(ClientStateController.provider);
return Wrap(
spacing: 4,
runSpacing: 4,
children: clientState?.homeserverUrl == null || message.reactions == null
? []
: message.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 controller = ref.watch(
RoomChatController.provider(
roomId,
).notifier,
);
if (selected) {
await controller
.removeReaction(
reaction,
message,
clientState.userId!,
)
.onError(showError);
} else {
await controller
.sendReaction(reaction, message)
.onError(showError);
}
} finally {
enabled.value = true;
}
}
: null,
),
);
},
),
)
.toList(),
);
}
}

View file

@ -1,147 +0,0 @@
import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_link_previewer/flutter_link_previewer.dart";
import "package:flutter_linkify/flutter_linkify.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/controllers/url_preview_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/widgets/chat_page/html/html.dart";
import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
import "package:nexus/widgets/chat_page/reply_widget.dart";
class TextMessageWrapper extends ConsumerWidget {
final Message message;
final String? content;
final MessageGroupStatus? groupStatus;
final Future<void> Function(Message oldMessage, Message newMessage)
updateMessage;
final bool isSentByMe;
final Widget? extra;
final OnTapReply onTapReply;
const TextMessageWrapper(
this.message, {
this.content,
this.onTapReply,
required this.updateMessage,
required this.groupStatus,
required this.isSentByMe,
this.extra,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final textMessage = message is TextMessage ? message as TextMessage : null;
final link = textMessage == null
? null
: RegExp(
r'''https?://[^\s"'<>]+''',
).allMatches(textMessage.text).firstOrNull?.group(0);
return MessageWrapper(
message,
ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
color: isSentByMe
? (message.id.startsWith("~")
? colorScheme.onPrimary
: colorScheme.primaryContainer)
: colorScheme.surfaceContainer,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReplyWidget(
message,
groupStatus: groupStatus,
onTapReply: onTapReply,
),
if (content != null)
message.metadata?["format"] == "org.matrix.custom.html"
? Html(
textStyle: message.metadata?["big"] == true
? TextStyle(fontSize: 32)
: null,
content!.replaceAllMapped(
RegExp(
"(<a\\b[^>]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)",
caseSensitive: false,
dotAll: true,
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
),
)
: Linkify(
text: content!,
options: LinkifyOptions(humanize: false),
onOpen: (link) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(link.url)),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
if (textMessage?.editedAt != null)
Text("(edited)", style: theme.textTheme.labelSmall),
if (link != null)
ref
.watch(UrlPreviewController.provider(link))
.betterWhen(
loading: SizedBox.shrink,
data: (preview) => preview == null
? SizedBox.shrink()
: LinkPreview(
onTap: (url) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(url)),
imageBuilder: (url) => Image(
image: CachedNetworkImage(
url,
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => SizedBox.shrink(),
),
text: link,
backgroundColor: isSentByMe
? colorScheme.inversePrimary
: colorScheme.surfaceContainerLow,
outsidePadding: EdgeInsets.only(top: 4),
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: preview,
onLinkPreviewDataFetched: (_) => null,
),
),
if (extra != null) extra!,
],
),
),
),
groupStatus,
);
}
}

View file

@ -0,0 +1,190 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/power_level_controller.dart";
import "package:nexus/models/configs/power_level_config.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/mention_overlay.dart";
import "package:nexus/widgets/composer/relation_preview.dart";
import "package:nexus/widgets/emoji_picker_button.dart";
class ChatBox extends HookConsumerWidget {
final String roomId;
final Event? relatedEvent;
final RelationType relationType;
final VoidCallback onDismiss;
final FocusNode? node;
final Future<void> Function(
String text, {
required bool shouldMention,
required IList<Tag> tags,
})
onSend;
const ChatBox(
this.roomId, {
required this.relatedEvent,
required this.relationType,
required this.onDismiss,
required this.onSend,
this.node,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final controller = useRef(FlutterTaggerController());
final triggerCharacter = useState("");
final shouldMention = useState(true);
final query = useState("");
if (relationType == RelationType.edit && controller.value.text.isEmpty) {
controller.value.text = relatedEvent?.localContent?.editSource ?? "";
}
void send() {
if (controller.value.text.isEmpty) return;
onSend(
controller.value.formattedText,
shouldMention: shouldMention.value,
tags: controller.value.tags.toIList(),
);
onDismiss();
controller.value.text = "";
}
final style = TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
);
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Padding(
padding: EdgeInsetsGeometry.all(12),
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)),
child: Column(
children: [
RelationPreview(
relatedEvent,
shouldMention: shouldMention.value,
toggleShouldMention: () =>
shouldMention.value = !shouldMention.value,
relationType: relationType,
onDismiss: onDismiss,
),
Container(
color: theme.colorScheme.surfaceContainerHighest,
padding: EdgeInsets.symmetric(horizontal: 8),
child: Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.center,
children:
ref.watch(
PowerLevelController.provider(
PowerLevelConfig(
eventType: EventType.message,
roomId: roomId,
),
),
)
? [
EmojiPickerButton(
context: context,
onSelection: (_) => node?.requestFocus(),
controller: controller.value,
),
PopupMenuButton(
tooltip: "Add media",
itemBuilder: (context) => [
PopupMenuItem(
child: ListTile(
title: Text("Camera"),
leading: Icon(Icons.add_a_photo),
),
),
PopupMenuItem(
child: ListTile(
title: Text("Gallery"),
leading: Icon(Icons.add_photo_alternate),
),
),
PopupMenuItem(
child: ListTile(
title: Text("Files"),
leading: Icon(Icons.attachment),
),
),
],
icon: Icon(Icons.add),
),
Expanded(
child: FlutterTagger(
triggerStrategy: TriggerStrategy.eager,
overlay: MentionOverlay(
roomId,
query: query.value,
triggerCharacter: triggerCharacter.value,
addTag: ({required id, required name}) {
controller.value.addTag(id: id, name: name);
node?.requestFocus();
},
),
controller: controller.value,
onSearch: (newQuery, newTriggerCharacter) {
triggerCharacter.value = newTriggerCharacter;
query.value = newQuery;
},
triggerCharacterAndStyles: {
"@": style,
"#": style,
},
builder: (context, key) => TextFormField(
maxLines: 12,
minLines: 1,
autofocus: true,
decoration: InputDecoration(
hintText: "Your message here...",
border: InputBorder.none,
),
controller: controller.value,
key: key,
onFieldSubmitted: (_) => send(),
// Don't defocus on submit
onEditingComplete: () {},
textInputAction: TextInputAction.done,
focusNode: node,
),
),
),
IconButton(
onPressed: send,
icon: Icon(Icons.send),
tooltip: "Send message",
),
]
: [
Padding(
padding: EdgeInsetsGeometry.all(8),
child: Text(
"You don't have permission to send messages in this room...",
),
),
],
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,154 @@
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/members_by_status_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/controllers/via_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_localpart.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";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/loading.dart";
class MentionOverlay extends ConsumerWidget {
final String? triggerCharacter;
final String query;
final String roomId;
final void Function({required String id, required String name}) addTag;
const MentionOverlay(
this.roomId, {
required this.query,
required this.addTag,
required this.triggerCharacter,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rooms = ref.watch(RoomsController.provider);
return Padding(
padding: EdgeInsets.all(8),
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
padding: EdgeInsets.all(8),
child: switch (triggerCharacter) {
"@" =>
ref
.watch(
MembersByStatusController.provider(
MembersByStatusConfig(
roomId: roomId,
status: MembershipStatus.join,
),
),
)
.betterWhen(
data: (members) => ListView(
children:
(query.isEmpty
? members
: members.where(
(member) =>
member.stateKey
?.toLowerCase()
.contains(
query.toLowerCase(),
) ==
true ||
switch (member.content) {
MembershipContent(
:final displayName,
) =>
displayName
?.toLowerCase()
.contains(
query.toLowerCase(),
) ==
true,
_ => false,
},
))
.map(
(member) => switch (member.content) {
MembershipContent(
:final displayName,
:final avatarUrl,
) =>
ListTile(
leading: AvatarOrHash(
avatarUrl,
displayName ??
member.stateKey!.localpart,
),
title: Text(
displayName ??
member.stateKey!.localpart,
),
subtitle: Text(member.stateKey!),
onTap: () => addTag(
id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})",
name: member.stateKey!.localpart,
),
),
_ => SizedBox.shrink(),
},
)
.toList(),
),
),
"#" => ListView(
children:
(query.isEmpty
? rooms.values
: rooms.values.where(
(room) =>
(room.metadata?.name ?? room.metadata!.id)
.toLowerCase()
.contains(query.toLowerCase()),
))
.map((room) {
final name =
room.metadata?.name ??
room.metadata!.canonicalAlias ??
room.metadata!.id;
return ListTile(
leading: AvatarOrHash(
room.metadata?.avatar,
name,
fallback: Icon(Icons.numbers),
),
title: Text(name),
subtitle: room.metadata?.topic == null
? null
: Text(room.metadata!.topic!, maxLines: 1),
onTap: () {
final vias = ref.watch(
ViaController.provider(room),
);
addTag(
id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)",
name:
(room.metadata?.canonicalAlias ??
room.metadata?.id)
?.substring(1)
.split(":")
.first ??
"",
);
},
);
})
.toList(),
),
_ => Loading(),
},
),
),
);
}
}

View file

@ -0,0 +1,70 @@
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/event_preview.dart";
class RelationPreview extends ConsumerWidget {
final Event? relatedEvent;
final RelationType relationType;
final VoidCallback onDismiss;
final bool shouldMention;
final VoidCallback toggleShouldMention;
const RelationPreview(
this.relatedEvent, {
required this.relationType,
required this.onDismiss,
required this.shouldMention,
required this.toggleShouldMention,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (relatedEvent == null) return SizedBox.shrink();
final theme = Theme.of(context);
return Container(
color: theme.colorScheme.surfaceContainerHigh,
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
spacing: 8,
children: [
if (relationType == RelationType.edit)
Text(
"Editing message:",
style: TextStyle(fontWeight: FontWeight.bold),
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: EventPreview(relatedEvent!),
),
),
if (relationType == RelationType.reply)
TextButton(
onPressed: toggleShouldMention,
child: Text(
shouldMention ? "@On" : "@Off",
style: TextStyle(
fontWeight: FontWeight.w900,
color: shouldMention ? null : Theme.of(context).disabledColor,
),
),
),
IconButton(
tooltip:
"Cancel ${relationType == RelationType.edit ? "edit" : "reply"}",
onPressed: onDismiss,
icon: const Icon(Icons.close),
iconSize: 20,
),
],
),
);
}
}

View file

@ -0,0 +1,51 @@
import "package:emoji_text_field/emoji_text_field.dart";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/emoji_controller.dart";
class EmojiPickerButton extends HookConsumerWidget {
final TextEditingController? controller;
final void Function(String emoji)? onSelection;
final VoidCallback? onPressed;
final BuildContext context;
const EmojiPickerButton({
this.controller,
this.onPressed,
this.onSelection,
required this.context,
super.key,
});
@override
Widget build(_, WidgetRef ref) => IconButton(
onPressed: () async {
onPressed?.call();
final controller = this.controller ?? TextEditingController();
final emojis = await ref.watch(EmojiController.provider.future);
if (context.mounted) {
showModalBottomSheet(
context: context,
builder: (context) => EmojiKeyboardView(
config: EmojiViewConfig(
showRecentTab: false,
customCategories: emojis.$1.unlock,
customKeywords: emojis.$2.unlock,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
height: 600,
),
textController: controller
..addListener(() {
// Without this, there will sometimes be a debugLocked is not true error sometimes
Future.delayed(Duration.zero, () {
if (context.mounted) Navigator.of(context).pop();
});
onSelection?.call(controller.text);
}),
),
);
}
},
icon: Icon(Icons.emoji_emotions),
);
}

View file

@ -0,0 +1,36 @@
import "package:flutter/material.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/event.dart";
class EventPreview extends StatelessWidget {
final Event event;
const EventPreview(this.event, {super.key});
@override
Widget build(BuildContext context) => IgnorePointer(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Row(
spacing: 12,
children: [
if (event.content is MessageContent) MessageAvatar(event),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 2,
children: [
if (event.content is MessageContent) MessageDisplayname(event),
EventRenderer(event, textOnly: true, maxLines: 1),
],
),
),
],
),
),
);
}

View file

@ -0,0 +1,48 @@
import "dart:math";
import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/widgets/error_dialog.dart";
class ExpandableImage extends ConsumerWidget {
final Widget child;
final String? source;
const ExpandableImage(this.source, {required this.child, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => InkWell(
onTap: source == null
? null
: () => showDialog(
context: context,
builder: (_) => LayoutBuilder(
builder: (context, constraints) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.all(constraints.maxWidth / 100),
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: min(constraints.maxWidth, 1000),
),
child: InteractiveViewer(
child: Image(
fit: BoxFit.contain,
errorBuilder: (_, error, stackTrace) => ErrorDialog(
"Loading failed for $source\nError: $error",
stackTrace,
),
image: CachedNetworkImage(
source!,
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),
),
),
),
),
),
),
child: child,
);
}

View file

@ -0,0 +1,29 @@
import "package:flutter/material.dart";
import "package:nexus/helpers/extensions/size_to_string.dart";
import "package:nexus/models/info/file.dart";
class FileCard extends StatelessWidget {
final Uri uri;
final FileInfo? info;
final String? filename;
const FileCard(this.uri, this.info, {this.filename, super.key});
@override
Widget build(BuildContext context) => SizedBox(
width: 320,
child: Card(
color: Theme.of(context).colorScheme.surfaceContainer,
child: ListTile(
leading: Icon(Icons.file_copy),
title: Text(
filename ?? "file",
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString),
// TODO: Downloading files
trailing: IconButton(onPressed: null, icon: Icon(Icons.download)),
),
),
);
}

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

@ -0,0 +1,56 @@
import "dart:math";
import "package:flutter/material.dart";
class CodeBlock extends StatelessWidget {
final String code;
final String lang;
const CodeBlock(this.code, {required this.lang, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(16)),
child: ColoredBox(
color: theme.colorScheme.surfaceContainerHighest,
child: IntrinsicWidth(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
lang.substring(0, min(lang.length, 15)),
style: TextStyle(fontFamily: "monospace"),
),
),
TextButton.icon(
onPressed: () {},
icon: Icon(Icons.copy),
label: Text("Copy"),
),
],
),
ColoredBox(
color: theme.colorScheme.surfaceContainerHigh,
child: Container(
constraints: BoxConstraints(minWidth: 250),
padding: EdgeInsets.all(8),
child: SelectableText(
code,
minLines: 1,
maxLines: 99,
style: TextStyle(fontFamily: "monospace"),
),
),
),
],
),
),
),
);
}
}

160
lib/widgets/html/html.dart Normal file
View file

@ -0,0 +1,160 @@
import "package:cross_cache/cross_cache.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/html/mention_chip.dart";
import "package:nexus/widgets/html/spoiler_text.dart";
import "package:nexus/widgets/html/code_block.dart";
import "package:nexus/widgets/html/quoted.dart";
class Html extends ConsumerWidget {
final String html;
final String? roomId;
final TextStyle? textStyle;
const Html(this.html, {this.roomId, this.textStyle, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget(
html,
buildAsync: false,
textStyle: textStyle,
customWidgetBuilder: (element) {
if (element.attributes.keys.contains("data-mx-profile-fallback")) {
return SizedBox.shrink();
}
if (element.attributes.keys.contains("data-mx-spoiler")) {
return InlineCustomWidget(child: SpoilerText(text: element.text));
}
final height =
int.tryParse(element.attributes["height"] ?? "") ??
(element.attributes.keys.contains("data-mx-emoticon") ? 32 : null) ??
300;
final width = int.tryParse(element.attributes["width"] ?? "");
final src = Uri.tryParse(element.attributes["src"] ?? "")
?.mxcToHttps(
ref.watch(
ClientStateController.provider.select(
(value) => value?.homeserverUrl,
),
) ??
"",
)
.toString();
return switch (element.localName) {
"code" =>
element.parent?.localName == "pre"
? CodeBlock(
element.text,
lang: element.className.replaceAll("language-", ""),
)
: null,
"blockquote" => Quoted(
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
),
"a" =>
element.attributes["href"]?.mention == null
? null
: InlineCustomWidget(
child: MentionChip(element.attributes["href"]!, roomId),
),
"img" =>
src == null
? SizedBox.shrink()
: InlineCustomWidget(
alignment: PlaceholderAlignment.middle,
child: ExpandableImage(
src,
child: Image(
image: CachedNetworkImage(
src,
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),
errorBuilder: (_, error, _) => Text(
"Image Failed to Load",
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
height: height.toDouble(),
width: width?.toDouble(),
loadingBuilder: (_, child, loadingProgress) =>
loadingProgress == null
? child
: CircularProgressIndicator(),
),
),
),
// Allowed elements list
("del" ||
"h1" ||
"h2" ||
"h3" ||
"h4" ||
"h5" ||
"h6" ||
"p" ||
"ul" ||
"ol" ||
"sup" ||
"sub" ||
"li" ||
"b" ||
"i" ||
"u" ||
"strong" ||
"em" ||
"s" ||
"code" ||
"hr" ||
"br" ||
"div" ||
"table" ||
"thead" ||
"tbody" ||
"tr" ||
"th" ||
"td" ||
"caption" ||
"pre" ||
"span" ||
"details" ||
"summary") =>
null,
_ => SizedBox.shrink(),
};
},
customStylesBuilder: (element) => {
"width": "auto",
...Map.fromEntries(
element.attributes
.mapTo<MapEntry<String, String>?>(
(key, value) => switch (key) {
"data-mx-color" => MapEntry("color", value),
"data-mx-bg-color" => MapEntry("background-color", value),
_ => null,
},
)
.nonNulls,
),
},
onTapUrl: (url) =>
ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)),
);
}

View file

@ -0,0 +1,53 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/user_controller.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/configs/user_config.dart";
class MentionChip extends ConsumerWidget {
final String? roomId;
final String content;
const MentionChip(this.content, this.roomId, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mention = content.mention;
final membership = mention?.startsWith("@") == true
? ref
.watch(
UserController.provider(
UserConfig(roomId: roomId, userId: mention!),
),
)
.whenOrNull(data: (data) => data)
: null;
return mention == null
? SizedBox.shrink()
: InkWell(
onTapUp: (details) {
if (membership != null) {
context.showUserPopover(
membership,
mention,
globalPosition: details.globalPosition,
);
}
},
child: IgnorePointer(
child: Chip(
label: Text(
(membership == null ? null : "@${membership.displayName}") ??
mention,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimary,
),
),
backgroundColor: Theme.of(context).colorScheme.primary,
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show more