add thread support

Fixes #129
This commit is contained in:
Henry Hiles 2026-11-05 19:14:46 -05:00
commit 7f7bdbf525
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
42 changed files with 1477 additions and 577 deletions

2
gomuks

@ -1 +1 @@
Subproject commit 9e6254b703a841d1e9ee64dbaf90d5a1b798696e
Subproject commit 46ab9e9afa54c12f585cbbf559c395b9c2dcb545

View file

@ -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, (String, MessageContent?)?, String>(
AttachmentController.new,
);
.autoDispose<
AttachmentController,
(String, MessageContent?)?,
ComposerParams
>(AttachmentController.new);
}

View file

@ -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<IList<BotArgumentSuggestion>> {
@override
IList<BotArgumentSuggestion> 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<BotArgumentSuggestion>,
String
ComposerParams
>(BotArgumentSuggestionsController.new);
}

View file

@ -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<BotCommandDraft?> {
@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<BotCommandDraftController, BotCommandDraft?, String>(
.family<BotCommandDraftController, BotCommandDraft?, ComposerParams>(
BotCommandDraftController.new,
);
}

View file

@ -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<int> {
Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson()));
Future<PaginateManual> getThreads(GetThreadsRequest request) async =>
.fromJson(await _sendCommand("get_threads", request.toJson()));
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
@ -438,10 +443,16 @@ class ClientController(final String? accountId) extends AsyncNotifier<int> {
{"room_id": roomId, "timeout": timeout.inMilliseconds},
);
Future<void> 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<void> 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<int> {
"room_id": room.metadata!.id,
"receipt_type": sendPublic ? "m.read" : "m.read.private",
"event_id": event.eventId,
"thread_id": ?threadRoot,
});
}

View file

@ -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<Event?> {
extends AsyncNotifier<int?> {
@override
Future<Event?> 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<int?> 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, Event?, GetEventRequest>(
EventController.new,
);
.autoDispose<EventController, int?, GetEventRequest>(EventController.new);
}

View file

@ -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();

View file

@ -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,

View file

@ -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<RoomChat?> {
@ -47,19 +56,41 @@ class RoomChatController(final ChatParams info)
ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
}
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: ref.watch(RoomsController.provider.select(threadTimeline)),
hasMoreBackward: nextBatch != null,
hasMoreForward: false,
);
}
if (info.threadRoot == null) {
final timeline = room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((e) => e.value)
.toIList()
.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),
(rooms) => (
rooms[info.roomId]?.timeline,
rooms[info.roomId]?.clientSticky,
),
),
);
@ -68,7 +99,9 @@ class RoomChatController(final ChatParams info)
hasMoreBackward: room.hasMore,
hasMoreForward: false,
);
} else {
}
}
final context = await client.getEventContext(
.new(
roomId: info.roomId,
@ -76,22 +109,119 @@ class RoomChatController(final ChatParams info)
),
);
// `before` is in reverse chronological order
final events = context.before.reversed
final contextEvents = context.before.reversed
.toIList()
.add(context.event)
.addAll(context.after);
ref
.read(RoomsController.provider.notifier)
.storeEvents(events.addAll(context.relatedEvents));
.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(
older.events
.addAll(older.relatedEvents)
.addAll(newer.events)
.addAll(newer.relatedEvents),
);
return .new(
timeline: .new(events.map((element) => element.rowId)),
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<int?> 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<int> threadTimeline(IMap<String, Room> 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<void> deleteMessage(Event event, {String? reason}) => ref
.read(ClientController.provider.notifier)
@ -133,13 +263,28 @@ 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);
try {
if (chat?.historicalData == null) {
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[info.roomId]))
.read(
RoomsController.provider.select((value) => value[info.roomId]),
)
?.timeline
.keys;
final response = await client.paginate(
@ -158,16 +303,18 @@ class RoomChatController(final ChatParams info)
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
info.roomId: Room(
events: IMap.fromIterable(
.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,
timeline: IMap.fromIterable(
// ignore: unused_result - I am using it you donkey
timeline: .fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
@ -179,6 +326,7 @@ class RoomChatController(final ChatParams info)
final paginationResponse = await client.paginateManual(
.new(
roomId: info.roomId,
threadRoot: info.threadRoot,
direction: direction,
since: direction == .forward
? chat!.historicalData!.end
@ -189,15 +337,22 @@ class RoomChatController(final ChatParams info)
ref
.read(RoomsController.provider.notifier)
.storeEvents(
paginationResponse.events.addAll(paginationResponse.relatedEvents),
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(
@ -219,12 +374,18 @@ class RoomChatController(final ChatParams info)
: null) ??
chat.historicalData!.start,
end:
(direction == .forward ? paginationResponse.nextBatch : null) ??
(direction == .forward
? paginationResponse.nextBatch
: null) ??
chat.historicalData!.end,
),
),
);
}
} catch (_) {
state = .data(chat);
rethrow;
}
}
Future<void> send(
@ -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),
),
);

View file

@ -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<String?> {
@override
Future<String?> build() => fetch(null);
Future<String?> 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<void> 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, String?, String>(
ThreadListController.new,
);
}

View file

@ -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<String?> {
@override
Future<String?> build() => fetch(null);
Future<String?> 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<void> 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, String?, ThreadParams>(
ThreadRepliesController.new,
);
}

View file

@ -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() ||

View file

@ -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;

View file

@ -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<Thread> 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<Event> 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,
};
}

View file

@ -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<int?> relatedEvent,
required final ValueNotifier<RelationType> relationType,
required final FocusNode node,
}) {
factory use() {
final relatedEvent = useState<int?>(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();
}
}

View file

@ -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<void> Function() jumpToBottom,
required final GlobalKey Function(int eventRowId) keyFor,
@ -28,6 +30,8 @@ final class ChatScroll({
final anchorId = useState<int?>(null);
final atBottom = useState(true);
final pendingJump = useState<int?>(null);
final highlightedEvent = useState<int?>(null);
final hookContext = useContext();
final pendingJumpDone = useRef<Completer<void>?>(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<int?>(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,14 +177,7 @@ final class ChatScroll({
],
);
return .new(
historyRows: split.history,
liveRows: split.live,
centerKey: centerKey,
scrollController: scrollController,
atBottom: atBottom.value,
keyFor: keyFor,
jumpToEvent: (int rowId) async {
Future<void> scrollToEvent(int rowId) async {
if (!scrollController.hasClients) return;
final context = keyFor(rowId).currentContext;
@ -204,6 +201,25 @@ final class ChatScroll({
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 {
highlightedEvent.value = rowId;
await scrollToEvent(rowId);
await Future.delayed(Durations.extralong1, () {
if (hookContext.mounted && highlightedEvent.value == rowId) {
highlightedEvent.value = null;
}
});
},
jumpToBottom: () async {
finishPendingJump();

View file

@ -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<String, String> 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<String, Object> get arguments => .fromEntries(
shown

View file

@ -0,0 +1 @@
typedef ComposerParams = ({String roomId, String? threadRoot});

View file

@ -29,6 +29,8 @@ class const Event({
final String? sendError,
final IMap<String, int> 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<String, Object?> toJson() => _$EventToJson(this);
String? get threadRoot => relationType == "m.thread" ? relatesTo : null;
static String typeJsonFromJson(Map<dynamic, dynamic> json, _) =>
json["decrypted_type"] ?? json["type"];
@ -47,7 +51,15 @@ class const Event({
static String? replyToFromJson(Map<dynamic, dynamic> 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;
}

View file

@ -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<String, Object?> toJson() => _$GetThreadsRequestToJson(this);
factory GetThreadsRequest.fromJson(Map<String, Object?> json) =>
_$GetThreadsRequestFromJson(json);
}

Some files were not shown because too many files have changed in this diff Show more