add support for jumping to out-of-context events #95

Merged
Henry-Hiles merged 12 commits from quad/feat/jump-to-out-of-timeline into main 2026-09-26 13:03:16 -04:00
16 changed files with 463 additions and 132 deletions

View file

@ -12,13 +12,16 @@ import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/models/capabilities.dart"; import "package:nexus/models/capabilities.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/event_context.dart";
import "package:nexus/models/gomuks_config.dart"; import "package:nexus/models/gomuks_config.dart";
import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/oauth_auth_code_response.dart";
import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/open_graph_data.dart";
import "package:nexus/models/paginate.dart"; import "package:nexus/models/paginate.dart";
import "package:nexus/models/paginate_manual.dart";
import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/deregister_pusher.dart";
import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/download_media.dart";
import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/get_event.dart";
import "package:nexus/models/requests/get_event_context.dart";
import "package:nexus/models/requests/get_mentions.dart"; import "package:nexus/models/requests/get_mentions.dart";
import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_related_events.dart";
import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_room_state.dart";
@ -28,6 +31,7 @@ import "package:nexus/models/requests/oauth/exchange_token.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart";
import "package:nexus/models/requests/oauth/register_client.dart"; import "package:nexus/models/requests/oauth/register_client.dart";
import "package:nexus/models/requests/paginate.dart"; import "package:nexus/models/requests/paginate.dart";
import "package:nexus/models/requests/paginate_manual.dart";
import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/register_pusher.dart";
import "package:nexus/models/requests/report.dart"; import "package:nexus/models/requests/report.dart";
@ -227,6 +231,12 @@ class ClientController 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> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson()));
Future<ProfileResponse> getProfile(String userId) async { Future<ProfileResponse> getProfile(String userId) async {
try { try {
return .fromJson(await _sendCommand("get_profile", {"user_id": userId})); return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));

View file

@ -7,8 +7,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController([final NotificationsRequest? request]) class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> { extends AsyncNotifier<IList<Event>> {
static const limit = 20;
@override @override
Future<IList<Event>> build() async { Future<IList<Event>> build() async {
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
@ -19,7 +17,6 @@ class NotificationsController([final NotificationsRequest? request])
.new( .new(
maxTimestamp: .now(), maxTimestamp: .now(),
unreadType: unreadType ?? .highlight, unreadType: unreadType ?? .highlight,
limit: limit,
roomId: roomId, roomId: roomId,
), ),
); );
@ -39,7 +36,6 @@ class NotificationsController([final NotificationsRequest? request])
.new( .new(
maxTimestamp: lastTs, maxTimestamp: lastTs,
unreadType: unreadType ?? .highlight, unreadType: unreadType ?? .highlight,
limit: limit,
roomId: roomId, roomId: roomId,
), ),
); );

View file

@ -10,18 +10,21 @@ 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/reaction.dart"; import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.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/requests/send_message.dart"; 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";
class RoomChatController(final String roomId) class RoomChatController(final (String roomId, String? contextualEvent) info)
extends AsyncNotifier<IList<Event>?> { extends AsyncNotifier<RoomChat?> {
@override @override
Future<IList<Event>?> build() async { Future<RoomChat?> build() async {
final (roomId, eventId) = info;
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final room = ref.watch( final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
); );
@ -32,7 +35,7 @@ class RoomChatController(final String roomId)
await ref.read(RoomsController.provider.notifier).addState(roomId, state); await ref.read(RoomsController.provider.notifier).addState(roomId, state);
} }
return room.timeline final timeline = room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((element) => element.value) .map((element) => element.value)
.toIList() .toIList()
@ -54,6 +57,26 @@ class RoomChatController(final String roomId)
}) })
.nonNulls .nonNulls
.toIList(); .toIList();
if (info.$2 == null || timeline.map((e) => e.eventId).contains(info.$2)) {
ref.watch(RoomsController.provider.select((rooms) => rooms[roomId]));
return .new(
timeline: timeline,
hasMoreBackward: room.hasMore,
hasMoreForward: false,
);
} else {
final context = await client.getEventContext(
.new(roomId: roomId, eventId: info.$2!),
);
return .new(
timeline: context.before.add(context.event).addAll(context.after),
hasMoreBackward: true,
hasMoreForward: true,
historicalData: .new(start: context.start, end: context.end),
);
}
} }
Future<void> deleteMessage(Event event, {String? reason}) => ref Future<void> deleteMessage(Event event, {String? reason}) => ref
@ -61,55 +84,101 @@ class RoomChatController(final String roomId)
.redactEvent( .redactEvent(
RedactEventRequest( RedactEventRequest(
eventId: event.eventId, eventId: event.eventId,
roomId: roomId, roomId: info.$1,
reason: reason, reason: reason,
), ),
); );
Future<void> loadOlder() async { Future<void> paginate(Direction direction) async {
if (state.isLoading) return; if (state.isLoading) return;
final chat = await future;
if (direction == .forward
? chat?.hasMoreForward == false
: chat?.hasMoreBackward == false) {
return;
}
state = .loading(); state = .loading();
final timelineKeys = ref final client = ref.read(ClientController.provider.notifier);
.read(RoomsController.provider.select((value) => value[roomId]))
?.timeline if (chat?.historicalData == null) {
.keys; final timelineKeys = ref
final response = await ref .read(RoomsController.provider.select((value) => value[info.$1]))
.read(ClientController.provider.notifier) ?.timeline
.paginate( .keys;
.new( final response = await client.paginate(
roomId: roomId, .new(
maxTimelineId: timelineKeys?.isNotEmpty == true roomId: info.$1,
? timelineKeys?.reduce(min) maxTimelineId: timelineKeys?.isNotEmpty == true
: null, ? timelineKeys?.reduce(min)
: null,
),
);
if (response.events.isEmpty) {
state = .data(state.value);
}
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
info.$1: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
.new(),
);
} else {
final paginationResponse = await client.paginateManual(
.new(
roomId: info.$1,
direction: direction,
since: direction == .forward
? chat!.historicalData!.end
: chat!.historicalData!.start,
),
);
state = .data(
.new(
timeline: direction == .forward
? chat.timeline.addAll(paginationResponse.events)
: paginationResponse.events.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,
), ),
); ),
);
if (response.events.isEmpty) {
state = .data(state.value);
} }
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
roomId: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
.new(),
);
} }
Future<void> send( Future<void> send(
@ -123,7 +192,7 @@ class RoomChatController(final String roomId)
if (relationType == .edit) { if (relationType == .edit) {
baseContent = relation?.content; baseContent = relation?.content;
} else { } else {
final provider = AttachmentController.provider(roomId); final provider = AttachmentController.provider(info.$1);
baseContent = ref.read(provider)?.$2; baseContent = ref.read(provider)?.$2;
ref.invalidate(provider); ref.invalidate(provider);
} }
@ -143,7 +212,7 @@ class RoomChatController(final String roomId)
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final event = await client.sendMessage( final event = await client.sendMessage(
SendMessageRequest( SendMessageRequest(
roomId: roomId, roomId: info.$1,
baseContent: baseContent, baseContent: baseContent,
mentions: Mentions( mentions: Mentions(
userIds: [ userIds: [
@ -165,7 +234,7 @@ class RoomChatController(final String roomId)
.read(RoomsController.provider.notifier) .read(RoomsController.provider.notifier)
.update( .update(
.new({ .new({
roomId: .new( info.$1: .new(
events: .new({event.rowId: event}), events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}), clientSticky: .new({event.rowId}),
), ),
@ -182,7 +251,7 @@ class RoomChatController(final String roomId)
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents( final allReactionEvents = await client.getRelatedEvents(
.new( .new(
roomId: roomId, roomId: info.$1,
eventId: event.eventId, eventId: event.eventId,
relationType: "m.annotation", relationType: "m.annotation",
), ),
@ -203,7 +272,7 @@ class RoomChatController(final String roomId)
if (reactionEvent != null) { if (reactionEvent != null) {
await ref await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId)); .redactEvent(.new(eventId: reactionEvent.eventId, roomId: info.$1));
} }
} }
@ -212,7 +281,7 @@ class RoomChatController(final String roomId)
await client.sendEvent( await client.sendEvent(
.new( .new(
roomId: roomId, roomId: info.$1,
type: EventType.reaction.type, type: EventType.reaction.type,
content: ReactionContent(key: reaction), content: ReactionContent(key: reaction),
synchronous: true, synchronous: true,
@ -224,7 +293,7 @@ class RoomChatController(final String roomId)
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, IList<Event>?, String>( .autoDispose<RoomChatController, RoomChat?, (String, String?)>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -27,7 +27,9 @@ extension BuildEventOptions on Event {
final theme = Theme.of(context); final theme = Theme.of(context);
final danger = theme.colorScheme.error; final danger = theme.colorScheme.error;
final notifier = ref.read(RoomChatController.provider(roomId).notifier); final notifier = ref.read(
RoomChatController.provider((roomId, null)).notifier,
);
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final isPinned = ref final isPinned = ref

View file

@ -1,53 +1,112 @@
import "dart:async";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room_chat.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
final class ChatScroll<T>({ final class ChatScroll({
required final IList<T> historyItems, required final IList<Event> historyItems,
required final IList<T> liveItems, required final IList<Event> liveItems,
required final GlobalKey centerKey, required final GlobalKey centerKey,
required final GlobalKey anchorItemKey,
required final ListController historyListController, required final ListController historyListController,
required final ListController liveListController, required final ListController liveListController,
required final ScrollController scrollController, required final ScrollController scrollController,
required final bool atBottom,
required final Future<void> Function(String id) jumpToId, required final Future<void> Function(String id) jumpToId,
required final Future<void> Function() jumpToBottom,
}) { }) {
static ChatScroll<T> use<T>({ factory use({
required AsyncValue<IList<T>?> controllerData, required AsyncValue<RoomChat?> controllerData,
required String Function(T item) id, required Future<void> Function(Direction direction) paginate,
required Future<void> Function() loadOlder, required Future<void> Function() markRead,
required Future<void> Function() onReachedBottom, required ValueNotifier<String?> contextualEvent,
}) { }) {
final historyListController = useRef(ListController()); final anchorId = useState<String?>(null);
final liveListController = useRef(ListController());
final anchorItemKey = useMemoized(GlobalKey.new, [anchorId.value]);
final historyListController = useMemoized(ListController.new);
final liveListController = useMemoized(ListController.new);
final scrollController = useScrollController(); final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new); final centerKey = useMemoized(GlobalKey.new);
final anchorId = useState<String?>(null); final atBottom = useState(true);
final anchorIdValue = anchorId.value; final pendingAnchorTarget = useState<String?>(null);
final anchorMountedCompleter = useRef<Completer<BuildContext>?>(null);
useEffect(() { useEffect(() {
if (anchorId.value == null) { if (anchorId.value == null) {
if (controllerData case AsyncData(:final value?) if (controllerData case AsyncData(:final value?)
when value.isNotEmpty) { when value.timeline.isNotEmpty) {
anchorId.value = id(value.last); final hasContextualEvent = value.timeline.any(
(event) => event.eventId == contextualEvent.value,
);
anchorId.value = hasContextualEvent
? contextualEvent.value
: value.timeline.last.eventId;
} }
} }
return null; return null;
}, [controllerData]); }, [controllerData, contextualEvent.value]);
final ({IList<T> history, IList<T> live}) split = useMemoized(() { useEffect(() {
final items = controllerData.value; final target = pendingAnchorTarget.value;
final anchor = anchorIdValue; if (target == null) return null;
final found =
controllerData.value?.timeline.any(
(event) => event.eventId == target,
) ??
false;
if (found || controllerData is AsyncError) {
if (found) {
anchorId.value = target;
} else {
anchorMountedCompleter.value?.completeError(
StateError("Failed to load context for $target"),
);
anchorMountedCompleter.value = null;
}
pendingAnchorTarget.value = null;
}
return null;
}, [controllerData, pendingAnchorTarget.value]);
useEffect(() {
final completer = anchorMountedCompleter.value;
if (completer == null) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
final context = anchorItemKey.currentContext;
if (context != null && context.mounted) {
anchorMountedCompleter.value?.complete(context);
anchorMountedCompleter.value = null;
}
});
return null;
}, [anchorId.value]);
final ({IList<Event> history, IList<Event> live}) split = useMemoized(() {
final items = controllerData.value?.timeline;
final anchor = anchorId.value;
if (items == null || anchor == null) { if (items == null || anchor == null) {
return (history: const .empty(), live: const .empty()); return (history: const .empty(), live: const .empty());
} }
final anchorIndex = items.indexWhere((item) => id(item) == anchor); final anchorIndex = items.indexWhere((item) => item.eventId == anchor);
if (anchorIndex == -1) { if (anchorIndex == -1) {
return (history: const .empty(), live: items); return (history: const .empty(), live: items);
@ -57,80 +116,122 @@ final class ChatScroll<T>({
history: items.take(anchorIndex).toIList().reversed.toIList(), history: items.take(anchorIndex).toIList().reversed.toIList(),
live: items.skip(anchorIndex).toIList(), live: items.skip(anchorIndex).toIList(),
); );
}, [controllerData, anchorIdValue]); }, [controllerData, anchorId.value]);
useEffect(() { useEffect(
const topThreshold = 500.0; () {
const bottomThreshold = 50.0; const loadThreshold = 500.0;
const readThreshold = 50.0;
Future<void> checkPosition() async { Future<void> checkPosition() async {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
final position = scrollController.position; final position = scrollController.position;
if (position.extentAfter <= topThreshold) { final isAtBottom = position.extentBefore <= readThreshold;
await loadOlder(); if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
} else if (position.extentBefore <= bottomThreshold) {
await onReachedBottom(); if (position.extentAfter <= loadThreshold) {
await paginate(.backward);
} else if (contextualEvent.value != null &&
position.extentBefore <= loadThreshold) {
await paginate(.forward);
} else if (position.extentBefore <= readThreshold) {
await markRead();
}
} }
scrollController.addListener(checkPosition);
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
return () => scrollController.removeListener(checkPosition);
},
[
scrollController,
controllerData,
paginate,
markRead,
contextualEvent.value,
],
);
double? resolveOffset(String itemId) {
final historyIndex = split.history.indexWhere(
(item) => item.eventId == itemId,
);
if (historyIndex != -1) {
// TODO: Replace SuperSliverView because of the bug that requires this: #94
// ignore: invalid_use_of_visible_for_testing_member
return historyListController.getOffsetToReveal(historyIndex, 0.5);
} }
scrollController.addListener(checkPosition); final liveIndex = split.live.indexWhere((item) => item.eventId == itemId);
if (liveIndex != -1) {
// ignore: invalid_use_of_visible_for_testing_member
return liveListController.getOffsetToReveal(liveIndex, 0.5);
}
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); return null;
}
return () {
scrollController.removeListener(checkPosition);
};
}, [scrollController, controllerData, loadOlder, onReachedBottom]);
Future<void> jumpToId(String itemId) async { Future<void> jumpToId(String itemId) async {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
final historyIndex = split.history.indexWhere( final offset = resolveOffset(itemId);
(item) => id(item) == itemId,
);
if (historyIndex != -1) {
// TODO: Replace SuperSliverView because of the bug that requires this: #94
// ignore: invalid_use_of_visible_for_testing_member
final offset = historyListController.value.getOffsetToReveal(
historyIndex,
0.5,
);
if (offset != null) {
await scrollController.animateTo( await scrollController.animateTo(
offset, offset,
duration: const Duration(milliseconds: 700), duration: const .new(milliseconds: 700),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
} else { return;
final liveIndex = split.live.indexWhere((item) => id(item) == itemId);
if (liveIndex != -1) {
// ignore: invalid_use_of_visible_for_testing_member
final offset = liveListController.value.getOffsetToReveal(
liveIndex,
0.5,
);
await scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 700),
curve: Curves.easeInOut,
);
}
} }
final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = itemId;
contextualEvent.value = itemId;
final context = await completer.future;
if (context.mounted) {
await Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
}
}
Future<void> jumpToBottom() async {
if (contextualEvent.value != null) {
anchorId.value = null;
contextualEvent.value = null;
}
if (!scrollController.hasClients) return;
await scrollController.animateTo(
scrollController.position.minScrollExtent,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
} }
return .new( return .new(
historyItems: split.history, historyItems: split.history,
liveItems: split.live, liveItems: split.live,
centerKey: centerKey, centerKey: centerKey,
historyListController: historyListController.value, anchorItemKey: anchorItemKey,
liveListController: liveListController.value, historyListController: historyListController,
liveListController: liveListController,
scrollController: scrollController, scrollController: scrollController,
atBottom: atBottom.value,
jumpToId: jumpToId, jumpToId: jumpToId,
jumpToBottom: jumpToBottom,
); );
} }
} }

View file

@ -0,0 +1,8 @@
import "package:freezed_annotation/freezed_annotation.dart";
enum Direction {
@JsonValue("f")
forward,
@JsonValue("b")
backward,
}

View file

@ -0,0 +1,22 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
part "event_context.freezed.dart";
part "event_context.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const EventContext({
required final Event event,
required final IList<Event> before,
required final IList<Event> after,
required final String start,
required final String end,
final IList<Event> relatedEvents = const IList.empty(),
}) with _$EventContext {
Map<String, Object?> toJson() => _$EventContextToJson(this);
factory EventContext.fromJson(Map<String, Object?> json) =>
_$EventContextFromJson(json);
}

View file

@ -0,0 +1,19 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
part "paginate_manual.freezed.dart";
part "paginate_manual.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PaginateManual({
required final IList<Event> events,
final IList<Event> relatedEvents = const IList.empty(),
required final String? nextBatch,
}) with _$PaginateManual {
Map<String, Object?> toJson() => _$PaginateManualToJson(this);
factory PaginateManual.fromJson(Map<String, Object?> json) =>
_$PaginateManualFromJson(json);
}

View file

@ -0,0 +1,17 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_event_context.freezed.dart";
part "get_event_context.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const GetEventContextRequest({
required final String roomId,
required final String eventId,
final int limit = 20,
}) with _$GetEventContextRequest {
Map<String, Object?> toJson() => _$GetEventContextRequestToJson(this);
factory GetEventContextRequest.fromJson(Map<String, Object?> json) =>
_$GetEventContextRequestFromJson(json);
}

View file

@ -9,7 +9,7 @@ part "get_mentions.g.dart";
class GetMentionsRequest({ class GetMentionsRequest({
@EpochDateTimeConverter() required final DateTime maxTimestamp, @EpochDateTimeConverter() required final DateTime maxTimestamp,
@JsonKey(name: "type") required final UnreadType unreadType, @JsonKey(name: "type") required final UnreadType unreadType,
required final int limit, final int limit = 20,
final String? roomId, final String? roomId,
}) with _$GetMentionsRequest { }) with _$GetMentionsRequest {
Map<String, Object?> toJson() => _$GetMentionsRequestToJson(this); Map<String, Object?> toJson() => _$GetMentionsRequestToJson(this);

View file

@ -0,0 +1,22 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/direction.dart";
part "paginate_manual.freezed.dart";
part "paginate_manual.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PaginateManualRequest({
required final String roomId,
// Root event ID of a thread to paginate
final String? threadRoot,
// Can be null for starting pagination of a thread
final String? since,
required final Direction direction,
final int limit = 20,
}) with _$PaginateManualRequest {
Map<String, Object?> toJson() => _$PaginateManualRequestToJson(this);
factory PaginateManualRequest.fromJson(Map<String, Object?> json) =>
_$PaginateManualRequestFromJson(json);
}

View file

@ -12,17 +12,26 @@ part "room.g.dart";
class const Room({ class const Room({
@JsonKey(name: "meta") final RoomMetadata? metadata, @JsonKey(name: "meta") final RoomMetadata? metadata,
/// [timeline] is an IMap of timelineRowId to eventRowId
@JsonKey(fromJson: Room.timelineTupleJsonToIMap) @JsonKey(fromJson: Room.timelineTupleJsonToIMap)
final IMap<int, int?> timeline = const IMap.empty(), final IMap<int, int?> timeline = const IMap.empty(),
/// [clientSticky] is an ISet of eventRowId
@JsonKey(includeFromJson: false, includeToJson: false)
final ISet<int> clientSticky = const ISet.empty(), final ISet<int> clientSticky = const ISet.empty(),
/// [events] is an IMap of eventRowId to event
@JsonKey(fromJson: Room.eventsJsonToIMap) @JsonKey(fromJson: Room.eventsJsonToIMap)
final IMap<int, Event> events = const IMap.empty(), final IMap<int, Event> events = const IMap.empty(),
final bool reset = false, final bool reset = false,
@JsonKey(includeFromJson: false, includeToJson: false)
final bool hasFetchedState = false, final bool hasFetchedState = false,
@JsonKey(includeFromJson: false, includeToJson: false)
final bool hasFetchedMembers = false, final bool hasFetchedMembers = false,
final IMap<String, IMap<String, int>> state = const IMap.empty(), final IMap<String, IMap<String, int>> state = const IMap.empty(),
final IMap<String, IList<ReadReceipt>> receipts = const IMap.empty(), final IMap<String, IList<ReadReceipt>> receipts = const IMap.empty(),
@ -32,9 +41,6 @@ class const Room({
// IMap<String, AccountData> accountData, // IMap<String, AccountData> accountData,
// IList<Notification> notifications, // IList<Notification> notifications,
}) with _$Room { }) with _$Room {
/// [timeline] is an IMap of timelineRowId to eventRowId
/// [events] is an IMap of eventRowId to event
/// [clientSticky] is an ISet of eventRowId
static IMap<int, int?> timelineTupleJsonToIMap(List<dynamic> json) => static IMap<int, int?> timelineTupleJsonToIMap(List<dynamic> json) =>
IMap.fromEntries( IMap.fromEntries(
json.map( json.map(

32
lib/models/room_chat.dart Normal file
View file

@ -0,0 +1,32 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
part "room_chat.freezed.dart";
part "room_chat.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const RoomChat({
required final IList<Event> timeline,
required final bool hasMoreForward,
required final bool hasMoreBackward,
final HistoricalData? historicalData,
}) with _$RoomChat {
Map<String, Object?> toJson() => _$RoomChatToJson(this);
factory RoomChat.fromJson(Map<String, Object?> json) =>
_$RoomChatFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const HistoricalData({
required final String start,
required final String end,
}) with _$HistoricalData {
Map<String, Object?> toJson() => _$HistoricalDataToJson(this);
factory HistoricalData.fromJson(Map<String, Object?> json) =>
_$HistoricalDataFromJson(json);
}

View file

@ -64,8 +64,10 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
enabled.value = false; enabled.value = false;
try { try {
final controller = ref.watch( final controller = ref.watch(
RoomChatController.provider(event.roomId) RoomChatController.provider((
.notifier, event.roomId,
null,
)).notifier,
); );
if (selected) { if (selected) {

View file

@ -8,7 +8,7 @@ import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({ class const ChatTimeline({
required final ChatScroll<Event> scroll, required final ChatScroll scroll,
required final Future<void> Function(String) jumpToId, required final Future<void> Function(String) jumpToId,
required final IList<PopupMenuEntry> Function(Event) getEventOptions, required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final String? highlightedEvent, required final String? highlightedEvent,
@ -28,6 +28,7 @@ class const ChatTimeline({
required Future<void> Function(String) jumpToId, required Future<void> Function(String) jumpToId,
required IList<PopupMenuEntry> Function(Event) getEventOptions, required IList<PopupMenuEntry> Function(Event) getEventOptions,
required String? highlightedEvent, required String? highlightedEvent,
required Key key,
}) => HighlightWrapper( }) => HighlightWrapper(
EventRenderer( EventRenderer(
event, event,
@ -35,7 +36,7 @@ class const ChatTimeline({
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
isGrouped: isGrouped(event, previousEvent), isGrouped: isGrouped(event, previousEvent),
), ),
key: ValueKey(event.eventId), key: key,
isHighlighted: highlightedEvent == event.eventId, isHighlighted: highlightedEvent == event.eventId,
); );
@ -59,6 +60,9 @@ class const ChatTimeline({
jumpToId: jumpToId, jumpToId: jumpToId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent, highlightedEvent: highlightedEvent,
key: index == 0
? scroll.anchorItemKey
: ValueKey(scroll.liveItems[index].eventId),
), ),
), ),
@ -72,6 +76,7 @@ class const ChatTimeline({
jumpToId: jumpToId, jumpToId: jumpToId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent, highlightedEvent: highlightedEvent,
key: ValueKey(scroll.historyItems[index].eventId),
), ),
), ),
], ],

View file

@ -24,13 +24,15 @@ final class const RoomChat({
required final String? roomId, required final String? roomId,
required final bool isDesktop, required final bool isDesktop,
required final bool showMembersByDefault, required final bool showMembersByDefault,
final String? initialHighlightedEvent,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<Event?>(null); final relatedEvent = useState<Event?>(null);
final relationType = useState(RelationType.reply); final relationType = useState(RelationType.reply);
final highlightedEvent = useState<String?>(null); final contextualEvent = useState<String?>(initialHighlightedEvent);
final highlightedEvent = useState<String?>(initialHighlightedEvent);
final composerSize = useState<double>(64); final composerSize = useState<double>(64);
@ -59,7 +61,10 @@ final class const RoomChat({
final roomId = this.roomId!; final roomId = this.roomId!;
final controllerProvider = RoomChatController.provider(roomId); final controllerProvider = RoomChatController.provider((
roomId,
contextualEvent.value,
));
final notifier = ref.watch(controllerProvider.notifier); final notifier = ref.watch(controllerProvider.notifier);
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
@ -68,9 +73,9 @@ final class const RoomChat({
final scroll = ChatScroll.use( final scroll = ChatScroll.use(
controllerData: controllerData, controllerData: controllerData,
id: (event) => event.eventId, paginate: notifier.paginate,
loadOlder: notifier.loadOlder, contextualEvent: contextualEvent,
onReachedBottom: () async { markRead: () async {
final room = ref.read( final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
); );
@ -153,6 +158,21 @@ final class const RoomChat({
), ),
), ),
), ),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
),
),
),
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,