diff --git a/gomuks b/gomuks index 9e6254b..46ab9e9 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 9e6254b703a841d1e9ee64dbaf90d5a1b798696e +Subproject commit 46ab9e9afa54c12f585cbbf559c395b9c2dcb545 diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart index 1bcc811..bbe9935 100644 --- a/lib/controllers/attachment.dart +++ b/lib/controllers/attachment.dart @@ -4,9 +4,10 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/message.dart"; +import "package:nexus/models/composer_params.dart"; import "package:path/path.dart"; -class AttachmentController(final String roomId) +class AttachmentController(final ComposerParams composer) extends Notifier<(String, MessageContent?)?> { @override Null build() => null; @@ -18,7 +19,10 @@ class AttachmentController(final String roomId) final isEncrypted = ref.read( RoomsController.provider.select( (value) => - value[roomId]?.state[EventType.encryption.type]?.isNotEmpty == true, + value[composer.roomId] + ?.state[EventType.encryption.type] + ?.isNotEmpty == + true, ), ); @@ -32,7 +36,9 @@ class AttachmentController(final String roomId) void set((String, MessageContent?)? attachment) => state = attachment; static final provider = NotifierProvider.family - .autoDispose( - AttachmentController.new, - ); + .autoDispose< + AttachmentController, + (String, MessageContent?)?, + ComposerParams + >(AttachmentController.new); } diff --git a/lib/controllers/bot_argument_suggestions.dart b/lib/controllers/bot_argument_suggestions.dart index 4150b8b..04c2152 100644 --- a/lib/controllers/bot_argument_suggestions.dart +++ b/lib/controllers/bot_argument_suggestions.dart @@ -8,16 +8,17 @@ import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/models/bot_argument_suggestion.dart"; import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/composer_params.dart"; import "package:nexus/models/content/bot_command_description.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/room.dart"; -class BotArgumentSuggestionsController(final String roomId) +class BotArgumentSuggestionsController(final ComposerParams composer) extends Notifier> { @override IList build() { - final draft = ref.watch(BotCommandDraftController.provider(roomId)); + final draft = ref.watch(BotCommandDraftController.provider(composer)); final (parameter, text) = switch (draft) { BotCommandDraft(focusedParameter: final parameter?, :final texts) => ( parameter, @@ -38,7 +39,7 @@ class BotArgumentSuggestionsController(final String roomId) ? ref .watch( MembersByStatusController.provider( - .new(roomId: roomId, status: .join), + .new(roomId: composer.roomId, status: .join), ), ) .value ?? @@ -102,7 +103,7 @@ class BotArgumentSuggestionsController(final String roomId) } void pick(BotArgumentSuggestion suggestion) => ref - .read(BotCommandDraftController.provider(roomId).notifier) + .read(BotCommandDraftController.provider(composer).notifier) .pick( suggestion, via: switch (suggestion.room) { @@ -112,7 +113,7 @@ class BotArgumentSuggestionsController(final String roomId) ); bool pickTop() { - final draft = ref.read(BotCommandDraftController.provider(roomId)); + final draft = ref.read(BotCommandDraftController.provider(composer)); if (state.firstOrNull case final suggestion? when draft != null && !draft.arguments.containsKey(draft.focused)) { pick(suggestion); @@ -125,6 +126,6 @@ class BotArgumentSuggestionsController(final String roomId) .family< BotArgumentSuggestionsController, IList, - String + ComposerParams >(BotArgumentSuggestionsController.new); } diff --git a/lib/controllers/bot_command_draft.dart b/lib/controllers/bot_command_draft.dart index efe5759..e0d67d5 100644 --- a/lib/controllers/bot_command_draft.dart +++ b/lib/controllers/bot_command_draft.dart @@ -4,9 +4,10 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/bot_argument_suggestion.dart"; import "package:nexus/models/bot_command.dart"; import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/composer_params.dart"; import "package:nexus/models/content/bot_command_description.dart"; -class BotCommandDraftController(final String roomId) +class BotCommandDraftController(final ComposerParams composer) extends Notifier { @override BotCommandDraft? build() => null; @@ -14,7 +15,7 @@ class BotCommandDraftController(final String roomId) void start(BotCommand command, {String text = ""}) { state = BotCommandDraft( command: command, - roomId: roomId, + composer: composer, texts: .fromEntries( command.description.parameters .where((parameter) => !parameter.optional) @@ -97,7 +98,7 @@ class BotCommandDraftController(final String roomId) void clear() => state = null; static final provider = NotifierProvider.autoDispose - .family( + .family( BotCommandDraftController.new, ); } diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 62b4502..3d2de6f 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -12,6 +12,7 @@ import "package:nexus/controllers/active_account.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; +import "package:nexus/helpers/extensions/thread_events.dart"; import "package:nexus/main.dart"; import "package:nexus/models/content/bot_command_description.dart"; import "package:nexus/models/account.dart"; @@ -37,6 +38,7 @@ import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_specific_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/get_room_directory.dart"; +import "package:nexus/models/requests/get_threads.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; import "package:nexus/models/push_notification.dart"; @@ -367,6 +369,9 @@ class ClientController(final String? accountId) extends AsyncNotifier { Future paginate(PaginateRequest request) async => .fromJson(await _sendCommand("paginate", request.toJson())); + Future getThreads(GetThreadsRequest request) async => + .fromJson(await _sendCommand("get_threads", request.toJson())); + Future paginateManual(PaginateManualRequest request) async => .fromJson(await _sendCommand("paginate_manual", request.toJson())); @@ -438,10 +443,16 @@ class ClientController(final String? accountId) extends AsyncNotifier { {"room_id": roomId, "timeout": timeout.inMilliseconds}, ); - Future markRead(Room room) async { - if (room.timeline.isEmpty || room.metadata == null) return; - final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; - final event = eventRowId == null ? null : room.events[eventRowId]; + Future markRead(Room room, {String? threadRoot}) async { + if (room.metadata == null) return; + final event = switch (threadRoot) { + final threadRoot? => room.latestThreadEvent(threadRoot), + null when room.timeline.isEmpty => null, + null => switch (room.timeline[room.timeline.keys.reduce(max)]) { + final rowId? => room.events[rowId], + null => null, + }, + }; if (event == null) return; @@ -454,6 +465,7 @@ class ClientController(final String? accountId) extends AsyncNotifier { "room_id": room.metadata!.id, "receipt_type": sendPublic ? "m.read" : "m.read.private", "event_id": event.eventId, + "thread_id": ?threadRoot, }); } diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart index 43ed9e8..fc1657f 100644 --- a/lib/controllers/event.dart +++ b/lib/controllers/event.dart @@ -1,25 +1,20 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; -import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_event.dart"; +/// The row ID of an event, fetching it if it isn't stored yet. class EventController(final GetEventRequest request) - extends AsyncNotifier { + extends AsyncNotifier { @override - Future build() async { - final event = ref.watch( - RoomsController.provider.select((rooms) { - final room = rooms[request.roomId]; - - return switch (room?.eventIds[request.eventId]) { - final rowId? => room?.events[rowId], - null => null, - }; - }), + Future build() async { + final rowId = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[request.roomId]?.eventIds[request.eventId], + ), ); - if (event != null) return event; + if (rowId != null) return rowId; final link = ref.keepAlive(); final fetchedEvent = await ref @@ -44,11 +39,9 @@ class EventController(final GetEventRequest request) ); } - return fetchedEvent; + return fetchedEvent?.rowId; } static final provider = AsyncNotifierProvider.family - .autoDispose( - EventController.new, - ); + .autoDispose(EventController.new); } diff --git a/lib/controllers/pinned_events.dart b/lib/controllers/pinned_events.dart index d0d7bb5..16456fc 100644 --- a/lib/controllers/pinned_events.dart +++ b/lib/controllers/pinned_events.dart @@ -13,7 +13,7 @@ class PinnedEventsController(final String roomId) pinIds.map( (eventId) => ref.watch( EventController.provider(.new(eventId: eventId, roomId: roomId)) - .selectAsync((data) => data?.rowId), + .future, ), ), )).nonNulls.toIList(); diff --git a/lib/controllers/read_receipts.dart b/lib/controllers/read_receipts.dart index 15f76aa..4dba46c 100644 --- a/lib/controllers/read_receipts.dart +++ b/lib/controllers/read_receipts.dart @@ -32,7 +32,12 @@ class ReadReceiptsController(final ChatParams chat) final room = ref.watch( RoomsController.provider.select( (rooms) => switch (rooms[chat.roomId]) { - final room? => (events: room.events, receipts: room.receipts), + final room? => ( + events: room.events, + eventIds: room.eventIds, + receipts: room.receipts, + timeline: room.timeline, + ), null => null, }, ), @@ -50,15 +55,48 @@ class ReadReceiptsController(final ChatParams chat) .toIList(); final messageIndexes = messages.map((entry) => entry.$1).toList(); + final positions = IMap.fromEntries( + room.timeline.entries + .map( + (entry) => switch (entry.value) { + final rowId? => MapEntry(rowId, entry.key), + null => null, + }, + ) + .nonNulls, + ); + final placed = events.indexed + .map( + (entry) => switch (positions[entry.$2.rowId]) { + final position? => (index: entry.$1, position: position), + null => null, + }, + ) + .nonNulls + .toList(); + final placedPositions = placed.map((entry) => entry.position).toList(); + + int? indexOf(String eventId) { + if (indexes[eventId] case final index?) return index; + + final position = switch (room.eventIds[eventId]) { + final rowId? => positions[rowId], + null => null, + }; + if (position == null) return null; + + final before = lowerBound(placedPositions, position + 1) - 1; + return before < 0 ? null : placed[before].index; + } + return room.receipts.values.flattened .where( (receipt) => receipt.userId != userId && - // Receipts in threads are for the thread, not the timeline - receipt.thread == "main", + receipt.thread == (chat.threadRoot ?? "main"), ) .map( - (receipt) => switch (indexes[receipt.eventId]) { + (receipt) => switch (indexOf(receipt.eventId)) { final index? => ( receipt: receipt, message: lowerBound(messageIndexes, index + 1) - 1, diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 01b2578..c908dd2 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -7,10 +7,14 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/event.dart"; import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/thread_replies.dart"; import "package:nexus/helpers/extensions/is_media.dart"; import "package:nexus/helpers/extensions/send_failed.dart"; +import "package:nexus/helpers/extensions/thread_events.dart"; import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/composer_params.dart"; import "package:nexus/models/content/bot_command.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/image_pack.dart"; @@ -21,6 +25,7 @@ import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/direction.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/paginate_manual.dart"; import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/content/message.dart"; @@ -28,7 +33,11 @@ import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/room_chat.dart"; -typedef ChatParams = ({String roomId, int? contextualEvent}); +typedef ChatParams = ({ + String roomId, + int? contextualEvent, + String? threadRoot, +}); class RoomChatController(final ChatParams info) extends AsyncNotifier { @@ -47,52 +56,173 @@ class RoomChatController(final ChatParams info) ref.read(RoomsController.provider.notifier).addState(info.roomId, state); } - final timeline = room.timeline - .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) - .map((e) => e.value) - .toIList() - .addAll(room.clientSticky) - .nonNulls - .toIList(); - if (info.contextualEvent == null || - timeline.contains(info.contextualEvent)) { - ref.watch( - RoomsController.provider.select( - (rooms) => - (rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky), - ), + if (info.threadRoot case final threadRoot? + when info.contextualEvent == null) { + final nextBatch = await ref.watch( + ThreadRepliesController.provider(( + roomId: info.roomId, + threadRoot: threadRoot, + )).future, ); return .new( - timeline: timeline, - hasMoreBackward: room.hasMore, + timeline: ref.watch(RoomsController.provider.select(threadTimeline)), + hasMoreBackward: nextBatch != null, hasMoreForward: false, ); - } else { - final context = await client.getEventContext( - .new( - roomId: info.roomId, - eventId: room.events[info.contextualEvent!]!.eventId, - ), - ); + } - // `before` is in reverse chronological order - final events = context.before.reversed + if (info.threadRoot == null) { + final timeline = room.timeline + .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .map((e) => e.value) .toIList() - .add(context.event) - .addAll(context.after); + .addAll(room.clientSticky) + .nonNulls + // Threads are shown from their root instead + .where((rowId) => room.events[rowId]?.threadRoot == null) + .toIList(); + + if (info.contextualEvent == null || + timeline.contains(info.contextualEvent)) { + ref.watch( + RoomsController.provider.select( + (rooms) => ( + rooms[info.roomId]?.timeline, + rooms[info.roomId]?.clientSticky, + ), + ), + ); + + return .new( + timeline: timeline, + hasMoreBackward: room.hasMore, + hasMoreForward: false, + ); + } + } + + final context = await client.getEventContext( + .new( + roomId: info.roomId, + eventId: room.events[info.contextualEvent!]!.eventId, + ), + ); + + final contextEvents = context.before.reversed + .toIList() + .add(context.event) + .addAll(context.after); + ref + .read(RoomsController.provider.notifier) + .storeEvents(contextEvents.addAll(context.relatedEvents)); + + if (info.threadRoot case final threadRoot?) { + final (older, newer) = await ( + client.paginateManual( + .new( + roomId: info.roomId, + threadRoot: threadRoot, + since: context.start, + direction: .backward, + ), + ), + client.paginateManual( + .new( + roomId: info.roomId, + threadRoot: threadRoot, + since: context.end, + direction: .forward, + ), + ), + ).wait; + final rootRowId = await threadRootBefore(older); ref .read(RoomsController.provider.notifier) - .storeEvents(events.addAll(context.relatedEvents)); + .storeEvents( + older.events + .addAll(older.relatedEvents) + .addAll(newer.events) + .addAll(newer.relatedEvents), + ); + return .new( - timeline: .new(events.map((element) => element.rowId)), - hasMoreBackward: true, - hasMoreForward: true, - historicalData: .new(start: context.start, end: context.end), + timeline: .new([?rootRowId]) + .addAll( + // Paginating backwards gives the newest first + older.events.reversed + .addAll( + contextEvents.where( + (event) => event.threadRoot == threadRoot, + ), + ) + .addAll(newer.events) + .map((event) => event.rowId), + ) + .removeDuplicates(), + hasMoreBackward: older.nextBatch != null, + hasMoreForward: newer.nextBatch != null, + historicalData: .new( + start: older.nextBatch ?? "", + end: newer.nextBatch ?? "", + ), ); } + + return .new( + timeline: .new( + contextEvents + .where((event) => event.threadRoot == null) + .map((event) => event.rowId), + ), + hasMoreBackward: true, + hasMoreForward: true, + historicalData: .new(start: context.start, end: context.end), + ); } + /// The thread's root, which comes before all of its events, if paginating + /// backwards to [older] reached the start of the thread. + Future threadRootBefore(PaginateManual older) async => + switch (info.threadRoot) { + final threadRoot? when older.nextBatch == null => await ref.read( + EventController.provider( + .new(roomId: info.roomId, eventId: threadRoot), + ).future, + ), + _ => null, + }; + + /// The thread's root, then its events, if this is a thread's chat. + IList threadTimeline(IMap rooms) => + switch ((rooms[info.roomId], info.threadRoot)) { + (final room?, final threadRoot?) => .new([ + ?room.eventIds[threadRoot], + ...room.threadEvents(threadRoot).map((event) => event.rowId), + ]), + _ => const .empty(), + }; + + Relation? replyRelation(Event? replied) => + switch (info.threadRoot ?? replied?.threadRoot) { + null => + replied == null + ? null + : .new(eventId: replied.eventId, relationType: .reply), + final threadRoot => .new( + eventId: + replied?.eventId ?? + ref + .read(RoomsController.provider)[info.roomId] + ?.latestThreadEvent(threadRoot) + ?.eventId ?? + threadRoot, + relationType: .reply, + threadRoot: threadRoot, + isFallingBack: replied == null, + ), + }; + Future deleteMessage(Event event, {String? reason}) => ref .read(ClientController.provider.notifier) .redactEvent( @@ -133,97 +263,128 @@ class RoomChatController(final ChatParams info) return; } + if ((info.threadRoot, chat) case (final threadRoot?, final chat?) + when chat.historicalData == null) { + return ref + .read( + ThreadRepliesController.provider(( + roomId: info.roomId, + threadRoot: threadRoot, + )).notifier, + ) + .loadOlder(); + } + state = .loading(); final client = ref.read(ClientController.provider.notifier); - if (chat?.historicalData == null) { - final timelineKeys = ref - .read(RoomsController.provider.select((value) => value[info.roomId])) - ?.timeline - .keys; - final response = await client.paginate( - .new( - roomId: info.roomId, - maxTimelineId: timelineKeys?.isNotEmpty == true - ? timelineKeys?.reduce(min) - : null, - ), - ); + try { + if (chat?.historicalData == null) { + final timelineKeys = ref + .read( + RoomsController.provider.select((value) => value[info.roomId]), + ) + ?.timeline + .keys; + final response = await client.paginate( + .new( + roomId: info.roomId, + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) + : null, + ), + ); - if (response.events.isEmpty) { - state = .data(chat); - } + if (response.events.isEmpty) { + state = .data(chat); + } - ref - .read(RoomsController.provider.notifier) - .update( - IMap({ - info.roomId: Room( - events: IMap.fromIterable( - response.events.addAll(response.relatedEvents), - keyMapper: (event) => event.rowId, - valueMapper: (event) => event, - ), - hasMore: response.hasMore, - receipts: response.receipts, - timeline: IMap.fromIterable( - response.events, - keyMapper: (event) => event.timelineRowId, - valueMapper: (event) => event.rowId, + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: .new( + // ignore: unused_result - I am using it you donkey + events: .fromIterable( + response.events.addAll(response.relatedEvents), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ), + hasMore: response.hasMore, + receipts: response.receipts, + // ignore: unused_result - I am using it you donkey + timeline: .fromIterable( + response.events, + keyMapper: (event) => event.timelineRowId, + valueMapper: (event) => event.rowId, + ), ), + }), + ); + } else { + final paginationResponse = await client.paginateManual( + .new( + roomId: info.roomId, + threadRoot: info.threadRoot, + direction: direction, + since: direction == .forward + ? chat!.historicalData!.end + : chat!.historicalData!.start, + ), + ); + + ref + .read(RoomsController.provider.notifier) + .storeEvents( + paginationResponse.events.addAll( + paginationResponse.relatedEvents, ), - }), - ); - } else { - final paginationResponse = await client.paginateManual( - .new( - roomId: info.roomId, - direction: direction, - since: direction == .forward - ? chat!.historicalData!.end - : chat!.historicalData!.start, - ), - ); + ); - ref - .read(RoomsController.provider.notifier) - .storeEvents( - paginationResponse.events.addAll(paginationResponse.relatedEvents), - ); - - final eventRowIds = - (direction == .forward + final rootRowId = direction == .backward + ? await threadRootBefore(paginationResponse) + : null; + final eventRowIds = [ + ?rootRowId, + ...(direction == .forward ? paginationResponse.events : paginationResponse.events.reversed) - .map((element) => element.rowId) - .toIList(); + .where((event) => event.threadRoot == info.threadRoot) + .map((element) => element.rowId), + ].lock; - state = .data( - .new( - timeline: direction == .forward - ? chat.timeline.addAll(eventRowIds) - : eventRowIds.addAll(chat.timeline), - hasMoreForward: - direction == .forward && paginationResponse.nextBatch == null - ? false - : chat.hasMoreForward, - hasMoreBackward: - direction == .backward && paginationResponse.nextBatch == null - ? false - : chat.hasMoreBackward, - historicalData: chat.historicalData?.copyWith( - start: - (direction == .backward - ? paginationResponse.nextBatch - : null) ?? - chat.historicalData!.start, - end: - (direction == .forward ? paginationResponse.nextBatch : null) ?? - chat.historicalData!.end, + state = .data( + .new( + timeline: direction == .forward + ? chat.timeline.addAll(eventRowIds) + : eventRowIds.addAll(chat.timeline), + hasMoreForward: + direction == .forward && paginationResponse.nextBatch == null + ? false + : chat.hasMoreForward, + hasMoreBackward: + direction == .backward && paginationResponse.nextBatch == null + ? false + : chat.hasMoreBackward, + historicalData: chat.historicalData?.copyWith( + start: + (direction == .backward + ? paginationResponse.nextBatch + : null) ?? + chat.historicalData!.start, + end: + (direction == .forward + ? paginationResponse.nextBatch + : null) ?? + chat.historicalData!.end, + ), ), - ), - ); + ); + } + } catch (_) { + state = .data(chat); + rethrow; } } @@ -239,7 +400,11 @@ class RoomChatController(final ChatParams info) ? null : events?[relatedEventRowId]; - final attachmentProvider = AttachmentController.provider(info.roomId); + final ComposerParams composer = ( + roomId: info.roomId, + threadRoot: info.threadRoot, + ); + final attachmentProvider = AttachmentController.provider(composer); final attachment = ref.read(attachmentProvider)?.$2; ref.invalidate(attachmentProvider); @@ -275,9 +440,9 @@ class RoomChatController(final ChatParams info) room: taggedMessage.contains("@room"), ), text: taggedMessage, - relation: relatedEvent == null - ? null - : .new(eventId: relatedEvent.eventId, relationType: relationType), + relation: relationType == .edit && relatedEvent != null + ? .new(eventId: relatedEvent.eventId, relationType: .edit) + : replyRelation(relatedEvent), ), ); @@ -384,7 +549,7 @@ class RoomChatController(final ChatParams info) if (shouldMention && repliedEvent != null) repliedEvent.sender, ].toIList(), ), - replyTo: repliedEvent?.eventId, + reply: replyRelation(repliedEvent), ), ); @@ -451,9 +616,7 @@ class RoomChatController(final ChatParams info) command: draft.input, ), mentions: .new(userIds: [draft.command.sender].lock), - relation: repliedEvent == null - ? null - : .new(eventId: repliedEvent.eventId, relationType: .reply), + relation: replyRelation(repliedEvent), ), ); diff --git a/lib/controllers/thread_list.dart b/lib/controllers/thread_list.dart new file mode 100644 index 0000000..847b29e --- /dev/null +++ b/lib/controllers/thread_list.dart @@ -0,0 +1,37 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; + +class ThreadListController(final String roomId) extends AsyncNotifier { + @override + Future build() => fetch(null); + + Future fetch(String? since) async { + final threads = await ref + .read(ClientController.provider.notifier) + .getThreads(.new(roomId: roomId, since: since)); + ref + .read(RoomsController.provider.notifier) + .storeEvents(threads.events.addAll(threads.relatedEvents)); + + return threads.nextBatch; + } + + Future loadMore() async { + final since = await future; + if (state.isLoading || since == null) return; + + state = .loading(); + try { + state = .data(await fetch(since)); + } catch (_) { + state = .data(since); + rethrow; + } + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + ThreadListController.new, + ); +} diff --git a/lib/controllers/thread_replies.dart b/lib/controllers/thread_replies.dart new file mode 100644 index 0000000..b0e4327 --- /dev/null +++ b/lib/controllers/thread_replies.dart @@ -0,0 +1,49 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; + +typedef ThreadParams = ({String roomId, String threadRoot}); + +/// Loads a thread's replies from the server, newest first, storing them. Its +/// state is the token for older replies, or null if there are none. +class ThreadRepliesController(final ThreadParams thread) + extends AsyncNotifier { + @override + Future build() => fetch(null); + + Future fetch(String? since) async { + final response = await ref + .read(ClientController.provider.notifier) + .paginateManual( + .new( + roomId: thread.roomId, + threadRoot: thread.threadRoot, + since: since, + direction: .backward, + ), + ); + ref + .read(RoomsController.provider.notifier) + .storeEvents(response.events.addAll(response.relatedEvents)); + + return response.nextBatch; + } + + Future loadOlder() async { + final since = await future; + if (state.isLoading || since == null) return; + + state = .loading(); + try { + state = .data(await fetch(since)); + } catch (_) { + state = .data(since); + rethrow; + } + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + ThreadRepliesController.new, + ); +} diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 4859953..aff4ba0 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -26,6 +26,7 @@ extension BuildEventOptions on Event { required String roomId, required String userId, required void Function(int, RelationType) onRelation, + void Function(String threadRoot)? onStartThread, }) { final theme = Theme.of(context); final danger = theme.colorScheme.error; @@ -34,11 +35,15 @@ extension BuildEventOptions on Event { final notifier = RoomChatController.provider(( roomId: roomId, contextualEvent: null, + threadRoot: null, )).notifier; final isPinned = ref .read(PinnedIdsController.provider(roomId)) .contains(eventId); + final canSend = ref.read( + PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), + ); void showReasonSheet({ required String title, @@ -170,11 +175,7 @@ extension BuildEventOptions on Event { ), ), - if (ref.read( - PowerLevelController.provider( - .new(eventType: .message, roomId: roomId), - ), - )) + if (canSend) PopupMenuItem( onTap: () => onRelation(rowId, .reply), child: const ListTile( @@ -183,6 +184,18 @@ extension BuildEventOptions on Event { ), ), + if (onStartThread != null && + threadCount == 0 && + canSend && + relatesTo == null) + PopupMenuItem( + onTap: () => onStartThread(eventId), + child: const ListTile( + leading: Icon(Icons.forum), + title: Text("Start Thread"), + ), + ), + if (sender == userId && switch (content) { LocationMessageContent() || diff --git a/lib/helpers/extensions/open_link_info.dart b/lib/helpers/extensions/open_link_info.dart index f8688f8..e15e9b0 100644 --- a/lib/helpers/extensions/open_link_info.dart +++ b/lib/helpers/extensions/open_link_info.dart @@ -32,7 +32,7 @@ extension OpenLinkInfo on WidgetRef { : await read( EventController.provider( .new(roomId: summary.roomId, eventId: linkInfo.eventId!), - ).selectAsync((data) => data?.rowId), + ).future, ).onError((_, _) => null); if (await focusRoom(summary.roomId, eventRowId) || !context.mounted) return; diff --git a/lib/helpers/extensions/thread_events.dart b/lib/helpers/extensions/thread_events.dart new file mode 100644 index 0000000..31c7808 --- /dev/null +++ b/lib/helpers/extensions/thread_events.dart @@ -0,0 +1,50 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/helpers/extensions/event_id_is_fake.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +typedef ThreadInfo = ({int count, Event? latest}); +typedef Thread = ({Event root, ThreadInfo info}); + +extension ThreadEvents on Room { + /// The threads in the loaded events, most recently active first. + IList get threads { + DateTime lastActive(Thread thread) => + thread.info.latest?.timestamp ?? thread.root.timestamp; + + return events.values + .where((event) => event.threadCount > 0) + .map((root) => (root: root, info: thread(root))) + .sorted((a, b) => lastActive(b).compareTo(lastActive(a))) + .toIList(); + } + + /// The events in the thread from [threadRoot], oldest first, without the + /// root itself. + IList threadEvents(String threadRoot) => + (relations[threadRoot] ?? const .empty()) + .map((rowId) => events[rowId]) + .nonNulls + .where( + (event) => + event.threadRoot == threadRoot && + (!event.eventId.isFake || clientSticky.contains(event.rowId)), + ) + .sorted((a, b) => a.timestamp.compareTo(b.timestamp)) + .toIList(); + + /// How many replies the thread from [root] has, and its latest event, from + /// gomuks' aggregate on it + ThreadInfo thread(Event root) => + (count: root.threadCount, latest: events[root.threadLatestRowId]); + + /// The latest event in the thread from [threadRoot], if it's loaded. + Event? latestThreadEvent(String threadRoot) => switch (eventIds[threadRoot]) { + final rowId? => switch (events[rowId]) { + final root? => thread(root).latest, + null => null, + }, + null => null, + }; +} diff --git a/lib/helpers/hooks/chat_composer.dart b/lib/helpers/hooks/chat_composer.dart new file mode 100644 index 0000000..1759e25 --- /dev/null +++ b/lib/helpers/hooks/chat_composer.dart @@ -0,0 +1,40 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/relation_type.dart"; + +final class ChatComposer({ + required final ValueNotifier relatedEvent, + required final ValueNotifier relationType, + required final FocusNode node, +}) { + factory use() { + final relatedEvent = useState(null); + final relationType = useState(RelationType.reply); + final node = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == .escape) { + relatedEvent.value = null; + return .handled; + } + + return .ignored; + }, + ); + + return .new( + relatedEvent: relatedEvent, + relationType: relationType, + node: node, + ); + } + + int? get repliedEventRowId => + relationType.value == .reply ? relatedEvent.value : null; + + void relate(int rowId, RelationType type) { + relatedEvent.value = rowId; + relationType.value = type; + node.requestFocus(); + } +} diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 50d9a32..b307c36 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -15,6 +15,8 @@ final class ChatScroll({ required final GlobalKey centerKey, required final ScrollController scrollController, required final bool atBottom, + required final int? highlightedEvent, + required final JumpToEvent jumpToEvent, required final Future Function() jumpToBottom, required final GlobalKey Function(int eventRowId) keyFor, @@ -28,6 +30,8 @@ final class ChatScroll({ final anchorId = useState(null); final atBottom = useState(true); final pendingJump = useState(null); + final highlightedEvent = useState(null); + final hookContext = useContext(); final pendingJumpDone = useRef?>(null); void finishPendingJump() { pendingJumpDone.value?.complete(); @@ -95,15 +99,14 @@ final class ChatScroll({ final anchorIndex = items.indexOf(anchor); - if (anchorIndex == -1) return (history: const .empty(), live: items); - + final historyLength = anchorIndex == -1 ? items.length : anchorIndex + 1; return ( - history: items.take(anchorIndex).toIList().reversed.toIList(), - live: items.skip(anchorIndex).toIList(), + history: items.take(historyLength).toIList().reversed.toIList(), + live: items.skip(historyLength).toIList(), ); }, [controllerData, anchorId.value]); - final lastRowId = split.live.lastOrNull; + final lastRowId = controllerData.value?.timeline.lastOrNull; final previousLastRowId = useRef(null); useEffect(() { @@ -118,7 +121,8 @@ final class ChatScroll({ WidgetsBinding.instance.addPostFrameCallback((_) { if (!scrollController.hasClients) return; - scrollController.jumpTo(scrollController.position.minScrollExtent); + anchorId.value = lastRowId; + scrollController.position.correctPixels(0); }); return null; @@ -173,37 +177,49 @@ final class ChatScroll({ ], ); + Future scrollToEvent(int rowId) async { + if (!scrollController.hasClients) return; + + final context = keyFor(rowId).currentContext; + if (context != null) { + await Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: Durations.extralong1, + curve: Curves.easeInOut, + ); + return; + } + + finishPendingJump(); + final done = pendingJumpDone.value = Completer(); + pendingJump.value = rowId; + if (controllerData.value?.timeline.contains(rowId) ?? false) { + anchorId.value = rowId; + } else { + anchorId.value = null; + contextualEvent.value = rowId; + } + await done.future; + } + return .new( historyRows: split.history, liveRows: split.live, centerKey: centerKey, scrollController: scrollController, atBottom: atBottom.value, + highlightedEvent: highlightedEvent.value, keyFor: keyFor, jumpToEvent: (int rowId) async { - if (!scrollController.hasClients) return; + highlightedEvent.value = rowId; - final context = keyFor(rowId).currentContext; - if (context != null) { - await Scrollable.ensureVisible( - context, - alignment: 0.5, - duration: Durations.extralong1, - curve: Curves.easeInOut, - ); - return; - } - - finishPendingJump(); - final done = pendingJumpDone.value = Completer(); - pendingJump.value = rowId; - if (controllerData.value?.timeline.contains(rowId) ?? false) { - anchorId.value = rowId; - } else { - anchorId.value = null; - contextualEvent.value = rowId; - } - await done.future; + await scrollToEvent(rowId); + await Future.delayed(Durations.extralong1, () { + if (hookContext.mounted && highlightedEvent.value == rowId) { + highlightedEvent.value = null; + } + }); }, jumpToBottom: () async { finishPendingJump(); diff --git a/lib/models/bot_command_draft.dart b/lib/models/bot_command_draft.dart index 4118088..d8339e0 100644 --- a/lib/models/bot_command_draft.dart +++ b/lib/models/bot_command_draft.dart @@ -2,6 +2,7 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/bot_command.dart"; +import "package:nexus/models/composer_params.dart"; import "package:nexus/models/content/bot_command.dart"; import "package:nexus/models/content/bot_command_description.dart"; @@ -10,7 +11,7 @@ part "bot_command_draft.freezed.dart"; @Freezed(toJson: false, fromJson: false) class const BotCommandDraft({ required final BotCommand command, - required final String roomId, + required final ComposerParams composer, final IMap texts = const .empty(), // The servers to join rooms picked from suggestions through, by room ID @@ -33,8 +34,9 @@ class const BotCommandDraft({ BotParameter? get focusedParameter => command.description.parameters .firstWhereOrNull((parameter) => parameter.key == focused); - ({Object? value, String? error}) parse(BotParameter parameter) => - parameter.schema.parseText(texts[parameter.key] ?? "", roomId: roomId); + ({Object? value, String? error}) parse(BotParameter parameter) => parameter + .schema + .parseText(texts[parameter.key] ?? "", roomId: composer.roomId); IMap get arguments => .fromEntries( shown diff --git a/lib/models/composer_params.dart b/lib/models/composer_params.dart new file mode 100644 index 0000000..12edb46 --- /dev/null +++ b/lib/models/composer_params.dart @@ -0,0 +1 @@ +typedef ComposerParams = ({String roomId, String? threadRoot}); diff --git a/lib/models/event.dart b/lib/models/event.dart index 93352ca..1467750 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -29,6 +29,8 @@ class const Event({ final String? sendError, final IMap reactions = const .empty(), @JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0, + final int threadCount = 0, + @JsonKey(name: "thread_latest_rowid") final int threadLatestRowId = 0, final UnreadType? unreadType, final Profile? pmp, required final Content content, @@ -36,6 +38,8 @@ class const Event({ }) with _$Event { Map toJson() => _$EventToJson(this); + String? get threadRoot => relationType == "m.thread" ? relatesTo : null; + static String typeJsonFromJson(Map json, _) => json["decrypted_type"] ?? json["type"]; @@ -47,7 +51,15 @@ class const Event({ static String? replyToFromJson(Map json) { try { - return json["m.relates_to"]?["m.in_reply_to"]?["event_id"]; + final relatesTo = json["m.relates_to"]; + + // Thread fallbacks are only for clients without thread support + if (relatesTo?["rel_type"] == "m.thread" && + relatesTo?["is_falling_back"] == true) { + return null; + } + + return relatesTo?["m.in_reply_to"]?["event_id"]; } catch (_) { return null; } diff --git a/lib/models/requests/get_threads.dart b/lib/models/requests/get_threads.dart new file mode 100644 index 0000000..e88227b --- /dev/null +++ b/lib/models/requests/get_threads.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_threads.freezed.dart"; +part "get_threads.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetThreadsRequest({ + required final String roomId, + final String? since, + final int limit = 20, +}) with _$GetThreadsRequest { + Map toJson() => _$GetThreadsRequestToJson(this); + + factory GetThreadsRequest.fromJson(Map json) => + _$GetThreadsRequestFromJson(json); +} diff --git a/lib/models/requests/send_event.dart b/lib/models/requests/send_event.dart index c12a715..13d8f86 100644 --- a/lib/models/requests/send_event.dart +++ b/lib/models/requests/send_event.dart @@ -14,7 +14,7 @@ class const SendEventRequest({ // Merged into the content by toJson, as gomuks doesn't take these @JsonKey(includeToJson: false) final String? relatesTo, @JsonKey(includeToJson: false) final String? relationType, - @JsonKey(includeToJson: false) final String? replyTo, + @JsonKey(includeToJson: false) final Relation? reply, @JsonKey(includeToJson: false) final Mentions? mentions, final bool synchronous = false, final bool disableEncryption = false, @@ -27,14 +27,14 @@ class const SendEventRequest({ "content": { ...content, "m.mentions": ?mentions?.toJson(), - if (relatesTo != null || replyTo != null) + if (relatesTo != null || reply != null) "m.relates_to": { ...((content["m.relates_to"] as Map?) ?? {}), if (relatesTo != null) ...{ "event_id": relatesTo, "rel_type": relationType, }, - if (replyTo != null) "m.in_reply_to": {"event_id": replyTo}, + ...?reply?.toJson(), }, }, }; diff --git a/lib/models/requests/send_message.dart b/lib/models/requests/send_message.dart index 10a9330..f6b61ac 100644 --- a/lib/models/requests/send_message.dart +++ b/lib/models/requests/send_message.dart @@ -38,11 +38,18 @@ class const Mentions({ class const Relation({ required final String eventId, required final RelationType relationType, + final String? threadRoot, + final bool isFallingBack = false, }) with _$Relation { Map toJson() { switch (relationType) { case RelationType.reply: return { + if (threadRoot != null) ...{ + "rel_type": "m.thread", + "event_id": threadRoot, + "is_falling_back": isFallingBack, + }, "m.in_reply_to": {"event_id": eventId}, }; diff --git a/lib/widgets/composer/bot_command_form.dart b/lib/widgets/composer/bot_command_form.dart index a7cc2aa..fdd0dc4 100644 --- a/lib/widgets/composer/bot_command_form.dart +++ b/lib/widgets/composer/bot_command_form.dart @@ -33,12 +33,12 @@ class const BotCommandForm( (index, parameter) => BotOptionPill( parameter, key: ValueKey(parameter.key), - roomId: draft.roomId, + composer: draft.composer, onSubmit: onSubmit, onRemove: parameter.optional ? () => ref .read( - BotCommandDraftController.provider(draft.roomId) + BotCommandDraftController.provider(draft.composer) .notifier, ) .removeOption(parameter.key) diff --git a/lib/widgets/composer/bot_command_panel.dart b/lib/widgets/composer/bot_command_panel.dart index 5ffb40a..57783ac 100644 --- a/lib/widgets/composer/bot_command_panel.dart +++ b/lib/widgets/composer/bot_command_panel.dart @@ -3,6 +3,7 @@ import "package:flutter/scheduler.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/models/composer_params.dart"; import "package:nexus/controllers/bot_argument_suggestions.dart"; import "package:nexus/controllers/bot_command_draft.dart"; import "package:nexus/models/bot_argument_suggestion.dart"; @@ -10,7 +11,7 @@ import "package:nexus/models/bot_command.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; class const BotCommandPanel( - final String roomId, { + final ComposerParams composer, { required final IList commands, required final TextEditingController controller, required final void Function(BotCommand command) onPick, @@ -19,9 +20,9 @@ class const BotCommandPanel( @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final draftProvider = BotCommandDraftController.provider(roomId); + final draftProvider = BotCommandDraftController.provider(composer); final suggestionsProvider = BotArgumentSuggestionsController.provider( - roomId, + composer, ); final text = useState(controller.text); diff --git a/lib/widgets/composer/bot_option_pill.dart b/lib/widgets/composer/bot_option_pill.dart index c805a47..0a86d8e 100644 --- a/lib/widgets/composer/bot_option_pill.dart +++ b/lib/widgets/composer/bot_option_pill.dart @@ -2,12 +2,13 @@ import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/models/composer_params.dart"; import "package:nexus/controllers/bot_command_draft.dart"; import "package:nexus/models/content/bot_command_description.dart"; class const BotOptionPill( final BotParameter parameter, { - required final String roomId, + required final ComposerParams composer, /// Called on backspace while empty, to remove the parameter or command. final VoidCallback? onRemove, @@ -17,7 +18,7 @@ class const BotOptionPill( @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final provider = BotCommandDraftController.provider(roomId); + final provider = BotCommandDraftController.provider(composer); final (:text, :isFocused, :error, :isLast) = ref.watch( provider.select( (draft) => ( diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 6472de2..940222a 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -10,6 +10,7 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/show_error_snack_bar.dart"; import "package:nexus/models/bot_command.dart"; import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/composer_params.dart"; import "package:nexus/widgets/composer/bot_command_form.dart"; import "package:nexus/widgets/composer/bot_command_panel.dart"; import "package:nexus/widgets/emoji_picker.dart"; @@ -75,6 +76,7 @@ class const Composer( }) onSendLocation, required final Future Function(BotCommandDraft draft) onSendBotCommand, + final String? threadRoot, final FocusNode? node, super.key, }) extends HookConsumerWidget { @@ -131,7 +133,9 @@ class const Composer( final poll = useState(null); final location = useState(null); - final botCommandProvider = BotCommandDraftController.provider(roomId); + final ComposerParams composer = (roomId: roomId, threadRoot: threadRoot); + final botCommandProvider = BotCommandDraftController.provider(composer); + final attachmentProvider = AttachmentController.provider(composer); final botCommand = ref.watch(botCommandProvider); useEffect(() { sticker.value = null; @@ -150,8 +154,6 @@ class const Composer( final isEditing = relationType == .edit && relatedEvent != null; useEffect(() { - final attachmentProvider = AttachmentController.provider(roomId); - if (relationType == .edit && relatedEvent != null) { textBefore.value ??= controller.text; attachmentBefore.value ??= (ref.read(attachmentProvider),); @@ -247,13 +249,11 @@ class const Composer( }; }, [roomId]); - final attachment = ref.watch(AttachmentController.provider(roomId)); + final attachment = ref.watch(attachmentProvider); Future attach(Future pick) async { if (await pick case final file?) { - await ref - .read(AttachmentController.provider(roomId).notifier) - .add(file); + await ref.read(attachmentProvider.notifier).add(file); } } @@ -266,9 +266,7 @@ class const Composer( final file = await File(join(directory.path, filename)) .writeAsBytes(bytes); - await ref - .read(AttachmentController.provider(roomId).notifier) - .add(.new(file.path)); + await ref.read(attachmentProvider.notifier).add(.new(file.path)); } /// Attaches the first file on the clipboard, or the image on it if there @@ -283,7 +281,7 @@ class const Composer( final files = await Pasteboard.files().onError((_, _) => const []); if (files.firstOrNull case final path?) { ref - .read(AttachmentController.provider(roomId).notifier) + .read(attachmentProvider.notifier) .add(XFile(path)) .onError(showError); return true; @@ -338,7 +336,9 @@ class const Composer( if (hasReplacement) { if (botCommand != null) { if (ref - .read(BotArgumentSuggestionsController.provider(roomId).notifier) + .read( + BotArgumentSuggestionsController.provider(composer).notifier, + ) .pickTop()) { return; } @@ -432,8 +432,7 @@ class const Composer( : Icon(Icons.file_copy), title: Text(attachment.$1), trailing: IconButton( - onPressed: () => - ref.invalidate(AttachmentController.provider(roomId)), + onPressed: () => ref.invalidate(attachmentProvider), icon: Icon(Icons.close), ), ), @@ -442,7 +441,7 @@ class const Composer( duration: Durations.short4, alignment: .bottomCenter, child: BotCommandPanel( - roomId, + composer, commands: botCommands, controller: controller, onPick: pickBotCommand, diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart index aaf1f5c..5af01ee 100644 --- a/lib/widgets/composer/relation_preview.dart +++ b/lib/widgets/composer/relation_preview.dart @@ -29,7 +29,10 @@ class const RelationPreview( Expanded( child: Padding( padding: .symmetric(vertical: 8), - child: EventPreview(relatedEvent!), + child: EventPreview( + relatedEvent!.rowId, + roomId: relatedEvent!.roomId, + ), ), ), diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart index 3de1eeb..53e10ac 100644 --- a/lib/widgets/event_preview.dart +++ b/lib/widgets/event_preview.dart @@ -1,14 +1,23 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/is_shown_as_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 const EventPreview(final Event event, {super.key}) - extends StatelessWidget { +class const EventPreview( + final int rowId, { + required final String roomId, + super.key, +}) extends ConsumerWidget { @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final event = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]?.events[rowId]), + ); + if (event == null) return SizedBox.shrink(); + final showSender = event.hasMessageContent; return IgnorePointer( @@ -27,8 +36,8 @@ class const EventPreview(final Event event, {super.key}) children: [ if (showSender) MessageDisplayname(event, shouldWrap: false), EventRenderer( - event.rowId, - roomId: event.roomId, + rowId, + roomId: roomId, textOnly: true, maxLines: 1, ), diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index 0b90781..288ebd6 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -73,6 +73,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { RoomChatController.provider(( roomId: event.roomId, contextualEvent: null, + threadRoot: null, )).notifier, ); diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index a422ba9..6b31931 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -44,6 +44,7 @@ import "package:nexus/widgets/renderers/power_levels_diff.dart"; import "package:nexus/widgets/renderers/pinned_events_diff.dart"; import "package:nexus/widgets/renderers/server_acl_diff.dart"; import "package:nexus/widgets/swipe_to_reply.dart"; +import "package:nexus/widgets/thread_summary.dart"; import "package:nexus/widgets/timestamp.dart"; import "package:nexus/widgets/user_span.dart"; @@ -54,6 +55,7 @@ class const EventRenderer( final bool textOnly = false, final int? maxLines, final JumpToEvent? jumpToEvent, + final void Function(String threadRoot)? onOpenThread, final IList Function(Event event)? getEventOptions, /// If set, events can be swiped, calling this @@ -106,18 +108,18 @@ class const EventRenderer( (settings.hideMembershipEvents, settings.hideServerAclEvents), ); - // Knocks and their outcomes stay visible, as moderators need to act on them - if (hideMembershipEvents && - event.content is MembershipContent && - ![event.content, event.previousContent].any( - (content) => content is MembershipContent && content.status == .knock, - )) { - return SizedBox.shrink(); - } + final isHidden = + // Knocks and their outcomes stay visible, as moderators need to act + // on them + (hideMembershipEvents && + event.content is MembershipContent && + ![event.content, event.previousContent].any( + (content) => + content is MembershipContent && content.status == .knock, + )) || + (hideServerAclEvents && event.content is ServerACLContent); - if (hideServerAclEvents && event.content is ServerACLContent) { - return SizedBox.shrink(); - } + if (isHidden && !textOnly) return SizedBox.shrink(); final isGrouped = (previousEvent?.isShownAsMessage ?? false) && @@ -170,15 +172,18 @@ class const EventRenderer( onTapReply: jumpToEvent == null ? null : () async { - final replyEvent = await ref.read( + final replyRowId = await ref.read( EventController.provider( .new(roomId: roomId, eventId: event.replyTo!), ).future, ); - if (replyEvent != null) { - await jumpToEvent!(replyEvent.rowId); + if (replyRowId != null) { + await jumpToEvent!(replyRowId); } }, + onTapThread: onOpenThread == null + ? null + : () => onOpenThread!(event.eventId), isGrouped: isGrouped, maxLines: maxLines, textOnly: textOnly, @@ -236,11 +241,11 @@ class const EventRenderer( ), ) .value) { - final poll? + final pollRowId? when ref .watch( PollVotesController.provider( - .new(roomId: roomId, eventRowId: poll.rowId), + .new(roomId: roomId, eventRowId: pollRowId), ), ) .value @@ -430,6 +435,15 @@ class const EventRenderer( globalPosition: details.globalPosition, ); + final threadSummary = switch (onOpenThread) { + final onOpenThread? when !textOnly && child is! MessageRenderer => + ThreadSummary(event, onTap: () => onOpenThread(event.eventId)), + _ => null, + }; + final belowIndent = child is MessageRenderer + ? 4.0 + : 8 + GenericEventRenderer.textIndent(context); + return Column( crossAxisAlignment: .start, children: [ @@ -492,6 +506,7 @@ class const EventRenderer( ...[ if (child is! MessageRenderer) ReactionRow(event), + ?threadSummary, // Outside the message itself, so it doesn't move when swiped if (readReceiptsChat case final chat? when event.isShownAsMessage) @@ -523,6 +538,7 @@ class const EventRenderer( RoomChatController.provider(( roomId: roomId, contextualEvent: null, + threadRoot: null, )).notifier, ) .hideClientSticky(event.rowId), @@ -530,13 +546,20 @@ class const EventRenderer( ], ), ), - ].map((child) => Padding(padding: .only(left: 4), child: child)), + ].map( + (child) => Padding( + padding: .only(left: belowIndent), + child: child, + ), + ), ] else if (textOnly) Text( event.redactedBy == null ? "Unknown event type" : "Redacted event", style: errorStyle, - ), + ) + else if (threadSummary != null) + Padding(padding: .only(left: 4), child: threadSummary), ], ); } diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart index 3922aed..100643b 100644 --- a/lib/widgets/renderers/generic_event.dart +++ b/lib/widgets/renderers/generic_event.dart @@ -13,6 +13,14 @@ class const GenericEventRenderer( final List details = const [], super.key, }) extends StatelessWidget { + static const _iconPadding = 4.0; + static const _spacing = 8.0; + + /// How far the text is from the start, past the icon, so what's shown under + /// the event can line up with it. + static double textIndent(BuildContext context) => + (IconTheme.of(context).size ?? 24) + _iconPadding * 2 + _spacing; + @override Widget build(BuildContext context) { // Only previewing the event, so it should stay short @@ -22,12 +30,15 @@ class const GenericEventRenderer( return Padding( padding: .symmetric(vertical: 4), child: Row( - spacing: 8, + spacing: _spacing, mainAxisSize: .min, // At the top, by the text, rather than the middle of any details crossAxisAlignment: .start, children: [ - Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), + Padding( + padding: .symmetric(horizontal: _iconPadding), + child: Icon(icon), + ), Flexible( child: Column( mainAxisSize: .min, diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index c5f6a28..3438779 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -27,10 +27,15 @@ import "package:nexus/widgets/event_preview.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; import "package:nexus/widgets/renderers/poll.dart"; +import "package:nexus/widgets/thread_summary.dart"; class const MessageRenderer( final Event event, { final VoidCallback? onTapReply, + + /// If set, thread roots show a summary of their thread, calling this when + /// tapped. + final VoidCallback? onTapThread, final bool textOnly = false, final bool isGrouped = false, final int? maxLines, @@ -255,9 +260,10 @@ class const MessageRenderer( ), )) { AsyncData(:final value?) || - AsyncLoading( - :final value?, - ) => EventPreview(value), + AsyncLoading(:final value?) => EventPreview( + value, + roomId: event.roomId, + ), AsyncError _ => Text( "An error occurred while fetching the reply", style: errorStyle, @@ -270,6 +276,8 @@ class const MessageRenderer( ), rendered, if (!textOnly) ReactionRow(event), + if (onTapThread case final onTapThread? when !textOnly) + ThreadSummary(event, onTap: onTapThread), ], ), ), diff --git a/lib/widgets/renderers/pinned_events_diff.dart b/lib/widgets/renderers/pinned_events_diff.dart index 9f3d34c..41bd3a1 100644 --- a/lib/widgets/renderers/pinned_events_diff.dart +++ b/lib/widgets/renderers/pinned_events_diff.dart @@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/models/content/message.dart"; @@ -54,17 +55,21 @@ class const PinnedEventsDiff( ), )) { // Keeps showing the event while it reloads - AsyncValue(value: final event?) => InkWell( + AsyncValue(value: final rowId?) => InkWell( borderRadius: .circular(4), hoverColor: color.withValues(alpha: 0.12), - onTap: jumpToEvent == null - ? null - : () => jumpToEvent!(event.rowId), + onTap: jumpToEvent == null ? null : () => jumpToEvent!(rowId), child: Row( mainAxisSize: .min, children: [ // Other events already say who sent them - if (event.content is MessageContent) ...[ + if (ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.events[rowId], + ), + ) + case final event? + when event.content is MessageContent) ...[ MessageDisplayname( event, clickable: false, @@ -81,8 +86,8 @@ class const PinnedEventsDiff( Flexible( child: IgnorePointer( child: EventRenderer( - event.rowId, - roomId: event.roomId, + rowId, + roomId: roomId, textOnly: true, maxLines: 1, ), diff --git a/lib/widgets/renderers/poll.dart b/lib/widgets/renderers/poll.dart index 4ef723a..d3a8cb0 100644 --- a/lib/widgets/renderers/poll.dart +++ b/lib/widgets/renderers/poll.dart @@ -66,6 +66,7 @@ class const PollRenderer(final Event event, final PollStart poll, {super.key}) final notifier = RoomChatController.provider(( roomId: event.roomId, contextualEvent: null, + threadRoot: null, )).notifier; Future run( diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index 3bdc570..b3a5c44 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -17,6 +17,7 @@ final class const RoomAppbar({ final void Function(BuildContext context)? onOpenMemberList, final void Function()? onOpenPinnedMessagesList, final void Function()? onOpenSearch, + final void Function()? onOpenThreads, super.key, }) extends ConsumerWidget implements PreferredSizeWidget { @override @@ -62,11 +63,6 @@ final class const RoomAppbar({ room == null ? [] : [ - IconButton( - onPressed: onOpenSearch?.call, - icon: Icon(Icons.search), - tooltip: "Search this room", - ), IconButton( onPressed: onOpenPinnedMessagesList?.call, icon: Icon(Icons.push_pin), @@ -77,7 +73,11 @@ final class const RoomAppbar({ tooltip: "Open member list", icon: Icon(Icons.people), ), - RoomMenuButton(room), + RoomMenuButton( + room, + onOpenSearch: onOpenSearch, + onOpenThreads: onOpenThreads, + ), ], ), ); diff --git a/lib/widgets/room_chat/chat_body.dart b/lib/widgets/room_chat/chat_body.dart new file mode 100644 index 0000000..1446f12 --- /dev/null +++ b/lib/widgets/room_chat/chat_body.dart @@ -0,0 +1,169 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:measure_size/measure_size.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/tombstone.dart"; +import "package:nexus/helpers/hooks/chat_composer.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/composer/typing_indicator.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/room_chat/chat_timeline.dart"; +import "package:nexus/widgets/tombstone_banner.dart"; + +final class const ChatBody({ + required final ChatParams chat, + required final ChatScroll scroll, + required final JumpToEvent jumpToEvent, + required final IList Function(Event event) getEventOptions, + required final ChatComposer composer, + final void Function(String threadRoot)? onOpenThread, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final roomId = chat.roomId; + final repliedEventRowId = composer.repliedEventRowId; + + final composerSize = useState(64); + final typingIndicatorSize = useState(0); + + final controllerProvider = RoomChatController.provider(chat); + final notifier = ref.watch(controllerProvider.notifier); + final controllerData = ref.watch(controllerProvider); + + final swipeToReply = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.swipeToReply ?? false, + ), + ); + final tombstone = ref.watch(TombstoneController.provider(roomId)); + final canReply = + tombstone == null && + ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + ); + + return Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncLoading(value: _?) || AsyncData(value: _) => ChatTimeline( + scroll: scroll, + jumpToEvent: jumpToEvent, + chat: chat, + getEventOptions: getEventOptions, + onSwipe: swipeToReply && canReply + ? (rowId) => composer.relate(rowId, .reply) + : null, + onOpenThread: onOpenThread, + composerHeight: composerSize.value, + typingIndicatorHeight: typingIndicatorSize.value, + ), + AsyncLoading _ => Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), + ), + AnimatedPositioned( + duration: Durations.short4, + bottom: composerSize.value, + left: 12, + // Leaves room for the jump to bottom button when it's shown + right: scroll.atBottom ? 12 : 72, + child: MeasureSize( + onChange: (size) => typingIndicatorSize.value = size.height, + child: TypingIndicator(roomId), + ), + ), + Positioned( + right: 16, + bottom: composerSize.value, + child: IgnorePointer( + ignoring: scroll.atBottom, + child: AnimatedOpacity( + opacity: scroll.atBottom ? 0 : 1, + duration: Durations.short4, + child: FloatingActionButton.small( + onPressed: scroll.jumpToBottom, + child: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MeasureSize( + onChange: (size) => composerSize.value = size.height, + child: switch (tombstone) { + final tombstone? => TombstoneBanner(tombstone), + null => Composer( + roomId, + threadRoot: chat.threadRoot, + node: composer.node, + onSend: (text, {required shouldMention, required tags}) => + notifier + .send( + text, + tags: tags, + relationType: composer.relationType.value, + shouldMention: shouldMention, + relatedEventRowId: composer.relatedEvent.value, + ) + .onError(showError), + onSendSticker: (shortcode, image, {required shouldMention}) => + notifier + .sendSticker( + shortcode, + image, + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ) + .onError(showError), + onSendPoll: (poll, {required shouldMention}) => notifier + .sendPoll( + poll, + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ) + .onError(showError), + onSendLocation: (location, {required shouldMention}) => notifier + .sendLocation( + location, + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ) + .onError(showError), + onSendBotCommand: (draft) => notifier + .sendBotCommand(draft, repliedEventRowId: repliedEventRowId) + .onError(showError), + relationType: composer.relationType.value, + relatedEvent: composer.relatedEvent.value, + onDismiss: () { + composer.relatedEvent.value = null; + composer.node.requestFocus(); + }, + ), + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index c949344..9501a17 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -12,12 +12,12 @@ class const ChatTimeline({ required final ChatScroll scroll, required final JumpToEvent jumpToEvent, required final IList Function(Event) getEventOptions, - required final int? highlightedEvent, required final double composerHeight, /// The typing indicator's height, or zero while it's hidden. required final double typingIndicatorHeight, final void Function(int rowId)? onSwipe, + final void Function(String threadRoot)? onOpenThread, super.key, }) extends StatelessWidget { Widget eventRow( @@ -30,58 +30,62 @@ class const ChatTimeline({ previousEventRowId: previousEventRowId, roomId: chat.roomId, jumpToEvent: jumpToEvent, + onOpenThread: onOpenThread, getEventOptions: getEventOptions, onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), readReceiptsChat: chat, ), key: key, - isHighlighted: highlightedEvent == eventRowId, + isHighlighted: scroll.highlightedEvent == eventRowId, ); @override - Widget build(BuildContext context) => CustomScrollView( - reverse: true, - center: scroll.centerKey, - keyboardDismissBehavior: .onDrag, - controller: scroll.scrollController, - slivers: [ - SliverToBoxAdapter( - child: Column( - children: [ - // Makes room for the typing indicator, with a small gap above it, - // so it doesn't cover messages - AnimatedContainer( - duration: Durations.medium2, - curve: Curves.easeInOutCubicEmphasized, - height: typingIndicatorHeight == 0 - ? 0 - : typingIndicatorHeight + 4, + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => TweenAnimationBuilder( + // Makes room for the typing indicator, with a small gap above it, so it + // doesn't cover messages + tween: Tween( + end: typingIndicatorHeight == 0 ? 0.0 : typingIndicatorHeight + 4, + ), + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + builder: (context, typingIndicatorGap, _) { + final bottomPadding = composerHeight + typingIndicatorGap; + + return CustomScrollView( + reverse: true, + center: scroll.centerKey, + // Offset 0 is the newest event above the padding, so short + // timelines can't be scrolled past either end + anchor: (bottomPadding / constraints.maxHeight).clamp(0, 1), + keyboardDismissBehavior: .onDrag, + controller: scroll.scrollController, + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: bottomPadding)), + + SuperSliverList.builder( + itemCount: scroll.liveRows.length, + itemBuilder: (_, index) => eventRow( + scroll.liveRows[index], + index > 0 + ? scroll.liveRows.getOrNull(index - 1) + : scroll.historyRows.firstOrNull, + key: scroll.keyFor(scroll.liveRows[index]), + ), + ), + + SuperSliverList.builder( + key: scroll.centerKey, + itemCount: scroll.historyRows.length, + itemBuilder: (_, index) => eventRow( + scroll.historyRows[index], + scroll.historyRows.getOrNull(index + 1), + key: scroll.keyFor(scroll.historyRows[index]), + ), ), - SizedBox(height: composerHeight), ], - ), - ), - - SuperSliverList.builder( - itemCount: scroll.liveRows.length, - itemBuilder: (_, index) => eventRow( - scroll.liveRows[index], - index > 0 - ? scroll.liveRows.getOrNull(index - 1) - : scroll.historyRows.firstOrNull, - key: scroll.keyFor(scroll.liveRows[index]), - ), - ), - - SuperSliverList.builder( - key: scroll.centerKey, - itemCount: scroll.historyRows.length, - itemBuilder: (_, index) => eventRow( - scroll.historyRows[index], - scroll.historyRows.getOrNull(index + 1), - key: scroll.keyFor(scroll.historyRows[index]), - ), - ), - ], + ); + }, + ), ); } diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index d336831..39f2002 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -1,32 +1,23 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.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/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/member_list_opened.dart"; -import "package:nexus/controllers/power_level.dart"; -import "package:nexus/controllers/settings.dart"; -import "package:nexus/controllers/tombstone.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/helpers/hooks/chat_composer.dart"; import "package:nexus/helpers/hooks/chat_scroll.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/composer/typing_indicator.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/search_drawer.dart"; import "package:nexus/widgets/sync_status_banner.dart"; -import "package:nexus/widgets/tombstone_banner.dart"; -import "package:nexus/main.dart"; -import "package:nexus/widgets/room_chat/chat_timeline.dart"; +import "package:nexus/widgets/thread_drawer.dart"; +import "package:nexus/widgets/threads_drawer.dart"; +import "package:nexus/widgets/room_chat/chat_body.dart"; import "package:nexus/helpers/extensions/build_event_options.dart"; import "package:nexus/controllers/jump_request.dart"; @@ -38,21 +29,23 @@ final class const RoomChat({ }) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final relatedEvent = useState(null); - final relationType = useState(RelationType.reply); - final repliedEventRowId = relationType.value == .reply - ? relatedEvent.value - : null; + final composer = ChatComposer.use(); final pendingJump = ref.read(JumpRequestController.provider); - final contextualEvent = useState( - pendingJump != null && pendingJump.roomId == this.roomId - ? pendingJump.rowId - : null, - ); - final highlightedEvent = useState(null); + final contextualEvent = useState(switch (pendingJump) { + // Threaded events open their thread instead, leaving the timeline + (:final roomId, :final rowId) + when roomId == this.roomId && + ref + .read(RoomsController.provider)[roomId] + ?.events[rowId] + ?.threadRoot == + null => + rowId, + _ => null, + }); - final composerSize = useState(64); - final typingIndicatorSize = useState(0); + final thread = useState<({String root, int? event})?>(null); + final threadScaffold = useMemoized(GlobalKey.new); final userId = ref.watch(ClientStateController.provider)?.userId; final memberListOpened = ref @@ -93,6 +86,7 @@ final class const RoomChat({ final ChatParams chat = ( roomId: roomId, contextualEvent: contextualEvent.value, + threadRoot: null, ); final controllerProvider = RoomChatController.provider(chat); final notifier = ref.watch(controllerProvider.notifier); @@ -116,49 +110,24 @@ final class const RoomChat({ }, ); - final lastComposerHeight = useRef(composerSize.value); + void openThread(String root, {int? event}) { + thread.value = (root: root, event: event); - useEffect(() { - final delta = composerSize.value - lastComposerHeight.value; - lastComposerHeight.value = composerSize.value; - - if (delta != 0) { - final wasAtBottom = scroll.atBottom; - - WidgetsBinding.instance.addPostFrameCallback((_) { - final controller = scroll.scrollController; - if (!controller.hasClients) return; - - final position = controller.position; - controller.jumpTo( - wasAtBottom ? position.minScrollExtent : position.pixels - delta, - ); - }); - } - - return null; - }, [composerSize.value]); - - final composerNode = useFocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && event.logicalKey == .escape) { - relatedEvent.value = null; - return .handled; - } - - return .ignored; - }, - ); + // The drawer is only there once rebuilt with the thread + WidgetsBinding.instance.addPostFrameCallback( + (_) => threadScaffold.currentState?.openEndDrawer(), + ); + } + // Threaded events aren't in the timeline, only their thread Future jumpToEvent(int rowId) async { - highlightedEvent.value = rowId; + final threadRoot = ref + .read(RoomsController.provider)[roomId] + ?.events[rowId] + ?.threadRoot; - await scroll.jumpToEvent(rowId); - await Future.delayed(Durations.extralong1, () { - if (highlightedEvent.value == rowId) { - highlightedEvent.value = null; - } - }); + if (threadRoot == null) return scroll.jumpToEvent(rowId); + openThread(threadRoot, event: rowId); } useEffect(() { @@ -182,226 +151,93 @@ final class const RoomChat({ return null; }, [jumpRowId]); - void onRelation(int rowId, RelationType type) { - relatedEvent.value = rowId; - relationType.value = type; - composerNode.requestFocus(); - } - IList getEventOptions(Event event) => event.buildEventOptions( context: context, ref: ref, roomId: roomId, userId: userId, - onRelation: onRelation, - ); - - final swipeToReply = ref.watch( - SettingsController.provider.select( - (settings) => settings.value?.swipeToReply ?? false, - ), - ); - final tombstone = ref.watch(TombstoneController.provider(roomId)); - final canReply = - tombstone == null && - ref.watch( - PowerLevelController.provider( - .new(eventType: .message, roomId: roomId), - ), + onRelation: composer.relate, + onStartThread: openThread, ); return Scaffold( + key: threadScaffold, endDrawerEnableOpenDragGesture: false, - endDrawer: SearchDrawer( - roomId, - getEventOptions: getEventOptions, - jumpToEvent: jumpToEvent, - ), - body: Builder( - builder: (searchContext) => Scaffold( - endDrawerEnableOpenDragGesture: false, - endDrawer: PinnedEventsDrawer( - roomId, - getEventOptions: getEventOptions, - jumpToEvent: jumpToEvent, - ), - body: Builder( - builder: (middleContext) => Scaffold( - endDrawer: showMembersByDefault ? null : MemberList(roomId), - appBar: RoomAppbar( - roomId: roomId, - isDesktop: isDesktop, - onOpenDrawer: Scaffold.of(context).openDrawer, - onOpenMemberList: (thisContext) { - ref - .read(MemberListOpenedController.provider.notifier) - .set(!memberListOpened); - Scaffold.of(thisContext).openEndDrawer(); - }, - onOpenPinnedMessagesList: Scaffold.of(middleContext) - .openEndDrawer, - onOpenSearch: Scaffold.of(searchContext).openEndDrawer, - ), - body: Column( - children: [ - const SyncStatusBanner(), - Expanded( - child: Row( + endDrawer: switch (thread.value) { + (:final root, :final event) => ThreadDrawer( + roomId, + threadRoot: root, + initialEvent: event, + jumpToRoomEvent: jumpToEvent, + userId: userId, + key: ValueKey((roomId, root, event)), + ), + null => null, + }, + body: Scaffold( + endDrawerEnableOpenDragGesture: false, + endDrawer: SearchDrawer( + roomId, + getEventOptions: getEventOptions, + jumpToEvent: jumpToEvent, + ), + body: Builder( + builder: (searchContext) => Scaffold( + endDrawerEnableOpenDragGesture: false, + endDrawer: ThreadsDrawer(roomId, onOpenThread: openThread), + body: Builder( + builder: (threadsContext) => Scaffold( + endDrawerEnableOpenDragGesture: false, + endDrawer: PinnedEventsDrawer( + roomId, + getEventOptions: getEventOptions, + jumpToEvent: jumpToEvent, + ), + body: Builder( + builder: (middleContext) => Scaffold( + endDrawer: showMembersByDefault ? null : MemberList(roomId), + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + onOpenMemberList: (thisContext) { + ref + .read(MemberListOpenedController.provider.notifier) + .set(!memberListOpened); + Scaffold.of(thisContext).openEndDrawer(); + }, + onOpenPinnedMessagesList: Scaffold.of(middleContext) + .openEndDrawer, + onOpenSearch: Scaffold.of(searchContext).openEndDrawer, + onOpenThreads: Scaffold.of(threadsContext).openEndDrawer, + ), + body: Column( children: [ + const SyncStatusBanner(), Expanded( - child: Stack( + child: Row( children: [ - Positioned.fill( - child: Padding( - padding: .symmetric(horizontal: 4), - child: switch (controllerData) { - AsyncLoading(value: _?) || - AsyncData(value: _) => ChatTimeline( - scroll: scroll, - chat: chat, - jumpToEvent: jumpToEvent, - getEventOptions: getEventOptions, - onSwipe: swipeToReply && canReply - ? (rowId) => onRelation(rowId, .reply) - : null, - highlightedEvent: highlightedEvent.value, - composerHeight: composerSize.value, - typingIndicatorHeight: - typingIndicatorSize.value, - ), - AsyncLoading _ => Loading(), - AsyncError( - :final error, - :final stackTrace, - ) => - ErrorDialog(error, stackTrace), - }, - ), - ), - AnimatedPositioned( - duration: Durations.short4, - bottom: composerSize.value, - left: 12, - // Leaves room for the jump to bottom button when it's shown - right: scroll.atBottom ? 12 : 72, - child: MeasureSize( - onChange: (size) => - typingIndicatorSize.value = size.height, - child: TypingIndicator(roomId), - ), - ), - Positioned( - right: 16, - bottom: composerSize.value, - child: IgnorePointer( - ignoring: scroll.atBottom, - child: AnimatedOpacity( - opacity: scroll.atBottom ? 0 : 1, - duration: Durations.short4, - child: FloatingActionButton.small( - onPressed: scroll.jumpToBottom, - child: const Icon( - Icons.keyboard_arrow_down, - ), - ), - ), - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: MeasureSize( - onChange: (size) => - composerSize.value = size.height, - child: switch (tombstone) { - final tombstone? => TombstoneBanner( - tombstone, - ), - null => Composer( - roomId, - node: composerNode, - onSend: - ( - text, { - required shouldMention, - required tags, - }) => notifier - .send( - text, - tags: tags, - relationType: - relationType.value, - shouldMention: shouldMention, - relatedEventRowId: - relatedEvent.value, - ) - .onError(showError), - onSendSticker: - ( - shortcode, - image, { - required shouldMention, - }) => notifier - .sendSticker( - shortcode, - image, - shouldMention: shouldMention, - repliedEventRowId: - repliedEventRowId, - ) - .onError(showError), - onSendPoll: - (poll, {required shouldMention}) => - notifier - .sendPoll( - poll, - shouldMention: - shouldMention, - repliedEventRowId: - repliedEventRowId, - ) - .onError(showError), - onSendLocation: - ( - location, { - required shouldMention, - }) => notifier - .sendLocation( - location, - shouldMention: shouldMention, - repliedEventRowId: - repliedEventRowId, - ) - .onError(showError), - onSendBotCommand: (draft) => notifier - .sendBotCommand( - draft, - repliedEventRowId: - repliedEventRowId, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedEvent.value, - onDismiss: () { - relatedEvent.value = null; - composerNode.requestFocus(); - }, - ), - }, + Expanded( + child: ChatBody( + chat: chat, + scroll: scroll, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + composer: composer, + onOpenThread: openThread, ), ), + + if (memberListOpened && showMembersByDefault) + MemberList(roomId), ], ), ), - - if (memberListOpened && showMembersByDefault) - MemberList(roomId), ], ), ), - ], + ), ), ), ), diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index e1e0e84..2d113d0 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -16,6 +16,8 @@ import "package:nexus/main.dart"; final class const RoomMenuButton( final Room? room, { final IList children = const .empty(), + final VoidCallback? onOpenSearch, + final VoidCallback? onOpenThreads, super.key, }) extends ConsumerWidget { @override @@ -27,6 +29,19 @@ final class const RoomMenuButton( icon: const Icon(Icons.more_vert), tooltip: "Room options", itemBuilder: (_) => [ + if (onOpenSearch case final onOpenSearch?) + PopupMenuItem( + onTap: onOpenSearch, + child: ListTile(leading: Icon(Icons.search), title: Text("Search")), + ), + if (onOpenThreads case final onOpenThreads?) + PopupMenuItem( + onTap: onOpenThreads, + child: ListTile( + leading: Icon(Icons.forum), + title: Text("View Threads"), + ), + ), PopupMenuItem( onTap: () async { if (room != null) await client.markRead(room!); diff --git a/lib/widgets/thread_drawer.dart b/lib/widgets/thread_drawer.dart new file mode 100644 index 0000000..88450c9 --- /dev/null +++ b/lib/widgets/thread_drawer.dart @@ -0,0 +1,122 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/build_event_options.dart"; +import "package:nexus/helpers/hooks/chat_composer.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/widgets/room_chat/chat_body.dart"; + +/// The thread from [threadRoot], with a composer to send to it. +final class const ThreadDrawer( + final String roomId, { + required final String threadRoot, + + /// An event in the thread to scroll to once it loads. + final int? initialEvent, + required final String userId, + + /// Jumps to events outside this thread, like ones replied to from it. + required final JumpToEvent jumpToRoomEvent, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final composer = ChatComposer.use(); + final contextualEvent = useState(initialEvent); + + final ChatParams chat = ( + roomId: roomId, + contextualEvent: contextualEvent.value, + threadRoot: threadRoot, + ); + final controllerProvider = RoomChatController.provider(chat); + final controllerData = ref.watch(controllerProvider); + + final scroll = ChatScroll.use( + controllerData: controllerData, + paginate: ref.watch(controllerProvider.notifier).paginate, + contextualEvent: contextualEvent, + markRead: () async { + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + if (room != null) { + await ref + .read(ClientController.provider.notifier) + .markRead(room, threadRoot: threadRoot); + } + }, + ); + + final initialEventLoaded = + controllerData.value?.timeline.contains(initialEvent) ?? false; + // Only once, so returning to the newest events doesn't jump back to it + final jumpedToInitialEvent = useRef(false); + useEffect(() { + if (initialEvent case final rowId? + when initialEventLoaded && !jumpedToInitialEvent.value) { + jumpedToInitialEvent.value = true; + WidgetsBinding.instance.addPostFrameCallback( + (_) => scroll.jumpToEvent(rowId), + ); + } + + return null; + }, [initialEventLoaded]); + + // This chat only has the thread, so other events are left to the room. + // Ones in the thread that aren't loaded are loaded around, like the room. + Future jumpToEvent(int rowId) async { + final isInThread = + ref + .read(RoomsController.provider)[roomId] + ?.events[rowId] + ?.threadRoot == + threadRoot; + if (isInThread || + (controllerData.value?.timeline.contains(rowId) ?? false)) { + return scroll.jumpToEvent(rowId); + } + + Scaffold.of(context).closeEndDrawer(); + await jumpToRoomEvent(rowId); + } + + return Drawer( + width: 400, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.forum), + title: Text("Thread"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close thread", + ), + ], + ), + body: ChatBody( + chat: chat, + scroll: scroll, + jumpToEvent: jumpToEvent, + getEventOptions: (event) => event.buildEventOptions( + context: context, + ref: ref, + roomId: roomId, + userId: userId, + onRelation: composer.relate, + ), + composer: composer, + ), + ), + ); + } +} diff --git a/lib/widgets/thread_summary.dart b/lib/widgets/thread_summary.dart new file mode 100644 index 0000000..fba44ba --- /dev/null +++ b/lib/widgets/thread_summary.dart @@ -0,0 +1,88 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/thread_events.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"; + +/// How many replies are in the thread from [root], and the latest one, or nothing if it has none. +class const ThreadSummary( + final Event root, { + required final VoidCallback onTap, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final thread = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[root.roomId]?.thread(root), + ), + ); + + if (thread case (:final count, :final latest) when count > 0) { + return Card( + margin: .only(top: 8), + color: colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: onTap, + child: Padding( + padding: .symmetric(vertical: 12, horizontal: 16), + child: Column( + crossAxisAlignment: .start, + spacing: 8, + children: [ + Row( + mainAxisSize: .min, + spacing: 6, + children: [ + Icon(Icons.forum, size: 16, color: colorScheme.primary), + Text( + "$count ${count == 1 ? "reply" : "replies"}", + style: theme.textTheme.labelLarge?.copyWith( + color: colorScheme.primary, + ), + ), + Icon( + Icons.chevron_right, + size: 16, + color: colorScheme.primary, + ), + ], + ), + if (latest != null) + IgnorePointer( + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + MessageAvatar(latest, height: 24), + Flexible( + child: MessageDisplayname(latest, shouldWrap: false), + ), + Flexible( + flex: 2, + child: EventRenderer( + latest.rowId, + roomId: latest.roomId, + textOnly: true, + maxLines: 1, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + return SizedBox.shrink(); + } +} diff --git a/lib/widgets/threads_drawer.dart b/lib/widgets/threads_drawer.dart new file mode 100644 index 0000000..68767f3 --- /dev/null +++ b/lib/widgets/threads_drawer.dart @@ -0,0 +1,146 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/thread_list.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/thread_events.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/event_preview.dart"; +import "package:nexus/widgets/timestamp.dart"; + +final class const ThreadsDrawer( + final String roomId, { + required final void Function(String threadRoot) onOpenThread, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final threads = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.threads ?? const .empty(), + ), + ); + final serverProvider = ThreadListController.provider(roomId); + final server = ref.watch(serverProvider); + final theme = Theme.of(context); + + void open(String threadRoot) { + Navigator.of(context).pop(); + onOpenThread(threadRoot); + } + + return Drawer( + width: 400, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.forum), + title: Text("Threads"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close threads", + ), + ], + ), + body: switch (server) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncValue(hasValue: true, value: final nextBatch) + when threads.isNotEmpty => + ListView.builder( + padding: .all(8), + itemCount: threads.length + (nextBatch == null ? 0 : 1), + itemBuilder: (context, index) { + if (index == threads.length) { + return Center( + child: TextButton( + onPressed: server.isLoading + ? null + : () => ref + .read(serverProvider.notifier) + .loadMore() + .onError(showError), + child: Text("Load more"), + ), + ); + } + + final (:root, :info) = threads[index]; + return Card( + margin: .symmetric(vertical: 4), + color: theme.colorScheme.surfaceContainerHigh, + clipBehavior: .antiAlias, + child: InkWell( + onTap: () => open(root.eventId), + child: Padding( + padding: .symmetric(vertical: 8, horizontal: 12), + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + EventPreview(root.rowId, roomId: roomId), + Row( + spacing: 6, + children: [ + Icon( + Icons.forum, + size: 16, + color: theme.colorScheme.primary, + ), + Text( + "${info.count} ${info.count == 1 ? "reply" : "replies"}", + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + ), + ), + Spacer(), + if (info.latest case final latest?) ...[ + Text( + "Last active", + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.grey, + ), + ), + Timestamp(latest.timestamp), + ], + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.primary, + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + AsyncLoading() => Loading(), + AsyncData() => Center( + child: Column( + mainAxisSize: .min, + children: [ + Icon( + Icons.forum_outlined, + size: 48, + color: theme.colorScheme.onSurface, + ), + SizedBox(height: 12), + Text("No threads", style: theme.textTheme.headlineSmall), + ], + ), + ), + }, + ), + ); + } +}