fix: timeline anchoring

This commit is contained in:
Erwan Leboucher 2026-08-09 23:14:02 +02:00
commit 3eb39e6782
No known key found for this signature in database
GPG key ID: E191CC9EB650480E

View file

@ -72,23 +72,47 @@ class RoomChat extends HookConsumerWidget {
final controllerProvider = RoomChatController.provider(roomId);
final notifier = ref.watch(controllerProvider.notifier);
final client = ref.watch(ClientController.provider.notifier);
final listController = useRef(ListController());
final scrollController = useScrollController();
final controllerData = ref.watch(controllerProvider);
final topEventBeforeLoad = useState<String?>(null);
final historyListController = useRef(ListController());
final liveListController = useRef(ListController());
final timelineCenterKey = useMemoized(GlobalKey.new);
final timelineBoundary = useRef<String?>(null);
final hasMore = ref.watch(
RoomsController.provider.select(
(rooms) => rooms[roomId]?.hasMore ?? false,
),
);
Future<void> jumpToId(String eventId) async {
final index = controllerData.value?.indexWhere(
(element) => element.eventId == eventId,
);
if (index == null) return;
var events = ref.read(controllerProvider).value;
while (events?.any((event) => event.eventId == eventId) != true) {
final room = ref.read(RoomsController.provider)[roomId];
if (room == null || !room.hasMore) return;
await notifier.loadOlder();
await WidgetsBinding.instance.endOfFrame;
events = ref.read(controllerProvider).value;
}
if (events == null || !scrollController.hasClients) return;
listController.value.animateToItem(
index: index,
final sourceIndex = events.indexWhere(
(event) => event.eventId == eventId,
);
final boundaryIndex = events.indexWhere(
(event) => event.eventId == timelineBoundary.value,
);
if (sourceIndex < 0 || boundaryIndex < 0) return;
final listController = sourceIndex <= boundaryIndex
? historyListController.value
: liveListController.value;
if (!listController.isAttached) return;
listController.animateToItem(
index: sourceIndex <= boundaryIndex
? boundaryIndex - sourceIndex
: sourceIndex - boundaryIndex - 1,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
@ -103,87 +127,11 @@ class RoomChat extends HookConsumerWidget {
}
Future<void> loadOlder() async {
if (controllerData case AsyncData(:final value?)) {
topEventBeforeLoad.value = value.firstOrNull?.eventId;
if (controllerData case AsyncData(value: final _?)) {
await notifier.loadOlder();
}
}
useEffect(() {
ref
.read(controllerProvider.future)
.then(
(_) => WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent - .000001,
);
}
}),
);
return null;
}, [scrollController.hasClients]);
useEffect(() {
if (controllerData case AsyncData(
:final value?,
) when scrollController.hasClients) {
if (topEventBeforeLoad.value != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
final index = value.indexWhere(
(event) => event.eventId == topEventBeforeLoad.value,
);
if (index != -1) {
listController.value.jumpToItem(
index: index,
scrollController: scrollController,
alignment: 0,
);
}
}
topEventBeforeLoad.value = null;
});
} else if (scrollController.position.atEdge &&
scrollController.position.pixels != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent,
);
}
});
}
}
return null;
}, [controllerData]);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || !scrollController.position.atEdge) {
return;
}
final room = ref.watch(
RoomsController.provider.select((value) => value[roomId]),
);
if (room == null) return;
if (scrollController.position.pixels == 0) {
if (room.hasMore) {
await loadOlder();
}
} else {
await client.markRead(room);
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [roomId, controllerData]);
final composerNode = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == .escape) {
@ -465,58 +413,117 @@ class RoomChat extends HookConsumerWidget {
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncData(:final value?) ||
AsyncLoading(:final value?) => CustomScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: controllerData is AsyncData
? loadOlder
: null,
child: Text("Load More"),
AsyncLoading(:final value?) => Builder(
builder: (_) {
if (value.isEmpty) return nothing;
var boundaryIndex = value.indexWhere(
(event) =>
event.eventId == timelineBoundary.value,
);
if (boundaryIndex < 0) {
boundaryIndex = value.length - 1;
timelineBoundary.value =
value[boundaryIndex].eventId;
}
final historyLength = boundaryIndex + 1;
final liveLength = value.length - historyLength;
int? historyChildIndex(Key key) {
if (key == const ValueKey("history-loader")) {
return historyLength;
}
if (key is! ValueKey<String>) return null;
final eventIndex = value.indexWhere(
(event) => event.eventId == key.value,
);
return eventIndex < 0 ||
eventIndex >= historyLength
? null
: historyLength - eventIndex - 1;
}
int? liveChildIndex(Key key) {
if (key is! ValueKey<String>) return null;
final eventIndex = value.indexWhere(
(event) => event.eventId == key.value,
);
return eventIndex < historyLength
? null
: eventIndex - historyLength;
}
Widget eventRow(
Event event,
Event? previousEvent,
) => HighlightWrapper(
EventRenderer(
event,
onTapReply: () => jumpToId(event.replyTo!),
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content
is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType !=
"m.replace" &&
"${event.sender}${event.pmp?.id}" ==
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
),
key: ValueKey(event.eventId),
isHighlighted:
highlightedEvent.value == event.eventId,
);
return CustomScrollView(
reverse: true,
center: timelineCenterKey,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: composerSize.value),
),
SuperSliverList.builder(
listController: liveListController.value,
itemCount: liveLength,
findChildIndexCallback: liveChildIndex,
itemBuilder: (_, index) => eventRow(
value[historyLength + index],
value.getOrNull(
historyLength + index - 1,
),
),
),
),
),
SuperSliverList.builder(
listController: listController.value,
itemCount: value.length,
itemBuilder: (_, index) {
final event = value[index];
final previousEvent = value.getOrNull(
index - 1,
);
return HighlightWrapper(
EventRenderer(
event,
onTapReply: () =>
jumpToId(event.replyTo!),
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content
is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType !=
"m.replace" &&
"${event.sender}${event.pmp?.id}" ==
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
),
isHighlighted:
highlightedEvent.value == event.eventId,
);
},
),
SliverPadding(
padding: .only(bottom: composerSize.value),
),
],
SuperSliverList.builder(
key: timelineCenterKey,
listController: historyListController.value,
itemCount: historyLength + 1,
findChildIndexCallback: historyChildIndex,
itemBuilder: (_, index) {
if (index == historyLength) {
return _HistoryLoader(
key: const ValueKey("history-loader"),
hasMore: hasMore,
isLoading:
controllerData is AsyncLoading,
onLoad: loadOlder,
);
}
final event =
value[historyLength - index - 1];
return eventRow(
event,
value.getOrNull(
historyLength - index - 2,
),
);
},
),
],
);
},
),
AsyncData() => nothing,
AsyncLoading() => Loading(),
@ -564,3 +571,42 @@ class RoomChat extends HookConsumerWidget {
);
}
}
class _HistoryLoader extends HookWidget {
final bool hasMore;
final bool isLoading;
final Future<void> Function() onLoad;
const _HistoryLoader({
required this.hasMore,
required this.isLoading,
required this.onLoad,
super.key,
});
@override
Widget build(BuildContext context) {
useEffect(() {
if (hasMore) {
Future.microtask(onLoad);
}
return null;
}, const []);
return Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: hasMore && !isLoading ? onLoad : null,
child: isLoading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(hasMore ? "Load More" : "Start of timeline"),
),
),
);
}
}