forked from Nexus/nexus
fix: timeline anchoring
This commit is contained in:
parent
0f364d7613
commit
3eb39e6782
1 changed files with 183 additions and 137 deletions
|
|
@ -72,23 +72,47 @@ class RoomChat extends HookConsumerWidget {
|
||||||
|
|
||||||
final controllerProvider = RoomChatController.provider(roomId);
|
final controllerProvider = RoomChatController.provider(roomId);
|
||||||
final notifier = ref.watch(controllerProvider.notifier);
|
final notifier = ref.watch(controllerProvider.notifier);
|
||||||
|
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
|
|
||||||
final listController = useRef(ListController());
|
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
final controllerData = ref.watch(controllerProvider);
|
final controllerData = ref.watch(controllerProvider);
|
||||||
|
final historyListController = useRef(ListController());
|
||||||
final topEventBeforeLoad = useState<String?>(null);
|
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 {
|
Future<void> jumpToId(String eventId) async {
|
||||||
final index = controllerData.value?.indexWhere(
|
var events = ref.read(controllerProvider).value;
|
||||||
(element) => element.eventId == eventId,
|
while (events?.any((event) => event.eventId == eventId) != true) {
|
||||||
);
|
final room = ref.read(RoomsController.provider)[roomId];
|
||||||
if (index == null) return;
|
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(
|
final sourceIndex = events.indexWhere(
|
||||||
index: index,
|
(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,
|
scrollController: scrollController,
|
||||||
alignment: 0.5,
|
alignment: 0.5,
|
||||||
duration: (_) => .new(milliseconds: 700),
|
duration: (_) => .new(milliseconds: 700),
|
||||||
|
|
@ -103,87 +127,11 @@ class RoomChat extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadOlder() async {
|
Future<void> loadOlder() async {
|
||||||
if (controllerData case AsyncData(:final value?)) {
|
if (controllerData case AsyncData(value: final _?)) {
|
||||||
topEventBeforeLoad.value = value.firstOrNull?.eventId;
|
|
||||||
await notifier.loadOlder();
|
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(
|
final composerNode = useFocusNode(
|
||||||
onKeyEvent: (_, event) {
|
onKeyEvent: (_, event) {
|
||||||
if (event is KeyDownEvent && event.logicalKey == .escape) {
|
if (event is KeyDownEvent && event.logicalKey == .escape) {
|
||||||
|
|
@ -465,58 +413,117 @@ class RoomChat extends HookConsumerWidget {
|
||||||
padding: .symmetric(horizontal: 4),
|
padding: .symmetric(horizontal: 4),
|
||||||
child: switch (controllerData) {
|
child: switch (controllerData) {
|
||||||
AsyncData(:final value?) ||
|
AsyncData(:final value?) ||
|
||||||
AsyncLoading(:final value?) => CustomScrollView(
|
AsyncLoading(:final value?) => Builder(
|
||||||
keyboardDismissBehavior:
|
builder: (_) {
|
||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
if (value.isEmpty) return nothing;
|
||||||
controller: scrollController,
|
var boundaryIndex = value.indexWhere(
|
||||||
slivers: [
|
(event) =>
|
||||||
SliverToBoxAdapter(
|
event.eventId == timelineBoundary.value,
|
||||||
child: Padding(
|
);
|
||||||
padding: .symmetric(vertical: 36),
|
if (boundaryIndex < 0) {
|
||||||
child: Center(
|
boundaryIndex = value.length - 1;
|
||||||
child: ElevatedButton(
|
timelineBoundary.value =
|
||||||
onPressed: controllerData is AsyncData
|
value[boundaryIndex].eventId;
|
||||||
? loadOlder
|
}
|
||||||
: null,
|
|
||||||
child: Text("Load More"),
|
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(
|
||||||
),
|
key: timelineCenterKey,
|
||||||
|
listController: historyListController.value,
|
||||||
SuperSliverList.builder(
|
itemCount: historyLength + 1,
|
||||||
listController: listController.value,
|
findChildIndexCallback: historyChildIndex,
|
||||||
itemCount: value.length,
|
itemBuilder: (_, index) {
|
||||||
itemBuilder: (_, index) {
|
if (index == historyLength) {
|
||||||
final event = value[index];
|
return _HistoryLoader(
|
||||||
final previousEvent = value.getOrNull(
|
key: const ValueKey("history-loader"),
|
||||||
index - 1,
|
hasMore: hasMore,
|
||||||
);
|
isLoading:
|
||||||
return HighlightWrapper(
|
controllerData is AsyncLoading,
|
||||||
EventRenderer(
|
onLoad: loadOlder,
|
||||||
event,
|
);
|
||||||
onTapReply: () =>
|
}
|
||||||
jumpToId(event.replyTo!),
|
final event =
|
||||||
getEventOptions: getEventOptions,
|
value[historyLength - index - 1];
|
||||||
isGrouped:
|
return eventRow(
|
||||||
previousEvent?.content
|
event,
|
||||||
is MessageContent &&
|
value.getOrNull(
|
||||||
previousEvent?.redactedBy == null &&
|
historyLength - index - 2,
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
AsyncData() => nothing,
|
AsyncData() => nothing,
|
||||||
AsyncLoading() => Loading(),
|
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"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue