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
10 changed files with 1477 additions and 577 deletions

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,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<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)
.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),
),
);

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,
);
}