forked from Nexus/nexus
Remove flutter chat (#26)
Had to squash merge manually as Forgejo was erroring
This commit is contained in:
parent
bd1d5ea745
commit
16cf126df4
111 changed files with 3162 additions and 2366 deletions
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,20 @@
|
|||
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: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/chat_page/composer/mention_overlay.dart";
|
||||
import "package:nexus/widgets/chat_page/composer/relation_preview.dart";
|
||||
import "package:nexus/widgets/chat_page/emoji_picker_button.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 Message? relatedMessage;
|
||||
final String roomId;
|
||||
final Event? relatedEvent;
|
||||
final RelationType relationType;
|
||||
final VoidCallback onDismiss;
|
||||
final FocusNode? node;
|
||||
|
|
@ -22,8 +24,9 @@ class ChatBox extends HookConsumerWidget {
|
|||
required IList<Tag> tags,
|
||||
})
|
||||
onSend;
|
||||
const ChatBox({
|
||||
required this.relatedMessage,
|
||||
const ChatBox(
|
||||
this.roomId, {
|
||||
required this.relatedEvent,
|
||||
required this.relationType,
|
||||
required this.onDismiss,
|
||||
required this.onSend,
|
||||
|
|
@ -39,10 +42,8 @@ class ChatBox extends HookConsumerWidget {
|
|||
final shouldMention = useState(true);
|
||||
final query = useState("");
|
||||
|
||||
if (relationType == RelationType.edit &&
|
||||
relatedMessage is TextMessage &&
|
||||
controller.value.text.isEmpty) {
|
||||
controller.value.text = relatedMessage?.metadata?["editSource"] ?? "";
|
||||
if (relationType == RelationType.edit && controller.value.text.isEmpty) {
|
||||
controller.value.text = relatedEvent?.localContent?.editSource ?? "";
|
||||
}
|
||||
|
||||
void send() {
|
||||
|
|
@ -73,7 +74,7 @@ class ChatBox extends HookConsumerWidget {
|
|||
child: Column(
|
||||
children: [
|
||||
RelationPreview(
|
||||
relatedMessage,
|
||||
relatedEvent,
|
||||
shouldMention: shouldMention.value,
|
||||
toggleShouldMention: () =>
|
||||
shouldMention.value = !shouldMention.value,
|
||||
|
|
@ -89,7 +90,10 @@ class ChatBox extends HookConsumerWidget {
|
|||
children:
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
PowerLevelConfig(eventType: "m.room.message"),
|
||||
PowerLevelConfig(
|
||||
eventType: EventType.message,
|
||||
roomId: roomId,
|
||||
),
|
||||
),
|
||||
)
|
||||
? [
|
||||
|
|
@ -126,6 +130,7 @@ class ChatBox extends HookConsumerWidget {
|
|||
child: FlutterTagger(
|
||||
triggerStrategy: TriggerStrategy.eager,
|
||||
overlay: MentionOverlay(
|
||||
roomId,
|
||||
query: query.value,
|
||||
triggerCharacter: triggerCharacter.value,
|
||||
addTag: ({required id, required name}) {
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/members_by_type_controller.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";
|
||||
|
|
@ -11,8 +14,10 @@ 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({
|
||||
const MentionOverlay(
|
||||
this.roomId, {
|
||||
required this.query,
|
||||
required this.addTag,
|
||||
required this.triggerCharacter,
|
||||
|
|
@ -34,7 +39,12 @@ class MentionOverlay extends ConsumerWidget {
|
|||
"@" =>
|
||||
ref
|
||||
.watch(
|
||||
MembersByTypeController.provider(MembershipStatus.join),
|
||||
MembersByStatusController.provider(
|
||||
MembersByStatusConfig(
|
||||
roomId: roomId,
|
||||
status: MembershipStatus.join,
|
||||
),
|
||||
),
|
||||
)
|
||||
.betterWhen(
|
||||
data: (members) => ListView(
|
||||
|
|
@ -43,33 +53,49 @@ class MentionOverlay extends ConsumerWidget {
|
|||
? members
|
||||
: members.where(
|
||||
(member) =>
|
||||
member.userId.toLowerCase().contains(
|
||||
query.toLowerCase(),
|
||||
) ==
|
||||
true ||
|
||||
member.displayName
|
||||
.toLowerCase()
|
||||
member.stateKey
|
||||
?.toLowerCase()
|
||||
.contains(
|
||||
query.toLowerCase(),
|
||||
) ==
|
||||
true,
|
||||
true ||
|
||||
switch (member.content) {
|
||||
MembershipContent(
|
||||
:final displayName,
|
||||
) =>
|
||||
displayName
|
||||
?.toLowerCase()
|
||||
.contains(
|
||||
query.toLowerCase(),
|
||||
) ==
|
||||
true,
|
||||
_ => false,
|
||||
},
|
||||
))
|
||||
.map(
|
||||
(member) => ListTile(
|
||||
leading: AvatarOrHash(
|
||||
member.avatarUrl,
|
||||
member.displayName,
|
||||
),
|
||||
title: Text(member.displayName),
|
||||
subtitle: Text(member.userId),
|
||||
onTap: () => addTag(
|
||||
id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})",
|
||||
name: member.userId
|
||||
.substring(1)
|
||||
.split(":")
|
||||
.first,
|
||||
),
|
||||
),
|
||||
(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(),
|
||||
),
|
||||
|
|
@ -1,19 +1,18 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat_core/flutter_chat_core.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/chat_page/lazy_loading/message_avatar.dart";
|
||||
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
|
||||
import "package:nexus/widgets/event_preview.dart";
|
||||
|
||||
class RelationPreview extends ConsumerWidget {
|
||||
final Message? relatedMessage;
|
||||
final Event? relatedEvent;
|
||||
final RelationType relationType;
|
||||
final VoidCallback onDismiss;
|
||||
final bool shouldMention;
|
||||
final VoidCallback toggleShouldMention;
|
||||
|
||||
const RelationPreview(
|
||||
this.relatedMessage, {
|
||||
this.relatedEvent, {
|
||||
required this.relationType,
|
||||
required this.onDismiss,
|
||||
required this.shouldMention,
|
||||
|
|
@ -23,12 +22,12 @@ class RelationPreview extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (relatedMessage == null) return SizedBox.shrink();
|
||||
if (relatedEvent == null) return SizedBox.shrink();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
|
|
@ -38,32 +37,10 @@ class RelationPreview extends ConsumerWidget {
|
|||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
|
||||
MessageAvatar(relatedMessage!),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Flexible(
|
||||
child: MessageDisplayname(
|
||||
relatedMessage!,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
relatedMessage?.metadata?["body"] ??
|
||||
relatedMessage?.metadata?["eventType"] ??
|
||||
"",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: EventPreview(relatedEvent!),
|
||||
),
|
||||
),
|
||||
|
||||
36
lib/widgets/event_preview.dart
Normal file
36
lib/widgets/event_preview.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
29
lib/widgets/file_card.dart
Normal file
29
lib/widgets/file_card.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
20
lib/widgets/flash_wrapper.dart
Normal file
20
lib/widgets/flash_wrapper.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -9,20 +9,22 @@ 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/chat_page/expandable_image.dart";
|
||||
import "package:nexus/widgets/chat_page/html/mention_chip.dart";
|
||||
import "package:nexus/widgets/chat_page/html/spoiler_text.dart";
|
||||
import "package:nexus/widgets/chat_page/html/code_block.dart";
|
||||
import "package:nexus/widgets/chat_page/html/quoted.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.textStyle, super.key});
|
||||
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")) {
|
||||
|
|
@ -58,13 +60,15 @@ class Html extends ConsumerWidget {
|
|||
)
|
||||
: null,
|
||||
|
||||
"blockquote" => Quoted(Html(element.innerHtml)),
|
||||
"blockquote" => Quoted(
|
||||
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
|
||||
),
|
||||
|
||||
"a" =>
|
||||
element.attributes["href"]?.mention == null
|
||||
? null
|
||||
: InlineCustomWidget(
|
||||
child: MentionChip(element.attributes["href"]!),
|
||||
child: MentionChip(element.attributes["href"]!, roomId),
|
||||
),
|
||||
|
||||
"img" =>
|
||||
53
lib/widgets/html/mention_chip.dart
Normal file
53
lib/widgets/html/mention_chip.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,36 @@
|
|||
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/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/models/event.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
|
||||
class MessageAvatar extends ConsumerWidget {
|
||||
final Message message;
|
||||
final Event event;
|
||||
final double height;
|
||||
const MessageAvatar(this.message, {this.height = 16, super.key});
|
||||
const MessageAvatar(this.event, {this.height = 24, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => ref
|
||||
.watch(AuthorController.provider(message))
|
||||
.watch(AuthorController.provider(event))
|
||||
.betterWhen(
|
||||
data: (membership) => InkWell(
|
||||
onTapUp: (details) => context.showUserPopover(
|
||||
membership,
|
||||
globalPosition: details.globalPosition,
|
||||
),
|
||||
onTapUp: (details) {
|
||||
context.showUserPopover(
|
||||
membership,
|
||||
event.sender,
|
||||
globalPosition: details.globalPosition,
|
||||
);
|
||||
},
|
||||
child: AvatarOrHash(
|
||||
membership.avatarUrl,
|
||||
membership.displayName,
|
||||
membership.displayName ?? event.sender.localpart,
|
||||
height: height,
|
||||
),
|
||||
),
|
||||
loading: () =>
|
||||
AvatarOrHash(null, message.authorId.substring(1), height: height),
|
||||
AvatarOrHash(null, event.sender.localpart, height: height),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
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/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 {
|
||||
final Message message;
|
||||
final Event event;
|
||||
final TextStyle? style;
|
||||
final bool clickable;
|
||||
const MessageDisplayname(
|
||||
this.message, {
|
||||
this.event, {
|
||||
this.clickable = true,
|
||||
this.style,
|
||||
super.key,
|
||||
|
|
@ -18,18 +20,25 @@ class MessageDisplayname extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => ref
|
||||
.watch(AuthorController.provider(message))
|
||||
.watch(AuthorController.provider(event))
|
||||
.betterWhen(
|
||||
data: (membership) => InkWell(
|
||||
onTapUp: clickable
|
||||
? (details) => context.showUserPopover(
|
||||
membership,
|
||||
event.sender,
|
||||
globalPosition: details.globalPosition,
|
||||
)
|
||||
: null,
|
||||
child: Text(
|
||||
"${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}",
|
||||
style: style,
|
||||
"${membership.displayName ?? event.sender.localpart}${event.pmp == null ? "" : " (via ${event.sender})"}",
|
||||
style:
|
||||
style ??
|
||||
TextStyle(
|
||||
color: event.sender.colorHash,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
119
lib/widgets/member_list.dart
Normal file
119
lib/widgets/member_list.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
|
||||
class MemberList extends HookConsumerWidget {
|
||||
final String roomId;
|
||||
const MemberList(this.roomId, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final status = useState(MembershipStatus.join);
|
||||
final membersProvider = ref.watch(
|
||||
MembersByStatusController.provider(
|
||||
MembersByStatusConfig(roomId: roomId, status: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
switch (membersProvider) {
|
||||
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||
error,
|
||||
stackTrace,
|
||||
),
|
||||
AsyncData(:final value) || AsyncLoading(:final value?) => Expanded(
|
||||
child: ListView(
|
||||
children: value
|
||||
.map(
|
||||
(member) => switch (member.content) {
|
||||
MembershipContent(
|
||||
:final avatarUrl,
|
||||
:final displayName,
|
||||
) =>
|
||||
InkWell(
|
||||
onTapUp: (details) => context.showUserPopover(
|
||||
member.content as MembershipContent,
|
||||
member.stateKey!,
|
||||
globalPosition: details.globalPosition,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: AvatarOrHash(
|
||||
avatarUrl,
|
||||
displayName ?? member.sender.localpart,
|
||||
),
|
||||
title: Text(
|
||||
displayName ?? member.stateKey!.localpart,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: member.stateKey!.colorHash,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
member.stateKey!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ => SizedBox.shrink(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
AsyncLoading _ => Loading(),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
lib/widgets/players/audio.dart
Normal file
104
lib/widgets/players/audio.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:media_kit/media_kit.dart";
|
||||
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||
import "package:nexus/models/info/audio.dart";
|
||||
|
||||
class AudioPlayer extends HookConsumerWidget {
|
||||
final Uri url;
|
||||
final AudioInfo? info;
|
||||
|
||||
const AudioPlayer(this.url, this.info, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = useMemoized(
|
||||
() => Player(
|
||||
configuration: PlayerConfiguration(bufferSize: 128 * 1024 * 1024),
|
||||
),
|
||||
);
|
||||
|
||||
final playing = useState(false);
|
||||
final position = useState(Duration.zero);
|
||||
final duration = useState(Duration.zero);
|
||||
|
||||
useEffect(() {
|
||||
scheduleMicrotask(() async {
|
||||
await player.open(
|
||||
Media(url.toString(), httpHeaders: ref.headers),
|
||||
play: false,
|
||||
);
|
||||
|
||||
player.stream.playing.listen((value) {
|
||||
playing.value = value;
|
||||
});
|
||||
|
||||
player.stream.position.listen((value) {
|
||||
position.value = value;
|
||||
});
|
||||
|
||||
player.stream.duration.listen((value) {
|
||||
duration.value = value;
|
||||
});
|
||||
});
|
||||
|
||||
return player.dispose;
|
||||
}, []);
|
||||
|
||||
String format(Duration duration) {
|
||||
final minutes = duration.inMinutes
|
||||
.remainder(60)
|
||||
.toString()
|
||||
.padLeft(2, "0");
|
||||
final seconds = duration.inSeconds
|
||||
.remainder(60)
|
||||
.toString()
|
||||
.padLeft(2, "0");
|
||||
|
||||
return "$minutes:$seconds";
|
||||
}
|
||||
|
||||
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())),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
format(duration.value),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/widgets/players/video.dart
Normal file
38
lib/widgets/players/video.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/models/info/video.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:media_kit/media_kit.dart";
|
||||
import "package:media_kit_video/media_kit_video.dart";
|
||||
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||
|
||||
class VideoPlayer extends HookConsumerWidget {
|
||||
final VideoInfo? info;
|
||||
final Uri url;
|
||||
const VideoPlayer(this.url, this.info, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = useMemoized(
|
||||
() => Player(
|
||||
configuration: PlayerConfiguration(bufferSize: 128 * 1024 * 1024),
|
||||
),
|
||||
);
|
||||
final controller = useMemoized(() => VideoController(player));
|
||||
|
||||
useEffect(() {
|
||||
scheduleMicrotask(
|
||||
() => player.open(
|
||||
Media(url.toString(), httpHeaders: ref.headers),
|
||||
play: false,
|
||||
),
|
||||
);
|
||||
|
||||
return player.dispose;
|
||||
}, []);
|
||||
|
||||
return SizedBox(height: 300, child: Video(controller: controller));
|
||||
}
|
||||
}
|
||||
120
lib/widgets/reaction_row.dart
Normal file
120
lib/widgets/reaction_row.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import "package:cross_cache/cross_cache.dart";
|
||||
import "package:flutter/material.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/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/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;
|
||||
const ReactionRow(this.event, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final clientState = ref.watch(ClientStateController.provider);
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
451
lib/widgets/renderers/event.dart
Normal file
451
lib/widgets/renderers/event.dart
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:cross_cache/cross_cache.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_blurhash/flutter_blurhash.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:linkify/linkify.dart";
|
||||
import "package:nexus/controllers/client_state_controller.dart";
|
||||
import "package:nexus/controllers/cross_cache_controller.dart";
|
||||
import "package:nexus/controllers/event_controller.dart";
|
||||
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
||||
import "package:nexus/helpers/extensions/show_context_menu.dart";
|
||||
import "package:nexus/helpers/launch_helper.dart";
|
||||
import "package:nexus/models/content/avatar.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/encrypted.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/requests/get_event_request.dart";
|
||||
import "package:nexus/widgets/event_preview.dart";
|
||||
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/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";
|
||||
import "package:timeago/timeago.dart";
|
||||
import "package:flutter_linkify/flutter_linkify.dart";
|
||||
|
||||
class EventRenderer extends ConsumerWidget {
|
||||
final Event event;
|
||||
final bool textOnly;
|
||||
final bool isGrouped;
|
||||
final int? maxLines;
|
||||
final VoidCallback? onTapReply;
|
||||
final IList<PopupMenuEntry> Function(Event event)? getEventOptions;
|
||||
const EventRenderer(
|
||||
this.event, {
|
||||
this.onTapReply,
|
||||
this.textOnly = false,
|
||||
this.isGrouped = false,
|
||||
this.maxLines,
|
||||
this.getEventOptions,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final errorStyle = TextStyle(color: colorScheme.error);
|
||||
|
||||
final timestamp = Tooltip(
|
||||
message: event.timestamp.toString(),
|
||||
child: Text(
|
||||
format(event.timestamp),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
final contextMenuCallback = getEventOptions == null
|
||||
? null
|
||||
: (details) => context.showContextMenu(
|
||||
globalPosition: details.globalPosition,
|
||||
children: getEventOptions!(event).toList(),
|
||||
);
|
||||
|
||||
final textStyle = TextStyle(
|
||||
fontSize: event.localContent?.bigEmoji == true ? 32 : null,
|
||||
fontStyle: event.content is EmoteMessageContent ? FontStyle.italic : null,
|
||||
);
|
||||
|
||||
final child = event.redactedBy != null || event.relationType == "m.replace"
|
||||
? null
|
||||
: switch (event.content) {
|
||||
Content(:final parseError?) => SelectableText(
|
||||
"An error occurred while parsing this event:\n$parseError\n${parseError.stackTrace}",
|
||||
style: errorStyle,
|
||||
),
|
||||
MessageContent() || EncryptedContent() => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (!textOnly)
|
||||
if (isGrouped)
|
||||
SizedBox(width: 40)
|
||||
else
|
||||
MessageAvatar(event, height: 40),
|
||||
Flexible(
|
||||
child: Column(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isGrouped && !textOnly)
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Flexible(child: MessageDisplayname(event)),
|
||||
Flexible(flex: 0, child: timestamp),
|
||||
],
|
||||
),
|
||||
Card(
|
||||
margin: textOnly
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.only(bottom: 4),
|
||||
color: textOnly
|
||||
? Colors.transparent
|
||||
: ref.watch(
|
||||
ClientStateController.provider.select(
|
||||
(value) => value?.userId,
|
||||
),
|
||||
) ==
|
||||
event.sender
|
||||
? (event.eventId.startsWith("~")
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.primaryContainer)
|
||||
: colorScheme.surfaceContainer,
|
||||
elevation: textOnly ? 0 : null,
|
||||
|
||||
child: Padding(
|
||||
padding: textOnly
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!textOnly && event.replyTo != null)
|
||||
Card(
|
||||
margin: EdgeInsets.only(bottom: 8),
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
child: InkWell(
|
||||
onTap: onTapReply,
|
||||
child: Padding(
|
||||
padding: EdgeInsetsGeometry.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 12,
|
||||
),
|
||||
child: switch (ref.watch(
|
||||
EventController.provider(
|
||||
GetEventRequest(
|
||||
roomId: event.roomId,
|
||||
eventId: event.replyTo!,
|
||||
),
|
||||
),
|
||||
)) {
|
||||
AsyncData(:final value?) ||
|
||||
AsyncLoading(
|
||||
:final value?,
|
||||
) => EventPreview(value),
|
||||
AsyncError _ => Text(
|
||||
"An error occurred while fetching the reply",
|
||||
style: errorStyle,
|
||||
),
|
||||
_ => Text("Fetching event..."),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
switch (event.content) {
|
||||
EncryptedContent() => Text(
|
||||
"Unable to decrypt event",
|
||||
style: errorStyle,
|
||||
),
|
||||
// TODO: Handle locations
|
||||
// LocationMessageContent(:final body , :final geoUri) =>
|
||||
TextMessageContent(
|
||||
:final body,
|
||||
:final formattedBody,
|
||||
:final format,
|
||||
) ||
|
||||
NoticeMessageContent(
|
||||
:final body,
|
||||
:final formattedBody,
|
||||
:final format,
|
||||
) ||
|
||||
EmoteMessageContent(
|
||||
:final body,
|
||||
:final formattedBody,
|
||||
:final format,
|
||||
) ||
|
||||
ImageMessageContent(
|
||||
:final body,
|
||||
:final formattedBody,
|
||||
:final format,
|
||||
) ||
|
||||
VideoMessageContent(
|
||||
:final body,
|
||||
:final formattedBody,
|
||||
:final format,
|
||||
) ||
|
||||
AudioMessageContent(
|
||||
:final body,
|
||||
:final formattedBody,
|
||||
:final format,
|
||||
) ||
|
||||
FileMessageContent(
|
||||
:final body,
|
||||
:final formattedBody,
|
||||
:final format,
|
||||
) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
format == MessageFormat.html && !textOnly
|
||||
? Html(
|
||||
roomId: event.roomId,
|
||||
textStyle: textStyle,
|
||||
formattedBody!.replaceAllMapped(
|
||||
RegExp(
|
||||
r"(<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(
|
||||
style: textStyle,
|
||||
text: body,
|
||||
maxLines: maxLines,
|
||||
overflow: maxLines == null
|
||||
? null
|
||||
: TextOverflow.ellipsis,
|
||||
options: LinkifyOptions(
|
||||
humanize: false,
|
||||
),
|
||||
onOpen: (link) => ref
|
||||
.watch(LaunchHelper.provider)
|
||||
.launchUrl(Uri.parse(link.url)),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
|
||||
if (!textOnly) ...[
|
||||
if (event.content
|
||||
case ImageMessageContent(
|
||||
:final url,
|
||||
) ||
|
||||
FileMessageContent(:final url) ||
|
||||
VideoMessageContent(:final url) ||
|
||||
AudioMessageContent(:final url))
|
||||
switch (url?.mxcToHttps(
|
||||
ref.watch(
|
||||
ClientStateController.provider
|
||||
.select(
|
||||
(value) =>
|
||||
value!.homeserverUrl!,
|
||||
),
|
||||
),
|
||||
)) {
|
||||
final url? => ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(
|
||||
Size.square(500),
|
||||
),
|
||||
child: switch (event.content) {
|
||||
VideoMessageContent(
|
||||
:final info,
|
||||
) =>
|
||||
VideoPlayer(url, info),
|
||||
AudioMessageContent(
|
||||
:final info,
|
||||
) =>
|
||||
AudioPlayer(url, info),
|
||||
FileMessageContent(
|
||||
:final info,
|
||||
:final filename,
|
||||
) =>
|
||||
FileCard(
|
||||
url,
|
||||
info,
|
||||
filename: filename,
|
||||
),
|
||||
ImageMessageContent(:final info) => ExpandableImage(
|
||||
url.toString(),
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
child: Image(
|
||||
image: CachedNetworkImage(
|
||||
url.toString(),
|
||||
ref.watch(
|
||||
CrossCacheController
|
||||
.provider,
|
||||
),
|
||||
headers: ref.headers,
|
||||
),
|
||||
width: info?.width,
|
||||
loadingBuilder:
|
||||
(
|
||||
_,
|
||||
child,
|
||||
loadingProgress,
|
||||
) => loadingProgress == null
|
||||
? child
|
||||
: switch (info?.blurHash) {
|
||||
final blurHash? => SizedBox(
|
||||
width:
|
||||
info?.width ??
|
||||
info?.height ??
|
||||
200,
|
||||
height:
|
||||
info?.height ??
|
||||
info?.width ??
|
||||
200,
|
||||
child: BlurHash(
|
||||
hash: blurHash,
|
||||
),
|
||||
),
|
||||
_ => Loading(),
|
||||
},
|
||||
errorBuilder:
|
||||
(
|
||||
context,
|
||||
error,
|
||||
stackTrace,
|
||||
) => Center(
|
||||
child: Text(
|
||||
"Image Failed to Load",
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_ => SizedBox.shrink(),
|
||||
},
|
||||
),
|
||||
_ => Text(
|
||||
"Nexus currently cannot handle encrypted media",
|
||||
style: errorStyle,
|
||||
),
|
||||
},
|
||||
|
||||
if (event.lastEditRowId != 0)
|
||||
Text(
|
||||
"(edited)",
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
|
||||
if (linkify(body).firstWhereOrNull(
|
||||
(element) => element is UrlElement,
|
||||
)
|
||||
case final UrlElement link?)
|
||||
UrlPreview(link.url),
|
||||
|
||||
SizedBox(height: 4),
|
||||
ReactionRow(event),
|
||||
],
|
||||
],
|
||||
),
|
||||
MessageContent(:final body) => Row(
|
||||
spacing: 8,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"Unknown message type:",
|
||||
style: errorStyle,
|
||||
),
|
||||
Text(body),
|
||||
],
|
||||
),
|
||||
_ => throw Exception("This is impossible"),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MembershipContent content =>
|
||||
event.previousContent is MembershipContent &&
|
||||
(event.previousContent as MembershipContent).status ==
|
||||
content.status
|
||||
? null
|
||||
: MembershipRenderer(event),
|
||||
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,
|
||||
};
|
||||
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/widgets/renderers/generic_event.dart
Normal file
22
lib/widgets/renderers/generic_event.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
class GenericEventRenderer extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final List<Widget> 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
57
lib/widgets/renderers/membership.dart
Normal file
57
lib/widgets/renderers/membership.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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";
|
||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||
import "package:nexus/widgets/renderers/generic_event.dart";
|
||||
|
||||
class MembershipRenderer extends StatelessWidget {
|
||||
final Event event;
|
||||
const MembershipRenderer(this.event, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(
|
||||
event.content is MembershipContent,
|
||||
"Make sure to only pass membership events to MembershipRenderer",
|
||||
);
|
||||
|
||||
return switch (event.content) {
|
||||
MembershipContent content => GenericEventRenderer(Icons.people, [
|
||||
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}\""),
|
||||
]),
|
||||
_ => SizedBox.shrink(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/selected_room_controller.dart";
|
||||
import "package:nexus/controllers/client_state_controller.dart";
|
||||
import "package:nexus/controllers/rooms_controller.dart";
|
||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
||||
import "package:nexus/widgets/appbar.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/chat_page/expandable_image.dart";
|
||||
import "package:nexus/widgets/chat_page/room_menu.dart";
|
||||
import "package:nexus/widgets/expandable_image.dart";
|
||||
import "package:nexus/widgets/room_menu.dart";
|
||||
|
||||
class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final bool isDesktop;
|
||||
final void Function(BuildContext context)? onOpenMemberList;
|
||||
final void Function(BuildContext context) onOpenDrawer;
|
||||
final String? roomId;
|
||||
const RoomAppbar({
|
||||
required this.roomId,
|
||||
required this.isDesktop,
|
||||
required this.onOpenDrawer,
|
||||
this.onOpenMemberList,
|
||||
|
|
@ -23,13 +27,23 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final room = ref.watch(SelectedRoomController.provider);
|
||||
final room = roomId == null
|
||||
? null
|
||||
: ref.watch(RoomsController.provider.select((value) => value[roomId!]));
|
||||
return Appbar(
|
||||
leading: isDesktop
|
||||
? room == null
|
||||
? null
|
||||
: ExpandableImage(
|
||||
room.metadata?.avatar?.toString(),
|
||||
room.metadata?.avatar
|
||||
?.mxcToHttps(
|
||||
ref.watch(
|
||||
ClientStateController.provider.select(
|
||||
(value) => value!.homeserverUrl!,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toString(),
|
||||
child: AvatarOrHash(
|
||||
room.metadata?.avatar,
|
||||
room.metadata?.name ?? "Unnamed Rooms",
|
||||
448
lib/widgets/room_chat.dart
Normal file
448
lib/widgets/room_chat.dart
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.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/rooms_controller.dart";
|
||||
import "package:nexus/controllers/room_chat_controller.dart";
|
||||
import "package:nexus/controllers/via_controller.dart";
|
||||
import "package:nexus/models/configs/power_level_config.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/models/requests/report_request.dart";
|
||||
import "package:nexus/widgets/composer/chat_box.dart";
|
||||
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/flash_wrapper.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/form_text_input.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
|
||||
class RoomChat extends HookConsumerWidget {
|
||||
final bool isDesktop;
|
||||
final bool showMembersByDefault;
|
||||
final String? roomId;
|
||||
const RoomChat({
|
||||
required this.roomId,
|
||||
required this.isDesktop,
|
||||
required this.showMembersByDefault,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final relatedEvent = useState<Event?>(null);
|
||||
final relationType = useState(RelationType.reply);
|
||||
final flashingEvent = useState<String?>(null);
|
||||
|
||||
final memberListOpened = useState<bool>(showMembersByDefault);
|
||||
|
||||
final userId = ref.watch(ClientStateController.provider)?.userId;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (userId == null || this.roomId == null) {
|
||||
return Scaffold(
|
||||
appBar: RoomAppbar(
|
||||
roomId: this.roomId,
|
||||
isDesktop: isDesktop,
|
||||
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
|
||||
onOpenMemberList: null,
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
"Nothing to see here...",
|
||||
style: theme.textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final roomId = this.roomId!;
|
||||
|
||||
final controllerProvider = RoomChatController.provider(roomId);
|
||||
final notifier = ref.watch(controllerProvider.notifier);
|
||||
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
|
||||
final listController = useRef(ListController());
|
||||
final scrollController = useScrollController();
|
||||
|
||||
useEffect(() {
|
||||
Future<void> listener() async {
|
||||
if (!scrollController.position.atEdge) return;
|
||||
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[roomId]),
|
||||
);
|
||||
if (room == null) return;
|
||||
|
||||
if (scrollController.position.pixels == 0) {
|
||||
await client.markRead(room);
|
||||
} else {
|
||||
if (room.hasMore) await notifier.loadOlder();
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(listener);
|
||||
return () => scrollController.removeListener(listener);
|
||||
}, [roomId]);
|
||||
|
||||
final composerNode = useFocusNode(
|
||||
onKeyEvent: (_, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
relatedEvent.value = null;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
);
|
||||
|
||||
IList<PopupMenuEntry> getEventOptions(Event event) {
|
||||
final danger = theme.colorScheme.error;
|
||||
final isSentByMe = event.sender == userId;
|
||||
return [
|
||||
if (ref.watch(
|
||||
PowerLevelController.provider(
|
||||
PowerLevelConfig(eventType: EventType.reaction, roomId: roomId),
|
||||
),
|
||||
))
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
child: IconTheme(
|
||||
data: theme.iconTheme,
|
||||
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, event)
|
||||
.onError(showError);
|
||||
},
|
||||
icon: Text(emoji),
|
||||
),
|
||||
),
|
||||
EmojiPickerButton(
|
||||
context: context,
|
||||
onPressed: Navigator.of(context).pop,
|
||||
onSelection: (emoji) =>
|
||||
notifier.sendReaction(emoji, event).onError(showError),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (ref.watch(
|
||||
PowerLevelController.provider(
|
||||
PowerLevelConfig(eventType: EventType.message, roomId: roomId),
|
||||
),
|
||||
))
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
relatedEvent.value = event;
|
||||
relationType.value = RelationType.reply;
|
||||
composerNode.requestFocus();
|
||||
},
|
||||
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
|
||||
),
|
||||
if (event.content is MessageContent && isSentByMe)
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
relatedEvent.value = event;
|
||||
relationType.value = RelationType.edit;
|
||||
composerNode.requestFocus();
|
||||
},
|
||||
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () async {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[roomId]),
|
||||
);
|
||||
if (room == null) return;
|
||||
|
||||
final vias = ref.watch(ViaController.provider(room));
|
||||
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text:
|
||||
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias)",
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
|
||||
),
|
||||
if (ref.watch(
|
||||
PowerLevelController.provider(
|
||||
PowerLevelConfig.redaction(
|
||||
targetUser: event.sender,
|
||||
roomId: roomId,
|
||||
),
|
||||
),
|
||||
))
|
||||
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(
|
||||
event,
|
||||
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: event.eventId,
|
||||
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)),
|
||||
),
|
||||
),
|
||||
].toIList();
|
||||
}
|
||||
|
||||
final controllerData = ref.watch(controllerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: RoomAppbar(
|
||||
roomId: roomId,
|
||||
isDesktop: isDesktop,
|
||||
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
|
||||
onOpenMemberList: (thisContext) {
|
||||
memberListOpened.value = !memberListOpened.value;
|
||||
Scaffold.of(thisContext).openEndDrawer();
|
||||
},
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: switch (controllerData) {
|
||||
AsyncData(:final value) ||
|
||||
AsyncLoading(:final value?) => CustomScrollView(
|
||||
reverse: true,
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: EdgeInsetsGeometry.only(bottom: 64),
|
||||
),
|
||||
|
||||
SuperSliverList.builder(
|
||||
listController: listController.value,
|
||||
itemCount: value.length,
|
||||
itemBuilder: (_, index) {
|
||||
final event = value[index];
|
||||
final previousEvent = value.getOrNull(index + 1);
|
||||
return FlashWrapper(
|
||||
EventRenderer(
|
||||
event,
|
||||
onTapReply: () async {
|
||||
final replyId = event.replyTo;
|
||||
listController.value.animateToItem(
|
||||
index: value.indexWhere(
|
||||
(element) => element.eventId == replyId,
|
||||
),
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration: (_) =>
|
||||
Duration(milliseconds: 700),
|
||||
curve: (_) => Curves.easeInOut,
|
||||
);
|
||||
flashingEvent.value = replyId;
|
||||
await Future.delayed(
|
||||
Duration(seconds: 1),
|
||||
() {
|
||||
if (flashingEvent.value == replyId) {
|
||||
flashingEvent.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
getEventOptions: getEventOptions,
|
||||
isGrouped:
|
||||
previousEvent?.content
|
||||
is MessageContent &&
|
||||
event.redactedBy == null &&
|
||||
event.relationType != "m.replace" &&
|
||||
"${event.sender}${event.pmp?.id}" ==
|
||||
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
|
||||
),
|
||||
isFlashing:
|
||||
flashingEvent.value == event.eventId,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
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(),
|
||||
AsyncError(:final error, :final stackTrace) =>
|
||||
ErrorDialog(error, stackTrace),
|
||||
},
|
||||
),
|
||||
),
|
||||
ChatBox(
|
||||
roomId,
|
||||
node: composerNode,
|
||||
onSend: (text, {required shouldMention, required tags}) =>
|
||||
notifier
|
||||
.send(
|
||||
text,
|
||||
tags: tags,
|
||||
relationType: relationType.value,
|
||||
shouldMention: shouldMention,
|
||||
relation: relatedEvent.value,
|
||||
)
|
||||
.onError(showError),
|
||||
relationType: relationType.value,
|
||||
relatedEvent: relatedEvent.value,
|
||||
onDismiss: () => relatedEvent.value = null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (memberListOpened.value == true && showMembersByDefault)
|
||||
MemberList(roomId),
|
||||
],
|
||||
),
|
||||
|
||||
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/key_controller.dart";
|
||||
import "package:nexus/controllers/selected_space_controller.dart";
|
||||
import "package:nexus/controllers/spaces_controller.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/chat_page/join_dialog.dart";
|
||||
import "package:nexus/widgets/chat_page/room_menu.dart";
|
||||
import "package:nexus/widgets/join_dialog.dart";
|
||||
import "package:nexus/widgets/room_menu.dart";
|
||||
|
||||
class Sidebar extends HookConsumerWidget {
|
||||
final bool isDesktop;
|
||||
|
|
@ -31,7 +31,9 @@ class Sidebar extends HookConsumerWidget {
|
|||
);
|
||||
final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected;
|
||||
|
||||
final selectedSpace = ref.watch(SelectedSpaceController.provider);
|
||||
final selectedSpace =
|
||||
spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ??
|
||||
spaces.first;
|
||||
|
||||
final indexOfSelectedRoom = selectedSpace.children.indexWhere(
|
||||
(room) => room.metadata?.id == selectedRoomId,
|
||||
69
lib/widgets/url_preview.dart
Normal file
69
lib/widgets/url_preview.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import "package:cross_cache/cross_cache.dart";
|
||||
import "package:flutter/material.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";
|
||||
|
||||
class UrlPreview extends ConsumerWidget {
|
||||
final String link;
|
||||
const UrlPreview(this.link, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(Size.fromWidth(400)),
|
||||
child: ref
|
||||
.watch(UrlPreviewController.provider(link))
|
||||
.betterWhen(
|
||||
data: (preview) => preview == null
|
||||
? SizedBox.shrink()
|
||||
: InkWell(
|
||||
onTap: () => ref
|
||||
.watch(LaunchHelper.provider)
|
||||
.launchUrl(Uri.parse(link)),
|
||||
child: Card(
|
||||
margin: EdgeInsets.symmetric(vertical: 4),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: Padding(
|
||||
padding: EdgeInsetsGeometry.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (preview.title != null)
|
||||
Text(
|
||||
preview.title!,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (preview.description != null) ...[
|
||||
Text(preview.description!),
|
||||
SizedBox(height: 4),
|
||||
],
|
||||
if (preview.imageUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
child: Image(
|
||||
errorBuilder: (_, _, _) => SizedBox.shrink(),
|
||||
width: preview.width,
|
||||
image: CachedNetworkImage(
|
||||
preview.imageUrl.toString(),
|
||||
ref.watch(CrossCacheController.provider),
|
||||
headers: ref.headers,
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -6,30 +6,30 @@ 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/profile_controller.dart";
|
||||
import "package:nexus/controllers/selected_room_controller.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
||||
import "package:nexus/models/configs/power_level_config.dart";
|
||||
import "package:nexus/models/membership.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/membership_status.dart";
|
||||
import "package:nexus/models/requests/membership_action.dart";
|
||||
import "package:nexus/models/requests/set_membership_request.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/chat_page/expandable_image.dart";
|
||||
import "package:nexus/widgets/expandable_image.dart";
|
||||
import "package:nexus/widgets/form_text_input.dart";
|
||||
|
||||
class UserPopover extends ConsumerWidget {
|
||||
final Membership member;
|
||||
const UserPopover(this.member, {super.key});
|
||||
final MembershipContent member;
|
||||
final String userId;
|
||||
final String? roomId;
|
||||
const UserPopover(this.member, this.userId, {this.roomId, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
final roomId = ref.watch(
|
||||
SelectedRoomController.provider.select((room) => room?.metadata?.id),
|
||||
);
|
||||
|
||||
void showMembershipDialog(MembershipAction action) => showDialog(
|
||||
context: context,
|
||||
|
|
@ -37,16 +37,12 @@ class UserPopover extends ConsumerWidget {
|
|||
builder: (context) {
|
||||
final actionReasonController = useTextEditingController();
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
"${toBeginningOfSentenceCase(action.name)} ${member.userId}",
|
||||
),
|
||||
title: Text("${toBeginningOfSentenceCase(action.name)} $userId"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Are you sure you want to ${action.name} ${member.userId}?",
|
||||
),
|
||||
Text("Are you sure you want to ${action.name} $userId?"),
|
||||
SizedBox(height: 12),
|
||||
FormTextInput(
|
||||
required: false,
|
||||
|
|
@ -67,7 +63,7 @@ class UserPopover extends ConsumerWidget {
|
|||
client
|
||||
.setMembership(
|
||||
SetMembershipRequest(
|
||||
userId: member.userId,
|
||||
userId: userId,
|
||||
roomId: roomId!,
|
||||
action: action,
|
||||
reason: actionReasonController.text,
|
||||
|
|
@ -93,10 +89,18 @@ class UserPopover extends ConsumerWidget {
|
|||
runSpacing: 8,
|
||||
children: [
|
||||
ExpandableImage(
|
||||
member.avatarUrl?.toString(),
|
||||
member.avatarUrl
|
||||
?.mxcToHttps(
|
||||
ref.watch(
|
||||
ClientStateController.provider.select(
|
||||
(value) => value!.homeserverUrl!,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toString(),
|
||||
child: AvatarOrHash(
|
||||
member.avatarUrl,
|
||||
member.displayName,
|
||||
member.displayName ?? userId.localpart,
|
||||
height: 80,
|
||||
),
|
||||
),
|
||||
|
|
@ -104,13 +108,13 @@ class UserPopover extends ConsumerWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
member.displayName,
|
||||
member.displayName ?? userId.localpart,
|
||||
style: textTheme.headlineSmall,
|
||||
),
|
||||
SelectableText(member.userId, style: textTheme.titleSmall),
|
||||
SelectableText(userId, style: textTheme.titleSmall),
|
||||
SizedBox(height: 4),
|
||||
ref
|
||||
.watch(ProfileController.provider(member.userId))
|
||||
.watch(ProfileController.provider(userId))
|
||||
.betterWhen(
|
||||
loading: SizedBox.shrink,
|
||||
data: (profile) => Wrap(
|
||||
|
|
@ -145,8 +149,7 @@ class UserPopover extends ConsumerWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
if (member.userId !=
|
||||
ref.watch(ClientStateController.provider)?.userId &&
|
||||
if (userId != ref.watch(ClientStateController.provider)?.userId &&
|
||||
roomId != null)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
|
|
@ -156,11 +159,10 @@ class UserPopover extends ConsumerWidget {
|
|||
|
||||
if (ref.watch(
|
||||
PowerLevelController.provider(
|
||||
PowerLevelConfig(
|
||||
eventType: "m.room.member",
|
||||
PowerLevelConfig.membershipAction(
|
||||
action: MembershipAction.kick,
|
||||
isStateEvent: true,
|
||||
targetUser: member.userId,
|
||||
roomId: roomId!,
|
||||
targetUser: userId,
|
||||
),
|
||||
),
|
||||
) &&
|
||||
|
|
@ -180,11 +182,10 @@ class UserPopover extends ConsumerWidget {
|
|||
),
|
||||
if (ref.watch(
|
||||
PowerLevelController.provider(
|
||||
PowerLevelConfig(
|
||||
eventType: "m.room.member",
|
||||
PowerLevelConfig.membershipAction(
|
||||
roomId: roomId!,
|
||||
action: MembershipAction.ban,
|
||||
isStateEvent: true,
|
||||
targetUser: member.userId,
|
||||
targetUser: userId,
|
||||
),
|
||||
),
|
||||
))
|
||||
Loading…
Add table
Add a link
Reference in a new issue