load old messages

This commit is contained in:
Henry Hiles 2025-11-15 17:10:41 -05:00
commit de561e0b95
No known key found for this signature in database
10 changed files with 130 additions and 73 deletions

View file

@ -0,0 +1,30 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/from_controller.dart";
class EventsController extends AsyncNotifier<GetRoomEventsResponse> {
EventsController(this.room);
final Room room;
@override
Future<GetRoomEventsResponse> build({String? from}) async {
final response = await room.client.getRoomEvents(
room.id,
Direction.b,
from: from,
limit: 32,
);
ref.watch(FromController.provider(room).notifier).set(response.end);
return response;
}
Future<GetRoomEventsResponse> prev() async {
final resp = await build(from: ref.read(FromController.provider(room)));
return resp;
}
static final provider = AsyncNotifierProvider.autoDispose
.family<EventsController, GetRoomEventsResponse, Room>(
EventsController.new,
);
}

View file

@ -0,0 +1,15 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
class FromController extends Notifier<String?> {
FromController(_);
@override
String? build() => null;
void set(String? value) => state = value;
static final provider =
NotifierProvider.family<FromController, String?, Room>(
FromController.new,
);
}

View file

@ -1,20 +1,22 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart"; import "package:matrix/matrix.dart";
class MembersController extends AsyncNotifier<List<MatrixEvent>> { class MembersController extends AsyncNotifier<IList<MatrixEvent>> {
final Room room; final Room room;
MembersController(this.room); MembersController(this.room);
@override @override
Future<List<MatrixEvent>> build() async => Future<IList<MatrixEvent>> build() async => IList(
(await room.client.getMembersByRoom( (await room.client.getMembersByRoom(
room.id, room.id,
notMembership: Membership.leave, notMembership: Membership.leave,
)) ?? )) ??
[]; [],
);
static final provider = static final provider =
AsyncNotifierProvider.family<MembersController, List<MatrixEvent>, Room>( AsyncNotifierProvider.family<MembersController, IList<MatrixEvent>, Room>(
MembersController.new, MembersController.new,
); );
} }

View file

@ -3,7 +3,7 @@ import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart"; import "package:matrix/matrix.dart";
import "package:nexus/controllers/timeline_controller.dart"; import "package:nexus/controllers/events_controller.dart";
import "package:nexus/helpers/extension_helper.dart"; import "package:nexus/helpers/extension_helper.dart";
class RoomChatController extends AsyncNotifier<ChatController> { class RoomChatController extends AsyncNotifier<ChatController> {
@ -12,7 +12,7 @@ class RoomChatController extends AsyncNotifier<ChatController> {
@override @override
Future<ChatController> build() async { Future<ChatController> build() async {
final timeline = await ref.watch(TimelineController.provider(room).future); final response = await ref.watch(EventsController.provider(room).future);
ref.onDispose( ref.onDispose(
room.client.onTimelineEvent.stream.listen((event) async { room.client.onTimelineEvent.stream.listen((event) async {
@ -26,8 +26,10 @@ class RoomChatController extends AsyncNotifier<ChatController> {
return InMemoryChatController( return InMemoryChatController(
messages: (await Future.wait( messages: (await Future.wait(
timeline.events.map((event) => event.toMessage()), response.chunk.map(
)).toList().reversed.nonNulls.toList(), (event) => Event.fromMatrixEvent(event, room).toMessage(),
),
)).nonNulls.toList(),
); );
} }
@ -46,13 +48,22 @@ class RoomChatController extends AsyncNotifier<ChatController> {
} }
Future<void> loadOlder() async { Future<void> loadOlder() async {
await ref.watch(TimelineController.provider(room).notifier).prev(); final controller = await future;
final response = await ref
.watch(EventsController.provider(room).notifier)
.prev();
await controller.insertAllMessages(
(await Future.wait(
response.chunk.map(
(event) => Event.fromMatrixEvent(event, room).toMessage(),
),
)).nonNulls.toList().reversed.toList(),
index: 0,
);
} }
Future<void> updateMessage(Message message, Message newMessage) async { Future<void> updateMessage(Message message, Message newMessage) async =>
final controller = await future; (await future).updateMessage(message, newMessage);
return controller.updateMessage(message, newMessage);
}
Future<void> send(String message, {Message? replyTo}) async => Future<void> send(String message, {Message? replyTo}) async =>
await room.sendTextEvent( await room.sendTextEvent(

View file

@ -1,13 +1,14 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/extension_helper.dart"; import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/models/space.dart"; import "package:nexus/models/space.dart";
class SpacesController extends AsyncNotifier<List<Space>> { class SpacesController extends AsyncNotifier<IList<Space>> {
@override @override
Future<List<Space>> build() async { Future<IList<Space>> build() async {
final client = await ref.watch(ClientController.provider.future); final client = await ref.watch(ClientController.provider.future);
final topLevel = await Future.wait( final topLevel = await Future.wait(
@ -28,7 +29,7 @@ class SpacesController extends AsyncNotifier<List<Space>> {
final topLevelSpaces = topLevel.where((r) => r.roomData.isSpace).toList(); final topLevelSpaces = topLevel.where((r) => r.roomData.isSpace).toList();
final topLevelRooms = topLevel.where((r) => !r.roomData.isSpace).toList(); final topLevelRooms = topLevel.where((r) => !r.roomData.isSpace).toList();
return [ return IList([
Space( Space(
client: client, client: client,
title: "Home", title: "Home",
@ -66,10 +67,10 @@ class SpacesController extends AsyncNotifier<List<Space>> {
), ),
), ),
)), )),
]; ]);
} }
static final provider = AsyncNotifierProvider<SpacesController, List<Space>>( static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
SpacesController.new, SpacesController.new,
); );
} }

View file

@ -1,21 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
class TimelineController extends AsyncNotifier<Timeline> {
TimelineController(this.room);
final Room room;
@override
Future<Timeline> build() => room.getTimeline();
Future<void> prev() async {
final timeline = await future;
await timeline.requestHistory();
state = AsyncValue.data(timeline);
}
static final provider =
AsyncNotifierProvider.family<TimelineController, Timeline, Room>(
TimelineController.new,
);
}

View file

@ -39,7 +39,9 @@ extension ToMessage on Event {
final metadata = { final metadata = {
"formatted": formattedText.isEmpty ? body : formattedText, "formatted": formattedText.isEmpty ? body : formattedText,
"eventType": type, "eventType": type,
"displayName": senderFromMemoryOrFallback.displayName, "displayName":
senderFromMemoryOrFallback.displayName ??
senderFromMemoryOrFallback.id,
"txnId": transactionId, "txnId": transactionId,
}; };

View file

@ -29,6 +29,7 @@ class App extends StatelessWidget {
builder: (lightDynamic, darkDynamic) => LayoutBuilder( builder: (lightDynamic, darkDynamic) => LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 650; final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000;
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
@ -47,7 +48,12 @@ class App extends StatelessWidget {
builder: (context) => Row( builder: (context) => Row(
children: [ children: [
if (isDesktop) Sidebar(), if (isDesktop) Sidebar(),
Expanded(child: RoomChat(isDesktop: isDesktop)), Expanded(
child: RoomChat(
isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault,
),
),
], ],
), ),
), ),

View file

@ -24,7 +24,12 @@ import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart
class RoomChat extends HookConsumerWidget { class RoomChat extends HookConsumerWidget {
final bool isDesktop; final bool isDesktop;
const RoomChat({required this.isDesktop, super.key}); final bool showMembersByDefault;
const RoomChat({
required this.isDesktop,
required this.showMembersByDefault,
super.key,
});
void showContextMenu({ void showContextMenu({
required BuildContext context, required BuildContext context,
@ -48,8 +53,7 @@ class RoomChat extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final replyToMessage = useState<Message?>(null); final replyToMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(isDesktop); final memberListOpened = useState<bool>(showMembersByDefault);
final urlRegex = RegExp(r"https?://[^\s\]\(\)]+");
final theme = Theme.of(context); final theme = Theme.of(context);
return ref return ref
.watch(CurrentRoomController.provider) .watch(CurrentRoomController.provider)
@ -105,14 +109,13 @@ class RoomChat extends HookConsumerWidget {
onTap: () => replyToMessage.value = message, onTap: () => replyToMessage.value = message,
), ),
builders: Builders( builders: Builders(
chatAnimatedListBuilder: (context, itemBuilder) { chatAnimatedListBuilder: (_, itemBuilder) =>
return ChatAnimatedList( ChatAnimatedList(
itemBuilder: itemBuilder, itemBuilder: itemBuilder,
onEndReached: ref onEndReached: ref
.watch(controllerProvider.notifier) .watch(controllerProvider.notifier)
.loadOlder, .loadOlder,
); ),
},
composerBuilder: (_) => ChatBox( composerBuilder: (_) => ChatBox(
replyToMessage: replyToMessage.value, replyToMessage: replyToMessage.value,
onDismiss: () => replyToMessage.value = null, onDismiss: () => replyToMessage.value = null,
@ -133,15 +136,21 @@ class RoomChat extends HookConsumerWidget {
return SizedBox.shrink(); return SizedBox.shrink();
} }
if (element.localName == "code") { if (element.localName == "code") {
if (element.parent?.localName ==
"pre") {
return SizedBox( return SizedBox(
width: 400, width: 400,
child: CodeField( child: CodeField(
name: element.className name: element.className
.replaceAll("language-", ""), .replaceAll(
"language-",
"",
),
codes: element.text, codes: element.text,
), ),
); );
} }
}
if (element.localName == "img") { if (element.localName == "img") {
final src = Uri.tryParse( final src = Uri.tryParse(
element.attributes["src"] ?? "", element.attributes["src"] ?? "",
@ -204,11 +213,7 @@ class RoomChat extends HookConsumerWidget {
), ),
linkPreviewBuilder: (_, message, isSentByMe) => linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview( LinkPreview(
text: text: message.text,
urlRegex
.firstMatch(message.text)
?.group(0) ??
"",
backgroundColor: isSentByMe backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary ? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow, : theme.colorScheme.surfaceContainerLow,
@ -306,11 +311,13 @@ class RoomChat extends HookConsumerWidget {
), ),
), ),
if (memberListOpened.value == true && isDesktop) if (memberListOpened.value == true && showMembersByDefault)
MemberList(room.roomData), MemberList(room.roomData),
], ],
), ),
endDrawer: isDesktop ? null : MemberList(room.roomData), endDrawer: showMembersByDefault
? null
: MemberList(room.roomData),
); );
}, },
); );

View file

@ -1,5 +1,4 @@
import "dart:math"; import "dart:math";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart"; import "package:flutter_chat_ui/flutter_chat_ui.dart";
@ -29,13 +28,18 @@ class TopWidget extends ConsumerWidget {
loading: SizedBox.shrink, loading: SizedBox.shrink,
data: (replyMessage) { data: (replyMessage) {
if (replyMessage == null) return SizedBox.shrink(); if (replyMessage == null) return SizedBox.shrink();
// Black magic to limit reply preview length
final replyText = message is TextMessage final replyText = message is TextMessage
? replyMessage.text.substring( ? replyMessage.text.substring(
0, 0,
min( min(
max( max(
min( min(
max(
(message as TextMessage).text.length - 20, (message as TextMessage).text.length - 20,
message.metadata?["displayName"].length,
),
replyMessage.text.length, replyMessage.text.length,
), ),
5, 5,