forked from Nexus/nexus
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1d6a98f165 |
|||
|
1723a0691a |
|||
|
3eb39e6782 |
|||
|
0f364d7613 |
2 changed files with 197 additions and 154 deletions
|
|
@ -17,27 +17,30 @@ import "package:nexus/models/room.dart";
|
|||
|
||||
class RoomChatController extends AsyncNotifier<IList<Event>?> {
|
||||
final String roomId;
|
||||
Future<bool>? _loadingOlder;
|
||||
RoomChatController(this.roomId);
|
||||
|
||||
@override
|
||||
Future<IList<Event>?> build() async {
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
final room = ref.watch(
|
||||
final initialRoom = ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||
);
|
||||
|
||||
if (room == null) return null;
|
||||
if (initialRoom == null) return null;
|
||||
Room room = initialRoom;
|
||||
|
||||
if (!room.hasFetchedState) {
|
||||
final state = await client.getRoomState(.new(roomId: roomId));
|
||||
|
||||
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
||||
room = ref.read(RoomsController.provider)[roomId] ?? room;
|
||||
}
|
||||
|
||||
// While there are under 20 events, try to load more
|
||||
// until there's no more or the conditions are met.
|
||||
// Load one more page when the initial timeline is short.
|
||||
if (room.hasMore && room.timeline.length < 20) {
|
||||
loadOlder();
|
||||
await loadOlder();
|
||||
room = ref.read(RoomsController.provider)[roomId] ?? room;
|
||||
}
|
||||
|
||||
return room.timeline
|
||||
|
|
@ -74,18 +77,22 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
|
|||
),
|
||||
);
|
||||
|
||||
Future<bool> loadOlder() async {
|
||||
final timelineKeys = ref
|
||||
.read(RoomsController.provider.select((value) => value[roomId]))
|
||||
?.timeline
|
||||
.keys;
|
||||
Future<bool> loadOlder() => _loadingOlder ??= _loadOlder().whenComplete(() {
|
||||
_loadingOlder = null;
|
||||
});
|
||||
|
||||
Future<bool> _loadOlder() async {
|
||||
final room = ref.read(RoomsController.provider)[roomId];
|
||||
if (room == null || !room.hasMore) return false;
|
||||
|
||||
final timelineKeys = room.timeline.keys;
|
||||
final response = await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.paginate(
|
||||
.new(
|
||||
roomId: roomId,
|
||||
maxTimelineId: timelineKeys?.isNotEmpty == true
|
||||
? timelineKeys?.reduce(min)
|
||||
maxTimelineId: timelineKeys.isNotEmpty
|
||||
? timelineKeys.reduce(min)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,23 +72,40 @@ 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;
|
||||
final events = controllerData.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),
|
||||
|
|
@ -102,88 +119,6 @@ class RoomChat extends HookConsumerWidget {
|
|||
});
|
||||
}
|
||||
|
||||
Future<void> loadOlder() async {
|
||||
if (controllerData case AsyncData(:final value?)) {
|
||||
topEventBeforeLoad.value = value.firstOrNull?.eventId;
|
||||
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,38 +400,56 @@ 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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SuperSliverList.builder(
|
||||
listController: listController.value,
|
||||
itemCount: value.length,
|
||||
itemBuilder: (_, index) {
|
||||
final event = value[index];
|
||||
final previousEvent = value.getOrNull(
|
||||
index - 1,
|
||||
AsyncLoading(:final value?) => Builder(
|
||||
builder: (_) {
|
||||
if (value.isEmpty) return nothing;
|
||||
var boundaryIndex = value.indexWhere(
|
||||
(event) =>
|
||||
event.eventId == timelineBoundary.value,
|
||||
);
|
||||
return HighlightWrapper(
|
||||
if (boundaryIndex < 0) {
|
||||
boundaryIndex = value.length - 1;
|
||||
timelineBoundary.value =
|
||||
value[boundaryIndex].eventId;
|
||||
}
|
||||
|
||||
final historyLength = boundaryIndex + 1;
|
||||
final liveLength = value.length - historyLength;
|
||||
|
||||
int? eventIndexForKey(Key key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final index = value.indexWhere(
|
||||
(event) => event.eventId == key.value,
|
||||
);
|
||||
return index < 0 ? null : index;
|
||||
}
|
||||
|
||||
int? historyChildIndex(Key key) {
|
||||
if (key == const ValueKey("history-loader")) {
|
||||
return historyLength;
|
||||
}
|
||||
final eventIndex = eventIndexForKey(key);
|
||||
return eventIndex == null ||
|
||||
eventIndex >= historyLength
|
||||
? null
|
||||
: historyLength - eventIndex - 1;
|
||||
}
|
||||
|
||||
int? liveChildIndex(Key key) {
|
||||
final eventIndex = eventIndexForKey(key);
|
||||
return eventIndex == null ||
|
||||
eventIndex < historyLength
|
||||
? null
|
||||
: eventIndex - historyLength;
|
||||
}
|
||||
|
||||
Widget eventRow(
|
||||
Event event,
|
||||
Event? previousEvent,
|
||||
) => HighlightWrapper(
|
||||
EventRenderer(
|
||||
event,
|
||||
onTapReply: () =>
|
||||
jumpToId(event.replyTo!),
|
||||
onTapReply: () => jumpToId(event.replyTo!),
|
||||
getEventOptions: getEventOptions,
|
||||
isGrouped:
|
||||
previousEvent?.content
|
||||
|
|
@ -507,16 +460,60 @@ class RoomChat extends HookConsumerWidget {
|
|||
"${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,
|
||||
itemCount: historyLength + 1,
|
||||
findChildIndexCallback: historyChildIndex,
|
||||
itemBuilder: (_, index) {
|
||||
if (index == historyLength) {
|
||||
return _HistoryLoader(
|
||||
key: const ValueKey("history-loader"),
|
||||
hasMore: hasMore,
|
||||
isLoading:
|
||||
controllerData is AsyncLoading,
|
||||
onLoad: notifier.loadOlder,
|
||||
);
|
||||
}
|
||||
final event =
|
||||
value[historyLength - index - 1];
|
||||
return eventRow(
|
||||
event,
|
||||
value.getOrNull(
|
||||
historyLength - index - 2,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
SliverPadding(
|
||||
padding: .only(bottom: composerSize.value),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
AsyncData() => nothing,
|
||||
AsyncLoading() => Loading(),
|
||||
|
|
@ -564,3 +561,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