parent
47645ea327
commit
7f7bdbf525
42 changed files with 1477 additions and 577 deletions
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
||||||
Subproject commit 9e6254b703a841d1e9ee64dbaf90d5a1b798696e
|
Subproject commit 46ab9e9afa54c12f585cbbf559c395b9c2dcb545
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
37
lib/controllers/thread_list.dart
Normal file
37
lib/controllers/thread_list.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
49
lib/controllers/thread_replies.dart
Normal file
49
lib/controllers/thread_replies.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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() ||
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
50
lib/helpers/extensions/thread_events.dart
Normal file
50
lib/helpers/extensions/thread_events.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
lib/helpers/hooks/chat_composer.dart
Normal file
40
lib/helpers/hooks/chat_composer.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
1
lib/models/composer_params.dart
Normal file
1
lib/models/composer_params.dart
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
typedef ComposerParams = ({String roomId, String? threadRoot});
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
lib/models/requests/get_threads.dart
Normal file
17
lib/models/requests/get_threads.dart
Normal 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
Loading…
Reference in a new issue