Watch
1
0
Fork
You've already forked nexus
0
forked from Nexus/nexus

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/controllers/rooms.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/composer_params.dart";
import "package:path/path.dart"; import "package:path/path.dart";
class AttachmentController(final String roomId) class AttachmentController(final ComposerParams composer)
extends Notifier<(String, MessageContent?)?> { extends Notifier<(String, MessageContent?)?> {
@override @override
Null build() => null; Null build() => null;
@ -18,7 +19,10 @@ class AttachmentController(final String roomId)
final isEncrypted = ref.read( final isEncrypted = ref.read(
RoomsController.provider.select( RoomsController.provider.select(
(value) => (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; void set((String, MessageContent?)? attachment) => state = attachment;
static final provider = NotifierProvider.family static final provider = NotifierProvider.family
.autoDispose<AttachmentController, (String, MessageContent?)?, String>( .autoDispose<
AttachmentController.new, 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/helpers/extensions/weighted_search.dart";
import "package:nexus/models/bot_argument_suggestion.dart"; import "package:nexus/models/bot_argument_suggestion.dart";
import "package:nexus/models/bot_command_draft.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/bot_command_description.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class BotArgumentSuggestionsController(final String roomId) class BotArgumentSuggestionsController(final ComposerParams composer)
extends Notifier<IList<BotArgumentSuggestion>> { extends Notifier<IList<BotArgumentSuggestion>> {
@override @override
IList<BotArgumentSuggestion> build() { IList<BotArgumentSuggestion> build() {
final draft = ref.watch(BotCommandDraftController.provider(roomId)); final draft = ref.watch(BotCommandDraftController.provider(composer));
final (parameter, text) = switch (draft) { final (parameter, text) = switch (draft) {
BotCommandDraft(focusedParameter: final parameter?, :final texts) => ( BotCommandDraft(focusedParameter: final parameter?, :final texts) => (
parameter, parameter,
@ -38,7 +39,7 @@ class BotArgumentSuggestionsController(final String roomId)
? ref ? ref
.watch( .watch(
MembersByStatusController.provider( MembersByStatusController.provider(
.new(roomId: roomId, status: .join), .new(roomId: composer.roomId, status: .join),
), ),
) )
.value ?? .value ??
@ -102,7 +103,7 @@ class BotArgumentSuggestionsController(final String roomId)
} }
void pick(BotArgumentSuggestion suggestion) => ref void pick(BotArgumentSuggestion suggestion) => ref
.read(BotCommandDraftController.provider(roomId).notifier) .read(BotCommandDraftController.provider(composer).notifier)
.pick( .pick(
suggestion, suggestion,
via: switch (suggestion.room) { via: switch (suggestion.room) {
@ -112,7 +113,7 @@ class BotArgumentSuggestionsController(final String roomId)
); );
bool pickTop() { bool pickTop() {
final draft = ref.read(BotCommandDraftController.provider(roomId)); final draft = ref.read(BotCommandDraftController.provider(composer));
if (state.firstOrNull case final suggestion? if (state.firstOrNull case final suggestion?
when draft != null && !draft.arguments.containsKey(draft.focused)) { when draft != null && !draft.arguments.containsKey(draft.focused)) {
pick(suggestion); pick(suggestion);
@ -125,6 +126,6 @@ class BotArgumentSuggestionsController(final String roomId)
.family< .family<
BotArgumentSuggestionsController, BotArgumentSuggestionsController,
IList<BotArgumentSuggestion>, IList<BotArgumentSuggestion>,
String ComposerParams
>(BotArgumentSuggestionsController.new); >(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_argument_suggestion.dart";
import "package:nexus/models/bot_command.dart"; import "package:nexus/models/bot_command.dart";
import "package:nexus/models/bot_command_draft.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/bot_command_description.dart";
class BotCommandDraftController(final String roomId) class BotCommandDraftController(final ComposerParams composer)
extends Notifier<BotCommandDraft?> { extends Notifier<BotCommandDraft?> {
@override @override
BotCommandDraft? build() => null; BotCommandDraft? build() => null;
@ -14,7 +15,7 @@ class BotCommandDraftController(final String roomId)
void start(BotCommand command, {String text = ""}) { void start(BotCommand command, {String text = ""}) {
state = BotCommandDraft( state = BotCommandDraft(
command: command, command: command,
roomId: roomId, composer: composer,
texts: .fromEntries( texts: .fromEntries(
command.description.parameters command.description.parameters
.where((parameter) => !parameter.optional) .where((parameter) => !parameter.optional)
@ -97,7 +98,7 @@ class BotCommandDraftController(final String roomId)
void clear() => state = null; void clear() => state = null;
static final provider = NotifierProvider.autoDispose static final provider = NotifierProvider.autoDispose
.family<BotCommandDraftController, BotCommandDraft?, String>( .family<BotCommandDraftController, BotCommandDraft?, ComposerParams>(
BotCommandDraftController.new, 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/client_state.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.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/main.dart";
import "package:nexus/models/content/bot_command_description.dart"; import "package:nexus/models/content/bot_command_description.dart";
import "package:nexus/models/account.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_specific_room_state.dart";
import "package:nexus/models/requests/get_space_hierarchy.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_room_directory.dart";
import "package:nexus/models/requests/get_threads.dart";
import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/profile_response.dart"; import "package:nexus/models/profile_response.dart";
import "package:nexus/models/push_notification.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 => Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson())); .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 => Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson())); .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}, {"room_id": roomId, "timeout": timeout.inMilliseconds},
); );
Future<void> markRead(Room room) async { Future<void> markRead(Room room, {String? threadRoot}) async {
if (room.timeline.isEmpty || room.metadata == null) return; if (room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; final event = switch (threadRoot) {
final event = eventRowId == null ? null : room.events[eventRowId]; 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; if (event == null) return;
@ -454,6 +465,7 @@ class ClientController(final String? accountId) extends AsyncNotifier<int> {
"room_id": room.metadata!.id, "room_id": room.metadata!.id,
"receipt_type": sendPublic ? "m.read" : "m.read.private", "receipt_type": sendPublic ? "m.read" : "m.read.private",
"event_id": event.eventId, "event_id": event.eventId,
"thread_id": ?threadRoot,
}); });
} }

View file

@ -1,25 +1,20 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_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) class EventController(final GetEventRequest request)
extends AsyncNotifier<Event?> { extends AsyncNotifier<int?> {
@override @override
Future<Event?> build() async { Future<int?> build() async {
final event = ref.watch( final rowId = ref.watch(
RoomsController.provider.select((rooms) { RoomsController.provider.select(
final room = rooms[request.roomId]; (rooms) => rooms[request.roomId]?.eventIds[request.eventId],
),
return switch (room?.eventIds[request.eventId]) {
final rowId? => room?.events[rowId],
null => null,
};
}),
); );
if (event != null) return event; if (rowId != null) return rowId;
final link = ref.keepAlive(); final link = ref.keepAlive();
final fetchedEvent = await ref final fetchedEvent = await ref
@ -44,11 +39,9 @@ class EventController(final GetEventRequest request)
); );
} }
return fetchedEvent; return fetchedEvent?.rowId;
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<EventController, Event?, GetEventRequest>( .autoDispose<EventController, int?, GetEventRequest>(EventController.new);
EventController.new,
);
} }

View file

@ -13,7 +13,7 @@ class PinnedEventsController(final String roomId)
pinIds.map( pinIds.map(
(eventId) => ref.watch( (eventId) => ref.watch(
EventController.provider(.new(eventId: eventId, roomId: roomId)) EventController.provider(.new(eventId: eventId, roomId: roomId))
.selectAsync((data) => data?.rowId), .future,
), ),
), ),
)).nonNulls.toIList(); )).nonNulls.toIList();

View file

@ -32,7 +32,12 @@ class ReadReceiptsController(final ChatParams chat)
final room = ref.watch( final room = ref.watch(
RoomsController.provider.select( RoomsController.provider.select(
(rooms) => switch (rooms[chat.roomId]) { (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, null => null,
}, },
), ),
@ -50,15 +55,48 @@ class ReadReceiptsController(final ChatParams chat)
.toIList(); .toIList();
final messageIndexes = messages.map((entry) => entry.$1).toList(); 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 return room.receipts.values.flattened
.where( .where(
(receipt) => (receipt) =>
receipt.userId != userId && receipt.userId != userId &&
// Receipts in threads are for the thread, not the timeline receipt.thread == (chat.threadRoot ?? "main"),
receipt.thread == "main",
) )
.map( .map(
(receipt) => switch (indexes[receipt.eventId]) { (receipt) => switch (indexOf(receipt.eventId)) {
final index? => ( final index? => (
receipt: receipt, receipt: receipt,
message: lowerBound(messageIndexes, index + 1) - 1, 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:fluttertagger/fluttertagger.dart";
import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/rooms.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/is_media.dart";
import "package:nexus/helpers/extensions/send_failed.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/bot_command_draft.dart";
import "package:nexus/models/composer_params.dart";
import "package:nexus/models/content/bot_command.dart"; import "package:nexus/models/content/bot_command.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.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/content/sticker.dart";
import "package:nexus/models/direction.dart"; import "package:nexus/models/direction.dart";
import "package:nexus/models/event.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/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/content/message.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.dart";
import "package:nexus/models/room_chat.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) class RoomChatController(final ChatParams info)
extends AsyncNotifier<RoomChat?> { extends AsyncNotifier<RoomChat?> {
@ -47,52 +56,173 @@ class RoomChatController(final ChatParams info)
ref.read(RoomsController.provider.notifier).addState(info.roomId, state); ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
} }
final timeline = room.timeline if (info.threadRoot case final threadRoot?
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) when info.contextualEvent == null) {
.map((e) => e.value) final nextBatch = await ref.watch(
.toIList() ThreadRepliesController.provider((
.addAll(room.clientSticky) roomId: info.roomId,
.nonNulls threadRoot: threadRoot,
.toIList(); )).future,
if (info.contextualEvent == null ||
timeline.contains(info.contextualEvent)) {
ref.watch(
RoomsController.provider.select(
(rooms) =>
(rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky),
),
); );
return .new( return .new(
timeline: timeline, timeline: ref.watch(RoomsController.provider.select(threadTimeline)),
hasMoreBackward: room.hasMore, hasMoreBackward: nextBatch != null,
hasMoreForward: false, hasMoreForward: false,
); );
} else { }
final context = await client.getEventContext(
.new(
roomId: info.roomId,
eventId: room.events[info.contextualEvent!]!.eventId,
),
);
// `before` is in reverse chronological order if (info.threadRoot == null) {
final events = context.before.reversed final timeline = room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((e) => e.value)
.toIList() .toIList()
.add(context.event) .addAll(room.clientSticky)
.addAll(context.after); .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 ref
.read(RoomsController.provider.notifier) .read(RoomsController.provider.notifier)
.storeEvents(events.addAll(context.relatedEvents)); .storeEvents(
older.events
.addAll(older.relatedEvents)
.addAll(newer.events)
.addAll(newer.relatedEvents),
);
return .new( return .new(
timeline: .new(events.map((element) => element.rowId)), timeline: .new([?rootRowId])
hasMoreBackward: true, .addAll(
hasMoreForward: true, // Paginating backwards gives the newest first
historicalData: .new(start: context.start, end: context.end), 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 Future<void> deleteMessage(Event event, {String? reason}) => ref
.read(ClientController.provider.notifier) .read(ClientController.provider.notifier)
.redactEvent( .redactEvent(
@ -133,97 +263,128 @@ class RoomChatController(final ChatParams info)
return; 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(); state = .loading();
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
if (chat?.historicalData == null) { try {
final timelineKeys = ref if (chat?.historicalData == null) {
.read(RoomsController.provider.select((value) => value[info.roomId])) final timelineKeys = ref
?.timeline .read(
.keys; RoomsController.provider.select((value) => value[info.roomId]),
final response = await client.paginate( )
.new( ?.timeline
roomId: info.roomId, .keys;
maxTimelineId: timelineKeys?.isNotEmpty == true final response = await client.paginate(
? timelineKeys?.reduce(min) .new(
: null, roomId: info.roomId,
), maxTimelineId: timelineKeys?.isNotEmpty == true
); ? timelineKeys?.reduce(min)
: null,
),
);
if (response.events.isEmpty) { if (response.events.isEmpty) {
state = .data(chat); state = .data(chat);
} }
ref ref
.read(RoomsController.provider.notifier) .read(RoomsController.provider.notifier)
.update( .update(
IMap({ .new({
info.roomId: Room( info.roomId: .new(
events: IMap.fromIterable( // ignore: unused_result - I am using it you donkey
response.events.addAll(response.relatedEvents), events: .fromIterable(
keyMapper: (event) => event.rowId, response.events.addAll(response.relatedEvents),
valueMapper: (event) => event, keyMapper: (event) => event.rowId,
), valueMapper: (event) => event,
hasMore: response.hasMore, ),
receipts: response.receipts, hasMore: response.hasMore,
timeline: IMap.fromIterable( receipts: response.receipts,
response.events, // ignore: unused_result - I am using it you donkey
keyMapper: (event) => event.timelineRowId, timeline: .fromIterable(
valueMapper: (event) => event.rowId, 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 final rootRowId = direction == .backward
.read(RoomsController.provider.notifier) ? await threadRootBefore(paginationResponse)
.storeEvents( : null;
paginationResponse.events.addAll(paginationResponse.relatedEvents), final eventRowIds = [
); ?rootRowId,
...(direction == .forward
final eventRowIds =
(direction == .forward
? paginationResponse.events ? paginationResponse.events
: paginationResponse.events.reversed) : paginationResponse.events.reversed)
.map((element) => element.rowId) .where((event) => event.threadRoot == info.threadRoot)
.toIList(); .map((element) => element.rowId),
].lock;
state = .data( state = .data(
.new( .new(
timeline: direction == .forward timeline: direction == .forward
? chat.timeline.addAll(eventRowIds) ? chat.timeline.addAll(eventRowIds)
: eventRowIds.addAll(chat.timeline), : eventRowIds.addAll(chat.timeline),
hasMoreForward: hasMoreForward:
direction == .forward && paginationResponse.nextBatch == null direction == .forward && paginationResponse.nextBatch == null
? false ? false
: chat.hasMoreForward, : chat.hasMoreForward,
hasMoreBackward: hasMoreBackward:
direction == .backward && paginationResponse.nextBatch == null direction == .backward && paginationResponse.nextBatch == null
? false ? false
: chat.hasMoreBackward, : chat.hasMoreBackward,
historicalData: chat.historicalData?.copyWith( historicalData: chat.historicalData?.copyWith(
start: start:
(direction == .backward (direction == .backward
? paginationResponse.nextBatch ? paginationResponse.nextBatch
: null) ?? : null) ??
chat.historicalData!.start, chat.historicalData!.start,
end: end:
(direction == .forward ? paginationResponse.nextBatch : null) ?? (direction == .forward
chat.historicalData!.end, ? paginationResponse.nextBatch
: null) ??
chat.historicalData!.end,
),
), ),
), );
); }
} catch (_) {
state = .data(chat);
rethrow;
} }
} }
@ -239,7 +400,11 @@ class RoomChatController(final ChatParams info)
? null ? null
: events?[relatedEventRowId]; : 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; final attachment = ref.read(attachmentProvider)?.$2;
ref.invalidate(attachmentProvider); ref.invalidate(attachmentProvider);
@ -275,9 +440,9 @@ class RoomChatController(final ChatParams info)
room: taggedMessage.contains("@room"), room: taggedMessage.contains("@room"),
), ),
text: taggedMessage, text: taggedMessage,
relation: relatedEvent == null relation: relationType == .edit && relatedEvent != null
? null ? .new(eventId: relatedEvent.eventId, relationType: .edit)
: .new(eventId: relatedEvent.eventId, relationType: relationType), : replyRelation(relatedEvent),
), ),
); );
@ -384,7 +549,7 @@ class RoomChatController(final ChatParams info)
if (shouldMention && repliedEvent != null) repliedEvent.sender, if (shouldMention && repliedEvent != null) repliedEvent.sender,
].toIList(), ].toIList(),
), ),
replyTo: repliedEvent?.eventId, reply: replyRelation(repliedEvent),
), ),
); );
@ -451,9 +616,7 @@ class RoomChatController(final ChatParams info)
command: draft.input, command: draft.input,
), ),
mentions: .new(userIds: [draft.command.sender].lock), mentions: .new(userIds: [draft.command.sender].lock),
relation: repliedEvent == null relation: replyRelation(repliedEvent),
? null
: .new(eventId: repliedEvent.eventId, relationType: .reply),
), ),
); );

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 roomId,
required String userId, required String userId,
required void Function(int, RelationType) onRelation, required void Function(int, RelationType) onRelation,
void Function(String threadRoot)? onStartThread,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
final danger = theme.colorScheme.error; final danger = theme.colorScheme.error;
@ -34,11 +35,15 @@ extension BuildEventOptions on Event {
final notifier = RoomChatController.provider(( final notifier = RoomChatController.provider((
roomId: roomId, roomId: roomId,
contextualEvent: null, contextualEvent: null,
threadRoot: null,
)).notifier; )).notifier;
final isPinned = ref final isPinned = ref
.read(PinnedIdsController.provider(roomId)) .read(PinnedIdsController.provider(roomId))
.contains(eventId); .contains(eventId);
final canSend = ref.read(
PowerLevelController.provider(.new(eventType: .message, roomId: roomId)),
);
void showReasonSheet({ void showReasonSheet({
required String title, required String title,
@ -170,11 +175,7 @@ extension BuildEventOptions on Event {
), ),
), ),
if (ref.read( if (canSend)
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
))
PopupMenuItem( PopupMenuItem(
onTap: () => onRelation(rowId, .reply), onTap: () => onRelation(rowId, .reply),
child: const ListTile( 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 && if (sender == userId &&
switch (content) { switch (content) {
LocationMessageContent() || LocationMessageContent() ||

View file

@ -32,7 +32,7 @@ extension OpenLinkInfo on WidgetRef {
: await read( : await read(
EventController.provider( EventController.provider(
.new(roomId: summary.roomId, eventId: linkInfo.eventId!), .new(roomId: summary.roomId, eventId: linkInfo.eventId!),
).selectAsync((data) => data?.rowId), ).future,
).onError((_, _) => null); ).onError((_, _) => null);
if (await focusRoom(summary.roomId, eventRowId) || !context.mounted) return; 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 GlobalKey centerKey,
required final ScrollController scrollController, required final ScrollController scrollController,
required final bool atBottom, required final bool atBottom,
required final int? highlightedEvent,
required final JumpToEvent jumpToEvent, required final JumpToEvent jumpToEvent,
required final Future<void> Function() jumpToBottom, required final Future<void> Function() jumpToBottom,
required final GlobalKey Function(int eventRowId) keyFor, required final GlobalKey Function(int eventRowId) keyFor,
@ -28,6 +30,8 @@ final class ChatScroll({
final anchorId = useState<int?>(null); final anchorId = useState<int?>(null);
final atBottom = useState(true); final atBottom = useState(true);
final pendingJump = useState<int?>(null); final pendingJump = useState<int?>(null);
final highlightedEvent = useState<int?>(null);
final hookContext = useContext();
final pendingJumpDone = useRef<Completer<void>?>(null); final pendingJumpDone = useRef<Completer<void>?>(null);
void finishPendingJump() { void finishPendingJump() {
pendingJumpDone.value?.complete(); pendingJumpDone.value?.complete();
@ -95,15 +99,14 @@ final class ChatScroll({
final anchorIndex = items.indexOf(anchor); final anchorIndex = items.indexOf(anchor);
if (anchorIndex == -1) return (history: const .empty(), live: items); final historyLength = anchorIndex == -1 ? items.length : anchorIndex + 1;
return ( return (
history: items.take(anchorIndex).toIList().reversed.toIList(), history: items.take(historyLength).toIList().reversed.toIList(),
live: items.skip(anchorIndex).toIList(), live: items.skip(historyLength).toIList(),
); );
}, [controllerData, anchorId.value]); }, [controllerData, anchorId.value]);
final lastRowId = split.live.lastOrNull; final lastRowId = controllerData.value?.timeline.lastOrNull;
final previousLastRowId = useRef<int?>(null); final previousLastRowId = useRef<int?>(null);
useEffect(() { useEffect(() {
@ -118,7 +121,8 @@ final class ChatScroll({
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
scrollController.jumpTo(scrollController.position.minScrollExtent); anchorId.value = lastRowId;
scrollController.position.correctPixels(0);
}); });
return null; return null;
@ -173,37 +177,49 @@ final class ChatScroll({
], ],
); );
Future<void> 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( return .new(
historyRows: split.history, historyRows: split.history,
liveRows: split.live, liveRows: split.live,
centerKey: centerKey, centerKey: centerKey,
scrollController: scrollController, scrollController: scrollController,
atBottom: atBottom.value, atBottom: atBottom.value,
highlightedEvent: highlightedEvent.value,
keyFor: keyFor, keyFor: keyFor,
jumpToEvent: (int rowId) async { jumpToEvent: (int rowId) async {
if (!scrollController.hasClients) return; highlightedEvent.value = rowId;
final context = keyFor(rowId).currentContext; await scrollToEvent(rowId);
if (context != null) { await Future.delayed(Durations.extralong1, () {
await Scrollable.ensureVisible( if (hookContext.mounted && highlightedEvent.value == rowId) {
context, highlightedEvent.value = null;
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;
}, },
jumpToBottom: () async { jumpToBottom: () async {
finishPendingJump(); finishPendingJump();

View file

@ -2,6 +2,7 @@ import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/bot_command.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.dart";
import "package:nexus/models/content/bot_command_description.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) @Freezed(toJson: false, fromJson: false)
class const BotCommandDraft({ class const BotCommandDraft({
required final BotCommand command, required final BotCommand command,
required final String roomId, required final ComposerParams composer,
final IMap<String, String> texts = const .empty(), final IMap<String, String> texts = const .empty(),
// The servers to join rooms picked from suggestions through, by room ID // 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 BotParameter? get focusedParameter => command.description.parameters
.firstWhereOrNull((parameter) => parameter.key == focused); .firstWhereOrNull((parameter) => parameter.key == focused);
({Object? value, String? error}) parse(BotParameter parameter) => ({Object? value, String? error}) parse(BotParameter parameter) => parameter
parameter.schema.parseText(texts[parameter.key] ?? "", roomId: roomId); .schema
.parseText(texts[parameter.key] ?? "", roomId: composer.roomId);
IMap<String, Object> get arguments => .fromEntries( IMap<String, Object> get arguments => .fromEntries(
shown 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 String? sendError,
final IMap<String, int> reactions = const .empty(), final IMap<String, int> reactions = const .empty(),
@JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0, @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 UnreadType? unreadType,
final Profile? pmp, final Profile? pmp,
required final Content content, required final Content content,
@ -36,6 +38,8 @@ class const Event({
}) with _$Event { }) with _$Event {
Map<String, Object?> toJson() => _$EventToJson(this); Map<String, Object?> toJson() => _$EventToJson(this);
String? get threadRoot => relationType == "m.thread" ? relatesTo : null;
static String typeJsonFromJson(Map<dynamic, dynamic> json, _) => static String typeJsonFromJson(Map<dynamic, dynamic> json, _) =>
json["decrypted_type"] ?? json["type"]; json["decrypted_type"] ?? json["type"];
@ -47,7 +51,15 @@ class const Event({
static String? replyToFromJson(Map<dynamic, dynamic> json) { static String? replyToFromJson(Map<dynamic, dynamic> json) {
try { 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 (_) { } catch (_) {
return null; 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