refactor chatview to have two sliver lists
Co-authored-by: Erwan Leboucher <erwanleboucher@gmail.com>
This commit is contained in:
parent
eeb2e2f54b
commit
110caff170
4 changed files with 161 additions and 152 deletions
|
|
@ -29,16 +29,9 @@ class RoomChatController(final String roomId)
|
||||||
|
|
||||||
if (!room.hasFetchedState) {
|
if (!room.hasFetchedState) {
|
||||||
final state = await client.getRoomState(.new(roomId: roomId));
|
final state = await client.getRoomState(.new(roomId: roomId));
|
||||||
|
|
||||||
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// While there are under 20 events, try to load more
|
|
||||||
// until there's no more or the conditions are met.
|
|
||||||
if (room.hasMore && room.timeline.length < 20) {
|
|
||||||
loadOlder();
|
|
||||||
}
|
|
||||||
|
|
||||||
return room.timeline
|
return 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)
|
||||||
|
|
|
||||||
|
|
@ -4,126 +4,148 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
final class ChatScroll({
|
final class ChatScroll<T>({
|
||||||
required final ListController listController,
|
required final IList<T> historyItems,
|
||||||
|
required final IList<T> liveItems,
|
||||||
|
required final GlobalKey centerKey,
|
||||||
|
required final ListController historyListController,
|
||||||
|
required final ListController liveListController,
|
||||||
required final ScrollController scrollController,
|
required final ScrollController scrollController,
|
||||||
required final bool hasMore,
|
required final bool hasMore,
|
||||||
|
required final bool isLoadingOlder,
|
||||||
required final Future<void> Function() loadOlder,
|
required final Future<void> Function() loadOlder,
|
||||||
required final Future<void> Function(String id) jumpToId,
|
required final Future<void> Function(String id) jumpToId,
|
||||||
}) {
|
}) {
|
||||||
static ChatScroll use<T>({
|
static ChatScroll<T> use<T>({
|
||||||
required AsyncValue<IList<T>?> controllerData,
|
required AsyncValue<IList<T>?> controllerData,
|
||||||
required String Function(T item) id,
|
required String Function(T item) id,
|
||||||
required Future<bool> Function() loadOlder,
|
required Future<bool> Function() loadOlder,
|
||||||
required bool Function() shouldLoadOlder,
|
|
||||||
required Future<void> Function() onReachedBottom,
|
required Future<void> Function() onReachedBottom,
|
||||||
}) {
|
}) {
|
||||||
final listController = useRef(ListController());
|
final historyListController = useRef(ListController());
|
||||||
|
final liveListController = useRef(ListController());
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
|
final centerKey = useMemoized(GlobalKey.new);
|
||||||
|
|
||||||
|
final anchorId = useState<String?>(null);
|
||||||
final hasMore = useState(true);
|
final hasMore = useState(true);
|
||||||
final topItemBeforeLoad = useState<String?>(null);
|
final isLoadingOlder = useState(false);
|
||||||
final loadingOlder = useRef(false);
|
|
||||||
final initialized = useRef(false);
|
final anchorIdValue = anchorId.value;
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (anchorId.value == null) {
|
||||||
|
if (controllerData case AsyncData(:final value?)
|
||||||
|
when value.isNotEmpty) {
|
||||||
|
anchorId.value = id(value.last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [controllerData]);
|
||||||
|
|
||||||
|
final ({IList<T> history, IList<T> live}) split = useMemoized(() {
|
||||||
|
final items = controllerData.value;
|
||||||
|
final anchor = anchorIdValue;
|
||||||
|
|
||||||
|
if (items == null || anchor == null) {
|
||||||
|
return (history: const .empty(), live: const .empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
final anchorIndex = items.indexWhere((item) => id(item) == anchor);
|
||||||
|
|
||||||
|
if (anchorIndex == -1) {
|
||||||
|
return (history: const .empty(), live: items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
history: items.take(anchorIndex).toIList().reversed.toIList(),
|
||||||
|
live: items.skip(anchorIndex).toIList(),
|
||||||
|
);
|
||||||
|
}, [controllerData, anchorIdValue]);
|
||||||
|
|
||||||
Future<void> loadOlderItems() async {
|
Future<void> loadOlderItems() async {
|
||||||
if (loadingOlder.value || !hasMore.value) return;
|
if (!hasMore.value || isLoadingOlder.value) return;
|
||||||
|
|
||||||
if (controllerData case AsyncData(:final value?)) {
|
isLoadingOlder.value = true;
|
||||||
loadingOlder.value = true;
|
|
||||||
topItemBeforeLoad.value = value.firstOrNull == null
|
|
||||||
? null
|
|
||||||
: id(value.first);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
hasMore.value = await loadOlder();
|
hasMore.value = await loadOlder();
|
||||||
} finally {
|
} finally {
|
||||||
loadingOlder.value = false;
|
isLoadingOlder.value = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> jumpToId(String itemId) async {
|
Future<void> jumpToId(String itemId) async {
|
||||||
final index =
|
if (!scrollController.hasClients) return;
|
||||||
controllerData.value?.indexWhere((item) => id(item) == itemId) ?? -1;
|
|
||||||
|
|
||||||
if (index == -1) return;
|
final historyIndex = split.history.indexWhere(
|
||||||
|
(item) => id(item) == itemId,
|
||||||
|
);
|
||||||
|
|
||||||
listController.value.animateToItem(
|
if (historyIndex != -1) {
|
||||||
index: index,
|
historyListController.value.animateToItem(
|
||||||
|
index: historyIndex,
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: (_) => .new(milliseconds: 700),
|
||||||
|
curve: (_) => Curves.easeInOut,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final liveIndex = split.live.indexWhere((item) => id(item) == itemId);
|
||||||
|
|
||||||
|
if (liveIndex != -1) {
|
||||||
|
liveListController.value.animateToItem(
|
||||||
|
index: liveIndex,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
alignment: 0.5,
|
alignment: 0.5,
|
||||||
duration: (_) => .new(milliseconds: 700),
|
duration: (_) => .new(milliseconds: 700),
|
||||||
curve: (_) => Curves.easeInOut,
|
curve: (_) => Curves.easeInOut,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (controllerData case AsyncData(:final value?)) {
|
const loadThreshold = 500.0;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
const bottomThreshold = 50.0;
|
||||||
if (!scrollController.hasClients) return;
|
|
||||||
|
|
||||||
if (!initialized.value) {
|
void checkPosition() {
|
||||||
initialized.value = true;
|
if (!scrollController.hasClients) {
|
||||||
|
|
||||||
if (value.isNotEmpty) {
|
|
||||||
listController.value.jumpToItem(
|
|
||||||
index: value.length - 1,
|
|
||||||
scrollController: scrollController,
|
|
||||||
alignment: 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final topItem = topItemBeforeLoad.value;
|
final position = scrollController.position;
|
||||||
|
|
||||||
if (topItem != null) {
|
if (position.extentAfter <= loadThreshold) {
|
||||||
final index = value.indexWhere((item) => id(item) == topItem);
|
if (hasMore.value && !isLoadingOlder.value) {
|
||||||
|
loadOlderItems();
|
||||||
if (index != -1) {
|
|
||||||
listController.value.jumpToItem(
|
|
||||||
index: index,
|
|
||||||
scrollController: scrollController,
|
|
||||||
alignment: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
topItemBeforeLoad.value = null;
|
|
||||||
} else if (scrollController.position.atEdge &&
|
|
||||||
scrollController.position.pixels != 0) {
|
|
||||||
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [controllerData]);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
Future<void> listener() async {
|
|
||||||
if (!scrollController.hasClients || !scrollController.position.atEdge) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollController.position.pixels == 0) {
|
|
||||||
if (shouldLoadOlder()) {
|
|
||||||
await loadOlderItems();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await onReachedBottom();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollController.addListener(listener);
|
if (position.extentBefore <= bottomThreshold) {
|
||||||
|
onReachedBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => scrollController.removeListener(listener);
|
scrollController.addListener(checkPosition);
|
||||||
}, [controllerData]);
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
|
||||||
|
|
||||||
|
return () => scrollController.removeListener(checkPosition);
|
||||||
|
}, [scrollController, onReachedBottom]);
|
||||||
|
|
||||||
return .new(
|
return .new(
|
||||||
listController: listController.value,
|
historyItems: split.history,
|
||||||
|
liveItems: split.live,
|
||||||
|
centerKey: centerKey,
|
||||||
|
historyListController: historyListController.value,
|
||||||
|
liveListController: liveListController.value,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
hasMore: hasMore.value,
|
hasMore: hasMore.value,
|
||||||
|
isLoadingOlder: isLoadingOlder.value,
|
||||||
loadOlder: loadOlderItems,
|
loadOlder: loadOlderItems,
|
||||||
jumpToId: jumpToId,
|
jumpToId: jumpToId,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,79 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:nexus/helpers/hooks/chat_scroll.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/widgets/renderers/event.dart";
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
|
||||||
import "package:nexus/widgets/loading.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 AsyncValue<IList<Event>?> controllerData,
|
required final ChatScroll<Event> scroll,
|
||||||
required final ScrollController scrollController,
|
|
||||||
required final ListController listController,
|
|
||||||
required final bool hasMore,
|
|
||||||
required final Future<void> Function() loadOlder,
|
|
||||||
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,
|
||||||
required final double composerHeight,
|
required final double composerHeight,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
bool isGrouped(Event event, Event? previousEvent) =>
|
||||||
Widget build(BuildContext context) => switch (controllerData) {
|
|
||||||
AsyncData(:final value?) || AsyncLoading(:final value?) => CustomScrollView(
|
|
||||||
keyboardDismissBehavior: .onDrag,
|
|
||||||
controller: scrollController,
|
|
||||||
slivers: [
|
|
||||||
if (hasMore)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: .symmetric(vertical: 36),
|
|
||||||
child: Center(
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: controllerData is AsyncData ? loadOlder : null,
|
|
||||||
child: Text("Load More"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
SuperSliverList.builder(
|
|
||||||
listController: listController,
|
|
||||||
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?.content is MessageContent &&
|
||||||
previousEvent?.redactedBy == null &&
|
previousEvent?.redactedBy == null &&
|
||||||
previousEvent?.relationType != "m.replace" &&
|
previousEvent?.relationType != "m.replace" &&
|
||||||
event.sender == previousEvent?.sender &&
|
event.sender == previousEvent?.sender &&
|
||||||
event.pmp?.id == previousEvent?.pmp?.id,
|
event.pmp?.id == previousEvent?.pmp?.id;
|
||||||
|
|
||||||
|
Widget eventRow(
|
||||||
|
Event event,
|
||||||
|
Event? previousEvent, {
|
||||||
|
required Future<void> Function(String) jumpToId,
|
||||||
|
required IList<PopupMenuEntry> Function(Event) getEventOptions,
|
||||||
|
required String? highlightedEvent,
|
||||||
|
}) => HighlightWrapper(
|
||||||
|
EventRenderer(
|
||||||
|
event,
|
||||||
|
onTapReply: () => jumpToId(event.replyTo!),
|
||||||
|
getEventOptions: getEventOptions,
|
||||||
|
isGrouped: isGrouped(event, previousEvent),
|
||||||
),
|
),
|
||||||
|
key: ValueKey(event.eventId),
|
||||||
isHighlighted: highlightedEvent == event.eventId,
|
isHighlighted: highlightedEvent == event.eventId,
|
||||||
);
|
);
|
||||||
},
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => CustomScrollView(
|
||||||
|
reverse: true,
|
||||||
|
center: scroll.centerKey,
|
||||||
|
keyboardDismissBehavior: .onDrag,
|
||||||
|
controller: scroll.scrollController,
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
|
||||||
|
|
||||||
|
SuperSliverList.builder(
|
||||||
|
listController: scroll.liveListController,
|
||||||
|
itemCount: scroll.liveItems.length,
|
||||||
|
itemBuilder: (_, index) => eventRow(
|
||||||
|
scroll.liveItems[index],
|
||||||
|
index > 0
|
||||||
|
? scroll.liveItems.getOrNull(index - 1)
|
||||||
|
: scroll.historyItems.firstOrNull,
|
||||||
|
jumpToId: jumpToId,
|
||||||
|
getEventOptions: getEventOptions,
|
||||||
|
highlightedEvent: highlightedEvent,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
SliverPadding(padding: .only(bottom: composerHeight)),
|
SuperSliverList.builder(
|
||||||
|
key: scroll.centerKey,
|
||||||
|
listController: scroll.historyListController,
|
||||||
|
itemCount: scroll.historyItems.length,
|
||||||
|
itemBuilder: (_, index) => eventRow(
|
||||||
|
scroll.historyItems[index],
|
||||||
|
scroll.historyItems.getOrNull(index),
|
||||||
|
jumpToId: jumpToId,
|
||||||
|
getEventOptions: getEventOptions,
|
||||||
|
highlightedEvent: highlightedEvent,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
AsyncData() => Center(child: Text("Nothing to see here...")),
|
|
||||||
AsyncLoading() => Loading(),
|
|
||||||
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
|
||||||
error,
|
|
||||||
stackTrace,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,6 @@ final class const RoomChat({
|
||||||
controllerData: controllerData,
|
controllerData: controllerData,
|
||||||
id: (event) => event.eventId,
|
id: (event) => event.eventId,
|
||||||
loadOlder: notifier.loadOlder,
|
loadOlder: notifier.loadOlder,
|
||||||
shouldLoadOlder: () => ref.read(
|
|
||||||
RoomsController.provider.select(
|
|
||||||
(rooms) => rooms[roomId]?.hasMore ?? false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onReachedBottom: () async {
|
onReachedBottom: () async {
|
||||||
final room = ref.read(
|
final room = ref.read(
|
||||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||||
|
|
@ -149,11 +144,7 @@ final class const RoomChat({
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: .symmetric(horizontal: 4),
|
padding: .symmetric(horizontal: 4),
|
||||||
child: ChatTimeline(
|
child: ChatTimeline(
|
||||||
controllerData: controllerData,
|
scroll: scroll,
|
||||||
scrollController: scroll.scrollController,
|
|
||||||
listController: scroll.listController,
|
|
||||||
hasMore: scroll.hasMore,
|
|
||||||
loadOlder: scroll.loadOlder,
|
|
||||||
jumpToId: jumpToId,
|
jumpToId: jumpToId,
|
||||||
getEventOptions: getEventOptions,
|
getEventOptions: getEventOptions,
|
||||||
highlightedEvent: highlightedEvent.value,
|
highlightedEvent: highlightedEvent.value,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue