✨ Search and jump to message
This commit is contained in:
327
lib/widgets/chat/chat_input.dart
Normal file
327
lib/widgets/chat/chat_input.dart
Normal file
@@ -0,0 +1,327 @@
|
||||
import "dart:async";
|
||||
import "dart:io";
|
||||
import "package:easy_localization/easy_localization.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:gap/gap.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:image_picker/image_picker.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/models/file.dart";
|
||||
import "package:island/pods/config.dart";
|
||||
import "package:island/widgets/content/attachment_preview.dart";
|
||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||
import "package:pasteboard/pasteboard.dart";
|
||||
import "package:styled_widget/styled_widget.dart";
|
||||
import "package:material_symbols_icons/symbols.dart";
|
||||
import "package:island/widgets/stickers/picker.dart";
|
||||
|
||||
class ChatInput extends HookConsumerWidget {
|
||||
final TextEditingController messageController;
|
||||
final SnChatRoom chatRoom;
|
||||
final VoidCallback onSend;
|
||||
final VoidCallback onClear;
|
||||
final Function(bool isPhoto) onPickFile;
|
||||
final SnChatMessage? messageReplyingTo;
|
||||
final SnChatMessage? messageForwardingTo;
|
||||
final SnChatMessage? messageEditingTo;
|
||||
final List<UniversalFile> attachments;
|
||||
final Function(int) onUploadAttachment;
|
||||
final Function(int) onDeleteAttachment;
|
||||
final Function(int, int) onMoveAttachment;
|
||||
final Function(List<UniversalFile>) onAttachmentsChanged;
|
||||
|
||||
const ChatInput({
|
||||
super.key,
|
||||
required this.messageController,
|
||||
required this.chatRoom,
|
||||
required this.onSend,
|
||||
required this.onClear,
|
||||
required this.onPickFile,
|
||||
required this.messageReplyingTo,
|
||||
required this.messageForwardingTo,
|
||||
required this.messageEditingTo,
|
||||
required this.attachments,
|
||||
required this.onUploadAttachment,
|
||||
required this.onDeleteAttachment,
|
||||
required this.onMoveAttachment,
|
||||
required this.onAttachmentsChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final inputFocusNode = useFocusNode();
|
||||
|
||||
final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend;
|
||||
|
||||
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||
|
||||
void send() {
|
||||
onSend.call();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
inputFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> handlePaste() async {
|
||||
final clipboard = await Pasteboard.image;
|
||||
if (clipboard == null) return;
|
||||
|
||||
onAttachmentsChanged([
|
||||
...attachments,
|
||||
UniversalFile(
|
||||
data: XFile.fromData(clipboard, mimeType: "image/jpeg"),
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
void handleKeyPress(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
RawKeyEvent event,
|
||||
) {
|
||||
if (event is! RawKeyDownEvent) return;
|
||||
|
||||
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
|
||||
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
|
||||
|
||||
if (isPaste && isModifierPressed) {
|
||||
handlePaste();
|
||||
return;
|
||||
}
|
||||
|
||||
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
|
||||
final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
|
||||
|
||||
if (isEnter) {
|
||||
if (enterToSend && !isModifierPressed) {
|
||||
send();
|
||||
} else if (!enterToSend && isModifierPressed) {
|
||||
send();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Material(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
if (attachments.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 280,
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return SizedBox(
|
||||
height: 280,
|
||||
width: 280,
|
||||
child: AttachmentPreview(
|
||||
item: attachments[idx],
|
||||
onRequestUpload: () => onUploadAttachment(idx),
|
||||
onDelete: () => onDeleteAttachment(idx),
|
||||
onUpdate: (value) {
|
||||
attachments[idx] = value;
|
||||
onAttachmentsChanged(attachments);
|
||||
},
|
||||
onMove: (delta) => onMoveAttachment(idx, delta),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, _) => const Gap(8),
|
||||
),
|
||||
).padding(top: 12),
|
||||
if (messageReplyingTo != null ||
|
||||
messageForwardingTo != null ||
|
||||
messageEditingTo != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.only(left: 8, right: 8, top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
messageReplyingTo != null
|
||||
? Symbols.reply
|
||||
: messageForwardingTo != null
|
||||
? Symbols.forward
|
||||
: Symbols.edit,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
messageReplyingTo != null
|
||||
? 'Replying to ${messageReplyingTo?.sender.account.nick}'
|
||||
: messageForwardingTo != null
|
||||
? 'Forwarding message'
|
||||
: 'Editing message',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: onClear,
|
||||
padding: EdgeInsets.zero,
|
||||
style: ButtonStyle(
|
||||
minimumSize: WidgetStatePropertyAll(Size(28, 28)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'stickers'.tr(),
|
||||
icon: const Icon(Symbols.add_reaction),
|
||||
onPressed: () {
|
||||
final size = MediaQuery.of(context).size;
|
||||
showStickerPickerPopover(
|
||||
context,
|
||||
Offset(
|
||||
20,
|
||||
size.height -
|
||||
480 -
|
||||
MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
onPick: (placeholder) {
|
||||
// Insert placeholder at current cursor position
|
||||
final text = messageController.text;
|
||||
final selection = messageController.selection;
|
||||
final start =
|
||||
selection.start >= 0
|
||||
? selection.start
|
||||
: text.length;
|
||||
final end =
|
||||
selection.end >= 0
|
||||
? selection.end
|
||||
: text.length;
|
||||
final newText = text.replaceRange(
|
||||
start,
|
||||
end,
|
||||
placeholder,
|
||||
);
|
||||
messageController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: start + placeholder.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Symbols.photo_library),
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(true),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Symbols.photo),
|
||||
Text('addPhoto').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(false),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Symbols.video_call),
|
||||
Text('addVideo').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey: (event) => handleKeyPress(context, ref, event),
|
||||
child: TextField(
|
||||
focusNode: inputFocusNode,
|
||||
controller: messageController,
|
||||
onSubmitted:
|
||||
(enterToSend && isMobile)
|
||||
? (_) {
|
||||
send();
|
||||
}
|
||||
: null,
|
||||
keyboardType:
|
||||
(enterToSend && isMobile)
|
||||
? TextInputType.text
|
||||
: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.send,
|
||||
inputFormatters: [
|
||||
if (enterToSend && !isMobile)
|
||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||
if (newValue.text.endsWith('\n')) {
|
||||
return oldValue;
|
||||
}
|
||||
return newValue;
|
||||
}),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
(chatRoom.type == 1 && chatRoom.name == null)
|
||||
? 'chatDirectMessageHint'.tr(
|
||||
args: [
|
||||
chatRoom.members!
|
||||
.map((e) => e.account.nick)
|
||||
.join(', '),
|
||||
],
|
||||
)
|
||||
: 'chatMessageHint'.tr(args: [chatRoom.name!]),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
counterText:
|
||||
messageController.text.length > 1024
|
||||
? '${messageController.text.length}/4096'
|
||||
: null,
|
||||
),
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: send,
|
||||
),
|
||||
],
|
||||
).padding(bottom: MediaQuery.of(context).padding.bottom),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
169
lib/widgets/chat/message_content.dart
Normal file
169
lib/widgets/chat/message_content.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:pretty_diff_text/pretty_diff_text.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class MessageContent extends StatelessWidget {
|
||||
final SnChatMessage item;
|
||||
final String? translatedText;
|
||||
|
||||
const MessageContent({super.key, required this.item, this.translatedText});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (item.type) {
|
||||
case 'call.start':
|
||||
case 'call.ended':
|
||||
return _MessageContentCall(
|
||||
isEnded: item.type == 'call.ended',
|
||||
duration: item.meta['duration']?.toDouble(),
|
||||
);
|
||||
case 'messages.update':
|
||||
case 'messages.update.links':
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.edit,
|
||||
size: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const Gap(4),
|
||||
if (item.meta['previous_content'] is String)
|
||||
PrettyDiffText(
|
||||
oldText: item.meta['previous_content'],
|
||||
newText: item.content ?? 'Edited a message',
|
||||
defaultTextStyle: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
addedTextStyle: TextStyle(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryFixedDim.withOpacity(0.4),
|
||||
),
|
||||
deletedTextStyle: TextStyle(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
item.content ?? 'Edited a message',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
case 'messages.delete':
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.delete,
|
||||
size: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
item.content ?? 'Deleted a message',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MarkdownTextContent(
|
||||
content: item.content ?? '*${item.type} has no content*',
|
||||
isSelectable: true,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
if (translatedText?.isNotEmpty ?? false)
|
||||
...([
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: math.min(
|
||||
280,
|
||||
MediaQuery.of(context).size.width * 0.4,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('translated').tr().fontSize(11).opacity(0.75),
|
||||
const Gap(8),
|
||||
Flexible(child: Divider()),
|
||||
],
|
||||
).padding(vertical: 4),
|
||||
),
|
||||
MarkdownTextContent(
|
||||
content: translatedText!,
|
||||
isSelectable: true,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static bool hasContent(SnChatMessage item) {
|
||||
return item.type != 'text' || (item.content?.isNotEmpty ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageContentCall extends StatelessWidget {
|
||||
final bool isEnded;
|
||||
final double? duration;
|
||||
|
||||
const _MessageContentCall({required this.isEnded, this.duration});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isEnded ? Symbols.call_end : Symbols.phone_in_talk,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
isEnded
|
||||
? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}'
|
||||
: 'Call started',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
69
lib/widgets/chat/message_indicators.dart
Normal file
69
lib/widgets/chat/message_indicators.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class MessageIndicators extends StatelessWidget {
|
||||
final DateTime? editedAt;
|
||||
final MessageStatus? status;
|
||||
final bool isCurrentUser;
|
||||
final Color textColor;
|
||||
|
||||
const MessageIndicators({
|
||||
super.key,
|
||||
this.editedAt,
|
||||
this.status,
|
||||
required this.isCurrentUser,
|
||||
required this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: 4,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (editedAt != null)
|
||||
Text(
|
||||
'edited'.tr().toLowerCase(),
|
||||
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
|
||||
),
|
||||
if (isCurrentUser && status != null)
|
||||
_buildStatusIcon(
|
||||
context,
|
||||
status!,
|
||||
textColor.withOpacity(0.7),
|
||||
).padding(bottom: 3),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(
|
||||
BuildContext context,
|
||||
MessageStatus status,
|
||||
Color textColor,
|
||||
) {
|
||||
switch (status) {
|
||||
case MessageStatus.pending:
|
||||
return Icon(Icons.access_time, size: 12, color: textColor);
|
||||
case MessageStatus.sent:
|
||||
return Icon(Icons.check, size: 12, color: textColor);
|
||||
case MessageStatus.failed:
|
||||
return Consumer(
|
||||
builder:
|
||||
(context, ref, _) => GestureDetector(
|
||||
onTap: () {
|
||||
// This would need to be passed in or accessed differently
|
||||
// For now, just show the error icon
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.error_outline,
|
||||
size: 12,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -10,22 +10,19 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/models/embed.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/messages_notifier.dart';
|
||||
import 'package:island/pods/translate.dart';
|
||||
import 'package:island/screens/chat/room.dart';
|
||||
import 'package:island/utils/mapping.dart';
|
||||
import 'package:island/widgets/account/account_name.dart';
|
||||
import 'package:island/widgets/account/account_pfc.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/chat/message_content.dart';
|
||||
import 'package:island/widgets/chat/message_indicators.dart';
|
||||
import 'package:island/widgets/chat/message_sender_info.dart';
|
||||
import 'package:island/widgets/content/alert.native.dart';
|
||||
import 'package:island/widgets/content/cloud_file_collection.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/embed/link.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:pretty_diff_text/pretty_diff_text.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_context_menu/super_context_menu.dart';
|
||||
|
||||
@@ -228,62 +225,10 @@ class MessageItem extends HookConsumerWidget {
|
||||
children: [
|
||||
if (showAvatar) ...[
|
||||
const Gap(8),
|
||||
Row(
|
||||
spacing: 8,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AccountPfcGestureDetector(
|
||||
uname: sender.account.name,
|
||||
child: ProfilePictureWidget(
|
||||
fileId: sender.account.profile.picture?.id,
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(
|
||||
DateTime.now().difference(message.createdAt).inDays >
|
||||
365
|
||||
? DateFormat(
|
||||
'yyyy/MM/dd HH:mm',
|
||||
).format(message.createdAt.toLocal())
|
||||
: DateTime.now()
|
||||
.difference(message.createdAt)
|
||||
.inDays >
|
||||
0
|
||||
? DateFormat(
|
||||
'MM/dd HH:mm',
|
||||
).format(message.createdAt.toLocal())
|
||||
: DateFormat(
|
||||
'HH:mm',
|
||||
).format(message.createdAt.toLocal()),
|
||||
style: TextStyle(fontSize: 10, color: textColor),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 5,
|
||||
children: [
|
||||
AccountName(
|
||||
account: sender.account,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Badge(
|
||||
label:
|
||||
Text(
|
||||
sender.role >= 100
|
||||
? 'permissionOwner'
|
||||
: sender.role >= 50
|
||||
? 'permissionModerator'
|
||||
: 'permissionMember',
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
MessageSenderInfo(
|
||||
sender: sender,
|
||||
createdAt: message.createdAt,
|
||||
textColor: textColor,
|
||||
),
|
||||
const Gap(4),
|
||||
],
|
||||
@@ -319,8 +264,8 @@ class MessageItem extends HookConsumerWidget {
|
||||
textColor: textColor,
|
||||
isReply: false,
|
||||
).padding(vertical: 4),
|
||||
if (_MessageItemContent.hasContent(remoteMessage))
|
||||
_MessageItemContent(
|
||||
if (MessageContent.hasContent(remoteMessage))
|
||||
MessageContent(
|
||||
item: remoteMessage,
|
||||
translatedText: translatedText.value,
|
||||
),
|
||||
@@ -406,12 +351,11 @@ class MessageItem extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildMessageIndicators(
|
||||
context,
|
||||
textColor,
|
||||
remoteMessage,
|
||||
message,
|
||||
isCurrentUser,
|
||||
MessageIndicators(
|
||||
editedAt: remoteMessage.editedAt,
|
||||
status: message.status,
|
||||
isCurrentUser: isCurrentUser,
|
||||
textColor: textColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -421,61 +365,6 @@ class MessageItem extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageIndicators(
|
||||
BuildContext context,
|
||||
Color textColor,
|
||||
SnChatMessage remoteMessage,
|
||||
LocalChatMessage message,
|
||||
bool isCurrentUser,
|
||||
) {
|
||||
return Row(
|
||||
spacing: 4,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (remoteMessage.editedAt != null)
|
||||
Text(
|
||||
'edited'.tr().toLowerCase(),
|
||||
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
|
||||
),
|
||||
if (isCurrentUser)
|
||||
_buildStatusIcon(
|
||||
context,
|
||||
message.status,
|
||||
textColor.withOpacity(0.7),
|
||||
).padding(bottom: 3),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(
|
||||
BuildContext context,
|
||||
MessageStatus status,
|
||||
Color textColor,
|
||||
) {
|
||||
switch (status) {
|
||||
case MessageStatus.pending:
|
||||
return Icon(Icons.access_time, size: 12, color: textColor);
|
||||
case MessageStatus.sent:
|
||||
return Icon(Icons.check, size: 12, color: textColor);
|
||||
case MessageStatus.failed:
|
||||
return Consumer(
|
||||
builder:
|
||||
(context, ref, _) => GestureDetector(
|
||||
onTap: () {
|
||||
ref
|
||||
.read(messagesNotifierProvider(message.roomId).notifier)
|
||||
.retryMessage(message.id);
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.error_outline,
|
||||
size: 12,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MessageQuoteWidget extends HookConsumerWidget {
|
||||
@@ -552,8 +441,8 @@ class MessageQuoteWidget extends HookConsumerWidget {
|
||||
).textColor(textColor).bold(),
|
||||
],
|
||||
).padding(right: 8),
|
||||
if (_MessageItemContent.hasContent(remoteMessage))
|
||||
_MessageItemContent(item: remoteMessage),
|
||||
if (MessageContent.hasContent(remoteMessage))
|
||||
MessageContent(item: remoteMessage),
|
||||
if (remoteMessage.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -580,159 +469,3 @@ class MessageQuoteWidget extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageItemContent extends StatelessWidget {
|
||||
final SnChatMessage item;
|
||||
final String? translatedText;
|
||||
const _MessageItemContent({required this.item, this.translatedText});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (item.type) {
|
||||
case 'call.start':
|
||||
case 'call.ended':
|
||||
return _MessageContentCall(
|
||||
isEnded: item.type == 'call.ended',
|
||||
duration: item.meta['duration']?.toDouble(),
|
||||
);
|
||||
case 'messages.update':
|
||||
case 'messages.update.links':
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.edit,
|
||||
size: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const Gap(4),
|
||||
if (item.meta['previous_content'] is String)
|
||||
PrettyDiffText(
|
||||
oldText: item.meta['previous_content'],
|
||||
newText: item.content ?? 'Edited a message',
|
||||
defaultTextStyle: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
addedTextStyle: TextStyle(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryFixedDim.withOpacity(0.4),
|
||||
),
|
||||
deletedTextStyle: TextStyle(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
item.content ?? 'Edited a message',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
case 'messages.delete':
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.delete,
|
||||
size: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
item.content ?? 'Deleted a message',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MarkdownTextContent(
|
||||
content: item.content ?? '*${item.type} has no content*',
|
||||
isSelectable: true,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
if (translatedText?.isNotEmpty ?? false)
|
||||
...([
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: math.min(
|
||||
280,
|
||||
MediaQuery.of(context).size.width * 0.4,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('translated').tr().fontSize(11).opacity(0.75),
|
||||
const Gap(8),
|
||||
Flexible(child: Divider()),
|
||||
],
|
||||
).padding(vertical: 4),
|
||||
),
|
||||
MarkdownTextContent(
|
||||
content: translatedText!,
|
||||
isSelectable: true,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static bool hasContent(SnChatMessage item) {
|
||||
return item.type != 'text' || (item.content?.isNotEmpty ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageContentCall extends StatelessWidget {
|
||||
final bool isEnded;
|
||||
final double? duration;
|
||||
const _MessageContentCall({required this.isEnded, this.duration});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isEnded ? Symbols.call_end : Symbols.phone_in_talk,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
isEnded
|
||||
? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}'
|
||||
: 'Call started',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
87
lib/widgets/chat/message_list_tile.dart
Normal file
87
lib/widgets/chat/message_list_tile.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/models/embed.dart';
|
||||
import 'package:island/utils/mapping.dart';
|
||||
import 'package:island/widgets/chat/message_content.dart';
|
||||
import 'package:island/widgets/chat/message_sender_info.dart';
|
||||
import 'package:island/widgets/content/cloud_file_collection.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/embed/link.dart';
|
||||
|
||||
class MessageListTile extends StatelessWidget {
|
||||
final LocalChatMessage message;
|
||||
final Function(String messageId) onJump;
|
||||
|
||||
const MessageListTile({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onJump,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remoteMessage = message.toRemoteMessage();
|
||||
final sender = remoteMessage.sender;
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ProfilePictureWidget(
|
||||
fileId: sender.account.profile.picture?.id,
|
||||
radius: 20,
|
||||
),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MessageSenderInfo(
|
||||
sender: sender,
|
||||
createdAt: message.createdAt,
|
||||
textColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
MessageContent(item: remoteMessage),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (remoteMessage.attachments.isNotEmpty)
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return CloudFileList(
|
||||
files: remoteMessage.attachments,
|
||||
maxWidth: constraints.maxWidth,
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (remoteMessage.meta['embeds'] != null)
|
||||
...((remoteMessage.meta['embeds'] as List<dynamic>)
|
||||
.map((embed) => convertMapKeysToSnakeCase(embed))
|
||||
.where((embed) => embed['type'] == 'link')
|
||||
.map((embed) => SnScrappedLink.fromJson(embed))
|
||||
.map(
|
||||
(link) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return EmbedLinkWidget(
|
||||
link: link,
|
||||
maxWidth: math.min(constraints.maxWidth, 480),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList()),
|
||||
],
|
||||
),
|
||||
onTap: () => onJump(message.id),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
}
|
124
lib/widgets/chat/message_sender_info.dart
Normal file
124
lib/widgets/chat/message_sender_info.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/widgets/account/account_name.dart';
|
||||
import 'package:island/widgets/account/account_pfc.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
|
||||
class MessageSenderInfo extends StatelessWidget {
|
||||
final SnChatMember sender;
|
||||
final DateTime createdAt;
|
||||
final Color textColor;
|
||||
final bool compact;
|
||||
|
||||
const MessageSenderInfo({
|
||||
super.key,
|
||||
required this.sender,
|
||||
required this.createdAt,
|
||||
required this.textColor,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timestamp =
|
||||
DateTime.now().difference(createdAt).inDays > 365
|
||||
? DateFormat('yyyy/MM/dd HH:mm').format(createdAt.toLocal())
|
||||
: DateTime.now().difference(createdAt).inDays > 0
|
||||
? DateFormat('MM/dd HH:mm').format(createdAt.toLocal())
|
||||
: DateFormat('HH:mm').format(createdAt.toLocal());
|
||||
|
||||
if (compact) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (!compact)
|
||||
AccountPfcGestureDetector(
|
||||
uname: sender.account.name,
|
||||
child: ProfilePictureWidget(
|
||||
fileId: sender.account.profile.picture?.id,
|
||||
radius: 14,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AccountName(
|
||||
account: sender.account,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Badge(
|
||||
label:
|
||||
Text(
|
||||
sender.role >= 100
|
||||
? 'permissionOwner'
|
||||
: sender.role >= 50
|
||||
? 'permissionModerator'
|
||||
: 'permissionMember',
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
timestamp,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: textColor.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
spacing: 8,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AccountPfcGestureDetector(
|
||||
uname: sender.account.name,
|
||||
child: ProfilePictureWidget(
|
||||
fileId: sender.account.profile.picture?.id,
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(timestamp, style: TextStyle(fontSize: 10, color: textColor)),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 5,
|
||||
children: [
|
||||
AccountName(
|
||||
account: sender.account,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Badge(
|
||||
label:
|
||||
Text(
|
||||
sender.role >= 100
|
||||
? 'permissionOwner'
|
||||
: sender.role >= 50
|
||||
? 'permissionModerator'
|
||||
: 'permissionMember',
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
223
lib/widgets/chat/public_room_preview.dart
Normal file
223
lib/widgets/chat/public_room_preview.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
import "package:easy_localization/easy_localization.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:gap/gap.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:island/database/message.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/pods/messages_notifier.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/services/responsive.dart";
|
||||
import "package:island/widgets/alert.dart";
|
||||
import "package:island/widgets/app_scaffold.dart";
|
||||
import "package:island/widgets/chat/message_item.dart";
|
||||
import "package:island/widgets/content/cloud_files.dart";
|
||||
import "package:island/widgets/response.dart";
|
||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||
import "package:styled_widget/styled_widget.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
import "package:material_symbols_icons/symbols.dart";
|
||||
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
|
||||
class PublicRoomPreview extends HookConsumerWidget {
|
||||
final String id;
|
||||
final SnChatRoom room;
|
||||
|
||||
const PublicRoomPreview({super.key, required this.id, required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messages = ref.watch(messagesNotifierProvider(id));
|
||||
final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier);
|
||||
final scrollController = useScrollController();
|
||||
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
|
||||
var isLoading = false;
|
||||
|
||||
// Add scroll listener for pagination
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
if (scrollController.position.pixels >=
|
||||
scrollController.position.maxScrollExtent - 200) {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
messagesNotifier.loadMore().then((_) => isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [scrollController]);
|
||||
|
||||
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
|
||||
SuperListView.builder(
|
||||
listController: listController,
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
controller: scrollController,
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
findChildIndexCallback: (key) {
|
||||
final valueKey = key as ValueKey;
|
||||
final messageId = valueKey.value as String;
|
||||
return messageList.indexWhere((m) => m.id == messageId);
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
final nextMessage =
|
||||
index < messageList.length - 1 ? messageList[index + 1] : null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
return MessageItem(
|
||||
message: message,
|
||||
isCurrentUser: false, // User is not a member, so not current user
|
||||
onAction: null, // No actions allowed in preview mode
|
||||
onJump: (_) {}, // No jump functionality in preview
|
||||
progress: null,
|
||||
showAvatar: isLastInGroup,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final compactHeader = isWideScreen(context);
|
||||
|
||||
Widget comfortHeaderWidget() => Column(
|
||||
spacing: 4,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 26,
|
||||
width: 26,
|
||||
child:
|
||||
(room.type == 1 && room.picture?.id == null)
|
||||
? SplitAvatarWidget(
|
||||
filesId:
|
||||
room.members!
|
||||
.map((e) => e.account.profile.picture?.id)
|
||||
.toList(),
|
||||
)
|
||||
: room.picture?.id != null
|
||||
? ProfilePictureWidget(
|
||||
fileId: room.picture?.id,
|
||||
fallbackIcon: Symbols.chat,
|
||||
)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
room.name![0].toUpperCase(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? room.members!.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(15),
|
||||
],
|
||||
);
|
||||
|
||||
Widget compactHeaderWidget() => Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 26,
|
||||
width: 26,
|
||||
child:
|
||||
(room.type == 1 && room.picture?.id == null)
|
||||
? SplitAvatarWidget(
|
||||
filesId:
|
||||
room.members!
|
||||
.map((e) => e.account.profile.picture?.id)
|
||||
.toList(),
|
||||
)
|
||||
: room.picture?.id != null
|
||||
? ProfilePictureWidget(
|
||||
fileId: room.picture?.id,
|
||||
fallbackIcon: Symbols.chat,
|
||||
)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
room.name![0].toUpperCase(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? room.members!.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(19),
|
||||
],
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: compactHeader ? null : 64,
|
||||
title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
context.pushNamed('chatDetail', pathParameters: {'id': id});
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: messages.when(
|
||||
data:
|
||||
(messageList) =>
|
||||
messageList.isEmpty
|
||||
? Center(child: Text('No messages yet'.tr()))
|
||||
: chatMessageListWidget(messageList),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Join button at the bottom for public rooms
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.post('/sphere/chat/${room.id}/members/me');
|
||||
ref.invalidate(chatroomIdentityProvider(id));
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
label: Text('chatJoin').tr(),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user