add support for jumping to out-of-context events #95
16 changed files with 463 additions and 132 deletions
|
|
@ -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}));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
lib/models/direction.dart
Normal file
8
lib/models/direction.dart
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
|
||||||
|
enum Direction {
|
||||||
|
@JsonValue("f")
|
||||||
|
forward,
|
||||||
|
@JsonValue("b")
|
||||||
|
backward,
|
||||||
|
}
|
||||||
22
lib/models/event_context.dart
Normal file
22
lib/models/event_context.dart
Normal 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);
|
||||||
|
}
|
||||||
19
lib/models/paginate_manual.dart
Normal file
19
lib/models/paginate_manual.dart
Normal 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);
|
||||||
|
}
|
||||||
17
lib/models/requests/get_event_context.dart
Normal file
17
lib/models/requests/get_event_context.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
22
lib/models/requests/paginate_manual.dart
Normal file
22
lib/models/requests/paginate_manual.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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
32
lib/models/room_chat.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue