forked from Nexus/nexus
602 lines
23 KiB
Dart
602 lines
23 KiB
Dart
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:measure_size/measure_size.dart";
|
|
import "package:nexus/controllers/account_data.dart";
|
|
import "package:nexus/controllers/client.dart";
|
|
import "package:nexus/controllers/client_state.dart";
|
|
import "package:nexus/controllers/member_list_opened.dart";
|
|
import "package:nexus/controllers/pinned_ids.dart";
|
|
import "package:nexus/controllers/power_level.dart";
|
|
import "package:nexus/controllers/rooms.dart";
|
|
import "package:nexus/controllers/room_chat.dart";
|
|
import "package:nexus/controllers/via.dart";
|
|
import "package:nexus/models/content/message.dart";
|
|
import "package:nexus/models/event.dart";
|
|
import "package:nexus/models/relation_type.dart";
|
|
import "package:nexus/widgets/composer/composer.dart";
|
|
import "package:nexus/widgets/emoji_picker_button.dart";
|
|
import "package:nexus/widgets/pinned_events_drawer.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/highlight_wrapper.dart";
|
|
import "package:nexus/widgets/error_dialog.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 highlightedEvent = useState<String?>(null);
|
|
|
|
final composerSize = useState<double>(64);
|
|
|
|
final userId = ref.watch(ClientStateController.provider)?.userId;
|
|
final memberListOpened = ref.watch(MemberListOpenedController.provider);
|
|
final theme = Theme.of(context);
|
|
|
|
final nothing = Center(
|
|
child: Text(
|
|
"Nothing to see here...",
|
|
style: theme.textTheme.headlineMedium,
|
|
),
|
|
);
|
|
if (userId == null || this.roomId == null) {
|
|
return Scaffold(
|
|
appBar: RoomAppbar(
|
|
roomId: this.roomId,
|
|
isDesktop: isDesktop,
|
|
onOpenDrawer: () => Scaffold.of(context).openDrawer(),
|
|
),
|
|
body: nothing,
|
|
);
|
|
}
|
|
|
|
final roomId = this.roomId!;
|
|
|
|
final controllerProvider = RoomChatController.provider(roomId);
|
|
final notifier = ref.watch(controllerProvider.notifier);
|
|
final client = ref.watch(ClientController.provider.notifier);
|
|
|
|
final scrollController = useScrollController();
|
|
final controllerData = ref.watch(controllerProvider);
|
|
final historyListController = useRef(ListController());
|
|
final liveListController = useRef(ListController());
|
|
final timelineCenterKey = useMemoized(GlobalKey.new);
|
|
final timelineBoundary = useRef<String?>(null);
|
|
final hasMore = ref.watch(
|
|
RoomsController.provider.select(
|
|
(rooms) => rooms[roomId]?.hasMore ?? false,
|
|
),
|
|
);
|
|
|
|
Future<void> jumpToId(String eventId) async {
|
|
final events = controllerData.value;
|
|
if (events == null || !scrollController.hasClients) return;
|
|
|
|
final sourceIndex = events.indexWhere(
|
|
(event) => event.eventId == eventId,
|
|
);
|
|
final boundaryIndex = events.indexWhere(
|
|
(event) => event.eventId == timelineBoundary.value,
|
|
);
|
|
if (sourceIndex < 0 || boundaryIndex < 0) return;
|
|
|
|
final listController = sourceIndex <= boundaryIndex
|
|
? historyListController.value
|
|
: liveListController.value;
|
|
if (!listController.isAttached) return;
|
|
listController.animateToItem(
|
|
index: sourceIndex <= boundaryIndex
|
|
? boundaryIndex - sourceIndex
|
|
: sourceIndex - boundaryIndex - 1,
|
|
scrollController: scrollController,
|
|
alignment: 0.5,
|
|
duration: (_) => .new(milliseconds: 700),
|
|
curve: (_) => Curves.easeInOut,
|
|
);
|
|
highlightedEvent.value = eventId;
|
|
await Future.delayed(.new(seconds: 1), () {
|
|
if (highlightedEvent.value == eventId) {
|
|
highlightedEvent.value = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
final composerNode = useFocusNode(
|
|
onKeyEvent: (_, event) {
|
|
if (event is KeyDownEvent && event.logicalKey == .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(
|
|
.new(eventType: .reaction, roomId: roomId),
|
|
),
|
|
))
|
|
PopupMenuItem(
|
|
enabled: false,
|
|
child: IconTheme(
|
|
data: theme.iconTheme,
|
|
child: Row(
|
|
children: [
|
|
...{
|
|
...ref.watch(
|
|
AccountDataController.provider.select(
|
|
(value) => value.recentEmoji
|
|
.map((entry) => entry.emoji)
|
|
.toIList(),
|
|
),
|
|
),
|
|
"👍",
|
|
"🤣",
|
|
"😭",
|
|
"🤔",
|
|
}
|
|
.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(
|
|
.new(eventType: .message, roomId: roomId),
|
|
),
|
|
))
|
|
PopupMenuItem(
|
|
onTap: () {
|
|
relatedEvent.value = event;
|
|
relationType.value = .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 = .edit;
|
|
composerNode.requestFocus();
|
|
},
|
|
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
|
|
),
|
|
if (ref.watch(
|
|
PowerLevelController.provider(
|
|
.state(eventType: .pinnedEvents, roomId: roomId),
|
|
),
|
|
))
|
|
switch (ref
|
|
.watch(PinnedIdsController.provider(roomId))
|
|
.contains(event.eventId)) {
|
|
bool isPinned => PopupMenuItem(
|
|
onTap: () async {
|
|
try {
|
|
final notifier = ref.read(
|
|
PinnedIdsController.provider(roomId).notifier,
|
|
);
|
|
if (isPinned) {
|
|
await notifier.removePin(event.eventId);
|
|
} else {
|
|
await notifier.addPin(event.eventId);
|
|
}
|
|
} catch (error, stackTrace) {
|
|
showError(error, stackTrace);
|
|
}
|
|
},
|
|
child: ListTile(
|
|
leading: Icon(Icons.push_pin),
|
|
title: Text(isPinned == true ? "Unpin Event" : "Pin Event"),
|
|
),
|
|
),
|
|
},
|
|
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(
|
|
.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: .min,
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(
|
|
"Are you sure you want to delete this message? This can not be reversed.",
|
|
),
|
|
SizedBox(height: 12),
|
|
TextField(
|
|
controller: deleteReasonController,
|
|
textCapitalization: .sentences,
|
|
decoration: .new(
|
|
labelText: "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: .new(color: danger)),
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
onTap: () => showDialog(
|
|
context: context,
|
|
builder: (context) => HookBuilder(
|
|
builder: (_) {
|
|
final reasonController = useTextEditingController();
|
|
return AlertDialog(
|
|
title: Text("Report"),
|
|
content: Column(
|
|
mainAxisSize: .min,
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(
|
|
"Report this event to your server administrators, who can take action like banning this server or room.",
|
|
),
|
|
|
|
SizedBox(height: 12),
|
|
TextField(
|
|
controller: reasonController,
|
|
textCapitalization: .sentences,
|
|
decoration: .new(
|
|
labelText: "Reason for report (optional)",
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: Navigator.of(context).pop,
|
|
child: Text("Cancel"),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
client.reportEvent(
|
|
.new(
|
|
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: .new(color: danger)),
|
|
),
|
|
),
|
|
].toIList();
|
|
}
|
|
|
|
return Scaffold(
|
|
endDrawer: PinnedEventsDrawer(
|
|
roomId,
|
|
getEventOptions: getEventOptions,
|
|
jumpToId: jumpToId,
|
|
),
|
|
body: Builder(
|
|
builder: (middleContext) => Scaffold(
|
|
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
|
appBar: RoomAppbar(
|
|
roomId: roomId,
|
|
isDesktop: isDesktop,
|
|
onOpenDrawer: Scaffold.of(context).openDrawer,
|
|
onOpenMemberList: (thisContext) {
|
|
ref
|
|
.watch(MemberListOpenedController.provider.notifier)
|
|
.set(!memberListOpened);
|
|
Scaffold.of(thisContext).openEndDrawer();
|
|
},
|
|
onOpenPinnedMessagesList: () {
|
|
Scaffold.of(middleContext).openEndDrawer();
|
|
},
|
|
),
|
|
body: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: Padding(
|
|
padding: .symmetric(horizontal: 4),
|
|
child: switch (controllerData) {
|
|
AsyncData(:final value?) ||
|
|
AsyncLoading(:final value?) => Builder(
|
|
builder: (_) {
|
|
if (value.isEmpty) return nothing;
|
|
var boundaryIndex = value.indexWhere(
|
|
(event) =>
|
|
event.eventId == timelineBoundary.value,
|
|
);
|
|
if (boundaryIndex < 0) {
|
|
boundaryIndex = value.length - 1;
|
|
timelineBoundary.value =
|
|
value[boundaryIndex].eventId;
|
|
}
|
|
|
|
final historyLength = boundaryIndex + 1;
|
|
final liveLength = value.length - historyLength;
|
|
|
|
int? eventIndexForKey(Key key) {
|
|
if (key is! ValueKey<String>) return null;
|
|
final index = value.indexWhere(
|
|
(event) => event.eventId == key.value,
|
|
);
|
|
return index < 0 ? null : index;
|
|
}
|
|
|
|
int? historyChildIndex(Key key) {
|
|
if (key == const ValueKey("history-loader")) {
|
|
return historyLength;
|
|
}
|
|
final eventIndex = eventIndexForKey(key);
|
|
return eventIndex == null ||
|
|
eventIndex >= historyLength
|
|
? null
|
|
: historyLength - eventIndex - 1;
|
|
}
|
|
|
|
int? liveChildIndex(Key key) {
|
|
final eventIndex = eventIndexForKey(key);
|
|
return eventIndex == null ||
|
|
eventIndex < historyLength
|
|
? null
|
|
: eventIndex - historyLength;
|
|
}
|
|
|
|
Widget eventRow(
|
|
Event event,
|
|
Event? previousEvent,
|
|
) => HighlightWrapper(
|
|
EventRenderer(
|
|
event,
|
|
onTapReply: () => jumpToId(event.replyTo!),
|
|
getEventOptions: getEventOptions,
|
|
isGrouped:
|
|
previousEvent?.content
|
|
is MessageContent &&
|
|
previousEvent?.redactedBy == null &&
|
|
previousEvent?.relationType !=
|
|
"m.replace" &&
|
|
"${event.sender}${event.pmp?.id}" ==
|
|
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
|
|
),
|
|
key: ValueKey(event.eventId),
|
|
isHighlighted:
|
|
highlightedEvent.value == event.eventId,
|
|
);
|
|
|
|
return CustomScrollView(
|
|
reverse: true,
|
|
center: timelineCenterKey,
|
|
keyboardDismissBehavior:
|
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
|
controller: scrollController,
|
|
slivers: [
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(height: composerSize.value),
|
|
),
|
|
SuperSliverList.builder(
|
|
listController: liveListController.value,
|
|
itemCount: liveLength,
|
|
findChildIndexCallback: liveChildIndex,
|
|
itemBuilder: (_, index) => eventRow(
|
|
value[historyLength + index],
|
|
value.getOrNull(
|
|
historyLength + index - 1,
|
|
),
|
|
),
|
|
),
|
|
SuperSliverList.builder(
|
|
key: timelineCenterKey,
|
|
listController: historyListController.value,
|
|
itemCount: historyLength + 1,
|
|
findChildIndexCallback: historyChildIndex,
|
|
itemBuilder: (_, index) {
|
|
if (index == historyLength) {
|
|
return _HistoryLoader(
|
|
key: const ValueKey("history-loader"),
|
|
hasMore: hasMore,
|
|
isLoading:
|
|
controllerData is AsyncLoading,
|
|
onLoad: notifier.loadOlder,
|
|
);
|
|
}
|
|
final event =
|
|
value[historyLength - index - 1];
|
|
return eventRow(
|
|
event,
|
|
value.getOrNull(
|
|
historyLength - index - 2,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
AsyncData() => nothing,
|
|
AsyncLoading() => Loading(),
|
|
AsyncError(:final error, :final stackTrace) =>
|
|
ErrorDialog(error, stackTrace),
|
|
},
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: MeasureSize(
|
|
onChange: (size) => composerSize.value = size.height,
|
|
child: Composer(
|
|
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 == true && showMembersByDefault)
|
|
MemberList(roomId),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryLoader extends HookWidget {
|
|
final bool hasMore;
|
|
final bool isLoading;
|
|
final Future<void> Function() onLoad;
|
|
|
|
const _HistoryLoader({
|
|
required this.hasMore,
|
|
required this.isLoading,
|
|
required this.onLoad,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
useEffect(() {
|
|
if (hasMore) {
|
|
Future.microtask(onLoad);
|
|
}
|
|
return null;
|
|
}, const []);
|
|
|
|
return Padding(
|
|
padding: .symmetric(vertical: 36),
|
|
child: Center(
|
|
child: ElevatedButton(
|
|
onPressed: hasMore && !isLoading ? onLoad : null,
|
|
child: isLoading
|
|
? SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(hasMore ? "Load More" : "Start of timeline"),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|