Files
App/lib/screens/chat/room.dart
2025-09-23 16:56:02 +08:00

828 lines
32 KiB
Dart

import "dart:async";
import "dart:convert";
import "package:easy_localization/easy_localization.dart";
import "package:file_picker/file_picker.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/models/file.dart";
import "package:island/pods/messages_notifier.dart";
import "package:island/pods/network.dart";
import "package:island/pods/websocket.dart";
import "package:island/screens/chat/chat.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/call_overlay.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:island/widgets/chat/call_button.dart";
import "package:island/widgets/chat/chat_input.dart";
import "package:island/widgets/chat/public_room_preview.dart";
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
final controller = StreamController<AppLifecycleState>();
final observer = _AppLifecycleObserver((state) {
if (controller.isClosed) return;
controller.add(state);
});
WidgetsBinding.instance.addObserver(observer);
ref.onDispose(() {
WidgetsBinding.instance.removeObserver(observer);
controller.close();
});
return controller.stream;
});
class _AppLifecycleObserver extends WidgetsBindingObserver {
final ValueChanged<AppLifecycleState> onChange;
_AppLifecycleObserver(this.onChange);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onChange(state);
}
}
class ChatRoomScreen extends HookConsumerWidget {
final String id;
const ChatRoomScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chatRoom = ref.watch(chatroomProvider(id));
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
final isSyncing = ref.watch(isSyncingProvider);
if (chatIdentity.isLoading || chatRoom.isLoading) {
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: CircularProgressIndicator().center(),
);
} else if (chatIdentity.value == null) {
// Identity was not found, user was not joined
return chatRoom.when(
data: (room) {
if (room!.isPublic) {
// Show public room preview with messages but no input
return PublicRoomPreview(id: id, room: room);
} else {
// Show regular "not joined" screen for private rooms
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: Center(
child:
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
room.isCommunity == true
? Symbols.person_add
: Symbols.person_remove,
size: 36,
fill: 1,
).padding(bottom: 4),
Text('chatNotJoined').tr(),
if (room.isCommunity != true)
Text(
'chatUnableJoin',
textAlign: TextAlign.center,
).tr().bold()
else
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),
).padding(top: 8),
],
),
).center(),
),
);
}
},
loading:
() => AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: CircularProgressIndicator().center(),
),
error:
(error, _) => AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: ResponseErrorWidget(
error: error,
onRetry: () => ref.refresh(chatroomProvider(id)),
),
),
);
}
final messages = ref.watch(messagesNotifierProvider(id));
final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier);
final ws = ref.watch(websocketProvider);
final messageController = useTextEditingController();
final scrollController = useScrollController();
final messageReplyingTo = useState<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(null);
final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double>>>({});
// Function to send read receipt
void sendReadReceipt() async {
// Send websocket packet
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.read',
data: {'chat_room_id': id},
endpoint: 'DysonNetwork.Sphere',
),
),
);
}
// Members who are typing
final typingStatuses = useState<List<SnChatMember>>([]);
final typingDebouncer = useState<Timer?>(null);
void sendTypingStatus() {
// Don't send if we're already in a cooldown period
if (typingDebouncer.value != null) return;
// Send typing status immediately
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.typing',
data: {'chat_room_id': id},
endpoint: 'DysonNetwork.Sphere',
),
),
);
typingDebouncer.value = Timer(const Duration(milliseconds: 850), () {
typingDebouncer.value = null;
});
}
// Add timer to remove typing status after inactivity
useEffect(() {
final removeTypingTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (typingStatuses.value.isNotEmpty) {
// Remove typing statuses older than 5 seconds
final now = DateTime.now();
typingStatuses.value =
typingStatuses.value.where((member) {
final lastTyped =
member.lastTyped ??
DateTime.now().subtract(const Duration(milliseconds: 1350));
return now.difference(lastTyped).inSeconds < 5;
}).toList();
}
});
return () => removeTypingTimer.cancel();
}, []);
var isLoading = false;
final listController = useMemoized(() => ListController(), []);
// 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]);
// Add websocket listener for new messages
useEffect(() {
void onMessage(WebSocketPacket pkt) {
if (!pkt.type.startsWith('messages')) return;
if (['messages.read'].contains(pkt.type)) return;
if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) {
if (pkt.data?['room_id'] != chatRoom.value?.id) return;
if (pkt.data?['sender_id'] == chatIdentity.value?.id) return;
final sender = SnChatMember.fromJson(
pkt.data?['sender'],
).copyWith(lastTyped: DateTime.now());
// Check if the sender is already in the typing list
final existingIndex = typingStatuses.value.indexWhere(
(member) => member.id == sender.id,
);
if (existingIndex >= 0) {
// Update the existing entry with new timestamp
final updatedList = [...typingStatuses.value];
updatedList[existingIndex] = sender;
typingStatuses.value = updatedList;
} else {
// Add new typing status
typingStatuses.value = [...typingStatuses.value, sender];
}
return;
}
final message = SnChatMessage.fromJson(pkt.data!);
if (message.chatRoomId != chatRoom.value?.id) return;
switch (pkt.type) {
case 'messages.new':
if (message.type.startsWith('call')) {
// Handle the ongoing call.
ref.invalidate(ongoingCallProvider(message.chatRoomId));
}
messagesNotifier.receiveMessage(message);
// Send read receipt for new message
sendReadReceipt();
case 'messages.update':
messagesNotifier.receiveMessageUpdate(message).then((_) {
messagesNotifier.receiveMessage(message);
});
case 'messages.delete':
messagesNotifier.receiveMessageDeletion(message.id).then((_) {
messagesNotifier.receiveMessage(message);
});
}
}
sendReadReceipt();
final subscription = ws.dataStream.listen(onMessage);
return () => subscription.cancel();
}, [ws, chatRoom]);
useEffect(() {
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': id},
),
),
);
return () {
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': id},
),
),
);
};
}, [id]);
Future<void> pickPhotoMedia() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.image),
),
];
}
Future<void> pickVideoMedia() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.video,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.video),
),
];
}
void sendMessage() {
if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty) {
messagesNotifier
.sendMessage(
messageController.text.trim(),
attachments.value,
editingTo: messageEditingTo.value,
forwardingTo: messageForwardingTo.value,
replyingTo: messageReplyingTo.value,
onProgress: (messageId, progress) {
attachmentProgress.value = {
...attachmentProgress.value,
messageId: progress,
};
},
)
.then((_) => sendReadReceipt());
messageController.clear();
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
attachments.value = [];
}
}
// Add listener to message controller for typing status
useEffect(() {
void onTextChange() {
if (messageController.text.isNotEmpty) {
sendTypingStatus();
}
}
messageController.addListener(onTextChange);
return () => messageController.removeListener(onTextChange);
}, [messageController]);
final compactHeader = isWideScreen(context);
Widget comfortHeaderWidget(SnChatRoom? room) => 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(SnChatRoom? room) => 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),
],
);
const messageKeyPrefix = 'message-';
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).substring(
messageKeyPrefix.length,
);
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;
final key = ValueKey('$messageKeyPrefix${message.id}');
return chatIdentity.when(
skipError: true,
data:
(identity) => MessageItem(
key: key,
message: message,
isCurrentUser: identity?.id == message.senderId,
onAction: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(message.id);
case MessageItemAction.edit:
messageEditingTo.value = message.toRemoteMessage();
messageController.text =
messageEditingTo.value?.content ?? '';
attachments.value =
messageEditingTo.value!.attachments
.map((e) => UniversalFile.fromAttachment(e))
.toList();
case MessageItemAction.forward:
messageForwardingTo.value = message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value = message.toRemoteMessage();
}
},
onJump: (messageId) {
final messageIndex = messageList.indexWhere(
(m) => m.id == messageId,
);
if (messageIndex == -1) {
messagesNotifier.jumpToMessage(messageId).then((index) {
if (index != -1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
listController.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration:
(estimatedDistance) =>
Duration(milliseconds: 250),
curve: (estimatedDistance) => Curves.easeInOut,
);
});
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.union({messageId}));
}
});
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
listController.animateToItem(
index: messageIndex,
scrollController: scrollController,
alignment: 0.5,
duration:
(estimatedDistance) =>
Duration(milliseconds: 250),
curve: (estimatedDistance) => Curves.easeInOut,
);
});
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.union({messageId}));
},
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
),
loading:
() => MessageItem(
key: key,
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => SizedBox.shrink(key: key),
);
},
);
return AppScaffold(
appBar: AppBar(
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
automaticallyImplyLeading: false,
toolbarHeight: compactHeader ? null : 64,
title: chatRoom.when(
data:
(room) =>
compactHeader
? compactHeaderWidget(room)
: comfortHeaderWidget(room),
loading: () => const Text('Loading...'),
error:
(err, _) => ResponseErrorWidget(
error: err,
onRetry: () => messagesNotifier.loadInitial(),
),
),
actions: [
AudioCallButton(roomId: id),
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () async {
final result = await context.pushNamed(
'chatDetail',
pathParameters: {'id': id},
);
if (result is String && messages.valueOrNull != null) {
// Jump to the message that was selected in search
final messageList = messages.valueOrNull!;
final messageIndex = messageList.indexWhere(
(m) => m.id == result,
);
if (messageIndex == -1) {
messagesNotifier.jumpToMessage(result).then((index) {
if (index != -1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
listController.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration:
(estimatedDistance) =>
Duration(milliseconds: 250),
curve: (estimatedDistance) => Curves.easeInOut,
);
});
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.union({result}));
}
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
listController.animateToItem(
index: messageIndex,
scrollController: scrollController,
alignment: 0.5,
duration:
(estimatedDistance) => Duration(milliseconds: 250),
curve: (estimatedDistance) => Curves.easeInOut,
);
});
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.union({result}));
}
}
},
),
const Gap(8),
],
bottom:
isSyncing
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(
borderRadius: BorderRadius.zero,
),
)
: null,
),
body: Stack(
children: [
Column(
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(),
),
),
),
chatRoom.when(
data:
(room) => Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
switchInCurve: Curves.fastEaseInToSlowEaseOut,
switchOutCurve: Curves.fastEaseInToSlowEaseOut,
transitionBuilder: (
Widget child,
Animation<double> animation,
) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.3),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
),
),
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1.0,
child: FadeTransition(
opacity: animation,
child: child,
),
),
);
},
child:
typingStatuses.value.isNotEmpty
? Container(
key: const ValueKey('typing-indicator'),
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: Row(
children: [
const Icon(
Symbols.more_horiz,
size: 16,
).padding(horizontal: 8),
const Gap(8),
Expanded(
child: Text(
'typingHint'.plural(
typingStatuses.value.length,
args: [
typingStatuses.value
.map(
(x) =>
x.nick ??
x.account.nick,
)
.join(', '),
],
),
style:
Theme.of(
context,
).textTheme.bodySmall,
),
),
],
),
)
: const SizedBox.shrink(
key: ValueKey('typing-indicator-none'),
),
),
ChatInput(
messageController: messageController,
chatRoom: room!,
onSend: sendMessage,
onClear: () {
if (messageEditingTo.value != null) {
attachments.value.clear();
messageController.clear();
}
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
},
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
onPickFile: (bool isPhoto) {
if (isPhoto) {
pickPhotoMedia();
} else {
pickVideoMedia();
}
},
attachments: attachments.value,
onUploadAttachment: (_) {
// not going to do anything, only upload when send the message
},
onDeleteAttachment: (index) async {
final attachment = attachments.value[index];
if (attachment.isOnCloud) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/drive/files/${attachment.data.id}',
);
}
final clone = List.of(attachments.value);
clone.removeAt(index);
attachments.value = clone;
},
onMoveAttachment: (idx, delta) {
if (idx + delta < 0 ||
idx + delta >= attachments.value.length) {
return;
}
final clone = List.of(attachments.value);
clone.insert(idx + delta, clone.removeAt(idx));
attachments.value = clone;
},
onAttachmentsChanged: (newAttachments) {
attachments.value = newAttachments;
},
),
],
),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
],
),
Positioned(
left: 0,
right: 0,
top: 0,
child: CallOverlayBar().padding(horizontal: 8, top: 12),
),
],
),
);
}
}