This commit is contained in:
Henry Hiles 2025-12-07 16:31:03 -05:00
commit 63a9d2d169
No known key found for this signature in database
15 changed files with 388 additions and 299 deletions

View file

@ -1,13 +1,14 @@
import "dart:io";
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:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/controllers/room_chat_controller.dart";
class ChatBox extends HookWidget {
class ChatBox extends HookConsumerWidget {
final Message? replyToMessage;
final VoidCallback onDismiss;
final Room room;
@ -19,83 +20,142 @@ class ChatBox extends HookWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final controller = useRef(FlutterTaggerController());
final trigger = useState<String?>(null);
Future<void> send() => ref
.watch(RoomChatController.provider(room).notifier)
.send(controller.value.text);
final node = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter &&
!(Platform.isAndroid || Platform.isIOS) ^
HardwareKeyboard.instance.isShiftPressed) {
send();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
final style = TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
);
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FlutterTagger(
overlay: SizedBox(),
controller: controller.value,
onSearch: (query, triggerCharacter) {
triggerCharacter == "#";
if (controller.value.tags.isEmpty)
controller.value.addTag(id: "id", name: "name");
},
triggerCharacterAndStyles: {"@": style, "#": style},
builder: (context, key) => TextFormField(controller: controller.value, key: key,autofocus: true,onFieldSubmitted: (_) {
},)
// Composer(
// textEditingController: controller.value,
// key: key,
// sigmaY: 0,
// sendIconColor: theme.colorScheme.primary,
// sendOnEnter: true,
// topWidget: replyToMessage == null
// ? null
// : ColoredBox(
// color: theme.colorScheme.surfaceContainer,
// child: Padding(
// padding: EdgeInsets.symmetric(
// horizontal: 16,
// vertical: 4,
// ),
// child: Row(
// spacing: 8,
// children: [
// Avatar(
// userId: replyToMessage!.authorId,
// headers: room.client.headers,
// size: 16,
// ),
// Text(
// replyToMessage!.metadata?["displayName"] ??
// replyToMessage!.authorId,
// style: theme.textTheme.labelMedium?.copyWith(
// fontWeight: FontWeight.bold,
// ),
// ),
// Expanded(
// child: (replyToMessage is TextMessage)
// ? Text(
// (replyToMessage as TextMessage).text,
// overflow: TextOverflow.ellipsis,
// style: theme.textTheme.labelMedium,
// maxLines: 1,
// )
// : SizedBox(),
// ),
// IconButton(
// onPressed: onDismiss,
// icon: Icon(Icons.close),
// iconSize: 20,
// ),
// ],
// ),
// ),
// ),
// autofocus: true,
// ),
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Padding(
padding: EdgeInsetsGeometry.all(12),
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)),
child: Container(
color: theme.colorScheme.surfaceContainerHighest,
padding: EdgeInsets.symmetric(horizontal: 8),
child: // TODO: This doesn't work?
room.canSendDefaultMessages
? Row(
spacing: 8,
children: [
PopupMenuButton(
itemBuilder: (context) => [],
icon: Icon(Icons.add),
),
Expanded(
child: FlutterTagger(
overlay: SizedBox(),
controller: controller.value,
onSearch: (query, triggerCharacter) {
triggerCharacter == "#";
if (controller.value.tags.isEmpty) {
controller.value.addTag(
id: "id",
name: "name",
); // TODO: RM
}
},
triggerCharacterAndStyles: {
"@": style,
"#": style,
":": style,
},
builder: (context, key) => TextFormField(
maxLines: 12,
minLines: 1,
decoration: InputDecoration(
hintText: "Your message here...",
border: InputBorder.none,
),
controller: controller.value,
key: key,
autofocus: true,
focusNode: node,
),
),
),
IconButton(onPressed: send, icon: Icon(Icons.send)),
],
)
: Text("You don't have permission to send messages here..."),
// Composer(
// textEditingController: controller.value,
// key: key,
// sigmaY: 0,
// sendIconColor: theme.colorScheme.primary,
// sendOnEnter: true,
// topWidget: replyToMessage == null
// ? null
// : ColoredBox(
// color: theme.colorScheme.surfaceContainer,
// child: Padding(
// padding: EdgeInsets.symmetric(
// horizontal: 16,
// vertical: 4,
// ),
// child: Row(
// spacing: 8,
// children: [
// Avatar(
// userId: replyToMessage!.authorId,
// headers: room.client.headers,
// size: 16,
// ),
// Text(
// replyToMessage!.metadata?["displayName"] ??
// replyToMessage!.authorId,
// style: theme.textTheme.labelMedium?.copyWith(
// fontWeight: FontWeight.bold,
// ),
// ),
// Expanded(
// child: (replyToMessage is TextMessage)
// ? Text(
// (replyToMessage as TextMessage).text,
// overflow: TextOverflow.ellipsis,
// style: theme.textTheme.labelMedium,
// maxLines: 1,
// )
// : SizedBox(),
// ),
// IconButton(
// onPressed: onDismiss,
// icon: Icon(Icons.close),
// iconSize: 20,
// ),
// ],
// ),
// ),
// ),
// autofocus: true,
// ),
),
),
],
),
);
}
}

View file

@ -1,5 +1,6 @@
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
class SpoilerText extends HookWidget {
final String text;
@ -10,18 +11,20 @@ class SpoilerText extends HookWidget {
Widget build(BuildContext context) {
final revealed = useState(false);
return InkWell(
onTap: () => revealed.value = !revealed.value,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: revealed.value ? Colors.transparent : Colors.blueGrey,
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: TextStyle(color: revealed.value ? null : Colors.transparent),
return InlineCustomWidget(
child: InkWell(
onTap: () => revealed.value = !revealed.value,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: revealed.value ? Colors.transparent : Colors.blueGrey,
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: TextStyle(color: revealed.value ? null : Colors.transparent),
),
),
),
);

View file

@ -135,177 +135,197 @@ class RoomChat extends HookConsumerWidget {
body: Row(
children: [
Expanded(
child: ref
.watch(controllerProvider)
.betterWhen(
data: (controller) => Chat(
currentUserId: room.roomData.client.userID!,
theme: ChatTheme.fromThemeData(theme).copyWith(
colors: ChatColors.fromThemeData(theme).copyWith(
primary: theme.colorScheme.primaryContainer,
onPrimary: theme.colorScheme.onPrimaryContainer,
),
),
onMessageSecondaryTap:
(
context,
message, {
required details,
required index,
}) => 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: (_) => Loading(),
chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList(
itemBuilder: itemBuilder,
onEndReached: notifier.loadOlder,
onStartReached: notifier.markRead,
),
composerBuilder: (_) => ChatBox(
replyToMessage: replyToMessage.value,
onDismiss: () => replyToMessage.value = null,
room: room.roomData,
),
textMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatTextMessage(
customWidget: Html(
message.metadata?["formatted"]
.replaceAllMapped(
RegExp(
regexLink,
caseSensitive: false,
),
(m) =>
"<a href=\"${m.group(0)!}\">${m.group(0)!}</a>",
) +
((message.editedAt != null)
? "<sub edited>(edited)</sub>"
: ""),
client: room.roomData.client,
),
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
showTime: true,
index: index,
),
linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview(
text: message.text,
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: message.linkPreviewData,
onLinkPreviewDataFetched:
(linkPreviewData) =>
notifier.updateMessage(
message,
message.copyWith(
linkPreviewData:
linkPreviewData,
),
child: Column(
children: [
Expanded(
child: ref
.watch(controllerProvider)
.betterWhen(
data: (controller) => Chat(
currentUserId: room.roomData.client.userID!,
theme: ChatTheme.fromThemeData(theme)
.copyWith(
colors: ChatColors.fromThemeData(theme)
.copyWith(
primary: theme
.colorScheme
.primaryContainer,
onPrimary: theme
.colorScheme
.onPrimaryContainer,
),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
alwaysShow: true,
),
message: message,
index: index,
headers: room.roomData.client.headers,
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
onMessageSecondaryTap:
(
context,
message, {
required details,
required index,
}) => 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: (_) => Loading(),
chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList(
itemBuilder: itemBuilder,
onEndReached: notifier.loadOlder,
onStartReached: notifier.markRead,
bottomPadding: 72,
),
composerBuilder: (_) => ChatBox(
replyToMessage: replyToMessage.value,
onDismiss: () =>
replyToMessage.value = null,
room: room.roomData,
),
textMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatTextMessage(
customWidget: Html(
message.metadata?["formatted"]
.replaceAllMapped(
RegExp(
regexLink,
caseSensitive: false,
),
(m) =>
"<a href=\"${m.group(0)!}\">${m.group(0)!}</a>",
) +
((message.editedAt != null)
? "<sub edited>(edited)</sub>"
: ""),
client: room.roomData.client,
),
topWidget: TopWidget(
message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
showTime: true,
index: index,
),
linkPreviewBuilder:
(_, message, isSentByMe) => LinkPreview(
text: message.text,
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme
.colorScheme
.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData:
message.linkPreviewData,
onLinkPreviewDataFetched:
(linkPreviewData) =>
notifier.updateMessage(
message,
message.copyWith(
linkPreviewData:
linkPreviewData,
),
),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
topWidget: TopWidget(
message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus,
alwaysShow: true,
),
message: message,
index: index,
headers: room.roomData.client.headers,
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
topWidget: TopWidget(
message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
),
),
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),
),
),
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: notifier.resolveUser,
chatController: controller,
),
resolveUser: notifier.resolveUser,
chatController: controller,
),
),
),
],
),
),
if (memberListOpened.value == true && showMembersByDefault)
MemberList(room.roomData),
],
),
endDrawer: showMembersByDefault
? null
: MemberList(room.roomData),

View file

@ -60,7 +60,9 @@ class Sidebar extends HookConsumerWidget {
(space) => NavigationRailDestination(
icon: AvatarOrHash(
space.avatar,
fallback: space.icon,
fallback: space.icon == null
? null
: Icon(space.icon),
space.title,
headers: space.client.headers,
hasBadge:
@ -126,7 +128,9 @@ class Sidebar extends HookConsumerWidget {
appBar: AppBar(
leading: AvatarOrHash(
space.avatar,
fallback: space.icon,
fallback: space.icon == null
? null
: Icon(space.icon),
space.title,
headers: space.client.headers,
),

View file

@ -3,8 +3,10 @@ import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/chat_page/quoted.dart";
class TopWidget extends ConsumerWidget {
@ -62,11 +64,18 @@ class TopWidget extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(
userId: replyMessage.authorId,
headers: headers,
size: 16,
),
ref
.watch(
AvatarController.provider(replyMessage.authorId),
)
.betterWhen(
data: (avatar) => AvatarOrHash(
avatar,
replyMessage.metadata?["displayName"] ??
replyMessage.authorId,
headers: headers,
),
),
Flexible(
child: Text(
replyMessage.metadata?["displayName"] ??