add /room/:room/:thread route

edit chat.dart to support threads
edit SendFileDialog and SendPollDialog to support threads
This commit is contained in:
OfficialDakari 2025-10-25 18:43:43 +05:00
parent 74c05be615
commit 251ca9893d
9 changed files with 240 additions and 37 deletions

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:extera_next/pages/thread/thread.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@ -148,6 +149,21 @@ abstract class AppRoutes {
),
),
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: ':threadroot',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ThreadPage(
roomId: state.pathParameters['roomid']!,
threadRootEventId:
state.pathParameters['threadroot']!,
eventId: state.uri.queryParameters['event'],
),
),
),
],
),
],
redirect: loggedOutRedirect,
@ -352,6 +368,18 @@ abstract class AppRoutes {
},
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: ':threadroot',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ThreadPage(
roomId: state.pathParameters['roomid']!,
threadRootEventId: state.pathParameters['threadroot']!,
eventId: state.uri.queryParameters['event'],
),
),
),
GoRoute(
path: 'search',
pageBuilder: (context, state) => defaultPageBuilder(

View File

@ -85,12 +85,14 @@ class ChatPage extends StatelessWidget {
class ChatPageWithRoom extends StatefulWidget {
final Room room;
final Thread? thread;
final List<ShareItem>? shareItems;
final String? eventId;
const ChatPageWithRoom({
super.key,
required this.room,
this.thread,
this.shareItems,
this.eventId,
});
@ -102,14 +104,18 @@ class ChatPageWithRoom extends StatefulWidget {
class ChatController extends State<ChatPageWithRoom>
with WidgetsBindingObserver {
Room get room => sendingClient.getRoomById(roomId) ?? widget.room;
Thread? get thread =>
sendingClient.getRoomById(roomId)?.threads[threadRootEventId] ??
widget.room.threads[threadRootEventId];
late Client sendingClient;
RoomTimeline? timeline;
Timeline? timeline;
late final String readMarkerEventId;
String get roomId => widget.room.id;
String? get threadRootEventId => widget.thread?.rootEvent.eventId;
final AutoScrollController scrollController = AutoScrollController();
@ -134,6 +140,7 @@ class ChatController extends State<ChatPageWithRoom>
builder: (c) => SendFileDialog(
files: details.files,
room: room,
thread: thread,
replyEvent: replyEvent,
outerContext: context,
),
@ -274,6 +281,7 @@ class ChatController extends State<ChatPageWithRoom>
builder: (c) => SendFileDialog(
files: files,
room: room,
thread: thread,
outerContext: context,
replyEvent: replyEvent,
),
@ -324,6 +332,7 @@ class ChatController extends State<ChatPageWithRoom>
void _tryLoadTimeline() async {
final initialEventId = widget.eventId;
loadTimelineFuture = _getTimeline();
Logs().v("Trying to load timeline...");
try {
await loadTimelineFuture;
if (initialEventId != null) scrollToEventId(initialEventId);
@ -387,15 +396,7 @@ class ChatController extends State<ChatPageWithRoom>
animateInEventIndex = i;
}
Future<void> _getTimeline({
String? eventContextId,
}) async {
await Matrix.of(context).client.roomsLoading;
await Matrix.of(context).client.accountDataLoading;
if (eventContextId != null &&
(!eventContextId.isValidMatrixId || eventContextId.sigil != '\$')) {
eventContextId = null;
}
Future<void> _loadRoomTimeline({String? eventContextId}) async {
try {
timeline?.cancelSubscriptions();
timeline = await room.getTimeline(
@ -415,6 +416,56 @@ class ChatController extends State<ChatPageWithRoom>
_showScrollUpMaterialBanner(eventContextId!);
}
}
}
Future<void> _loadThreadTimeline({String? eventContextId}) async {
if (thread == null) {
throw Exception(
"_loadThreadTimeline should not be called, thread == null",
);
}
try {
timeline?.cancelSubscriptions();
timeline = await thread!.getTimeline(
onUpdate: updateView,
eventContextId: eventContextId,
onInsert: onInsert,
);
if (timeline is ThreadTimeline) {
(timeline as ThreadTimeline).getThreadEvents();
}
Logs().v("Thread timeline loaded");
} catch (e, s) {
Logs().w(
'Unable to load timeline on event ID $eventContextId (in thread)',
e,
s);
if (!mounted) return;
timeline = await thread!.getTimeline(
onUpdate: updateView,
onInsert: onInsert,
);
if (!mounted) return;
if (e is TimeoutException || e is IOException) {
_showScrollUpMaterialBanner(eventContextId!);
}
}
}
Future<void> _getTimeline({
String? eventContextId,
}) async {
await Matrix.of(context).client.roomsLoading;
await Matrix.of(context).client.accountDataLoading;
if (eventContextId != null &&
(!eventContextId.isValidMatrixId || eventContextId.sigil != '\$')) {
eventContextId = null;
}
if (thread == null) {
await _loadRoomTimeline(eventContextId: eventContextId);
} else {
await _loadThreadTimeline(eventContextId: eventContextId);
}
timeline!.requestKeys(onlineKeyBackupOnly: false);
if (room.markedUnread) room.markUnread(false);
@ -471,9 +522,13 @@ class ChatController extends State<ChatPageWithRoom>
.then((_) {
_setReadMarkerFuture = null;
});
if (eventId == null || eventId == timeline.room.lastEvent?.eventId) {
Matrix.of(context).backgroundPush?.cancelNotification(roomId);
if (timeline is RoomTimeline) {
if (eventId == null || eventId == timeline.room.lastEvent?.eventId) {
Matrix.of(context).backgroundPush?.cancelNotification(roomId);
}
}
// TODO same for Threads
}
@override
@ -540,12 +595,13 @@ class ChatController extends State<ChatPageWithRoom>
}
// ignore: unawaited_futures
room.sendTextEvent(
sendController.text,
inReplyTo: replyEvent,
editEventId: editEvent?.eventId,
parseCommands: parseCommands,
);
room.sendTextEvent(sendController.text,
inReplyTo: replyEvent,
editEventId: editEvent?.eventId,
parseCommands: parseCommands,
threadRootEventId: thread?.rootEvent.eventId,
threadLastEventId:
thread?.lastEvent?.eventId ?? thread?.rootEvent.eventId);
sendController.value = TextEditingValue(
text: pendingText,
selection: const TextSelection.collapsed(offset: 0),
@ -562,8 +618,13 @@ class ChatController extends State<ChatPageWithRoom>
void sendPollAction() async {
await showAdaptiveDialog(
context: context,
builder: (c) => SendPollDialog(room: room, outerContext: context));
context: context,
builder: (c) => SendPollDialog(
room: room,
thread: thread,
outerContext: context,
),
);
replyEvent = null;
}
@ -582,6 +643,7 @@ class ChatController extends State<ChatPageWithRoom>
builder: (c) => SendFileDialog(
files: files,
room: room,
thread: thread,
outerContext: context,
replyEvent: replyEvent,
),
@ -596,6 +658,7 @@ class ChatController extends State<ChatPageWithRoom>
builder: (c) => SendFileDialog(
files: [XFile.fromData(image)],
room: room,
thread: thread,
outerContext: context,
),
);
@ -612,6 +675,7 @@ class ChatController extends State<ChatPageWithRoom>
builder: (c) => SendFileDialog(
files: [file],
room: room,
thread: thread,
outerContext: context,
),
);
@ -631,6 +695,7 @@ class ChatController extends State<ChatPageWithRoom>
builder: (c) => SendFileDialog(
files: [file],
room: room,
thread: thread,
outerContext: context,
),
);

View File

@ -37,7 +37,7 @@ class ChatEventList extends StatelessWidget {
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
final events = timeline.events.filterByVisibleInGui().filterByThreaded(false);
final events = timeline.events.filterByVisibleInGui().filterByThreaded(controller.thread != null);
final animateInEventIndex = controller.animateInEventIndex;
// create a map of eventId --> index to greatly improve performance of

View File

@ -3,10 +3,12 @@ import 'dart:ui' as ui;
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:extera_next/utils/adaptive_bottom_sheet.dart';
import 'package:extera_next/utils/poll_events.dart';
import 'package:extera_next/widgets/mxc_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:extera_next/generated/l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:swipe_to_action/swipe_to_action.dart';
@ -690,11 +692,58 @@ class Message extends StatelessWidget {
: const SizedBox.shrink(),
),
),
Text(
thread == null
? 'No thread'
: 'Has thread, last event: ${thread!.lastEvent != null ? thread!.lastEvent!.eventId : 'None'}',
),
thread != null
? Align(
alignment: ownMessage
? Alignment.bottomRight
: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
color: Colors.grey[200],
size: 20,
),
const SizedBox(width: 16),
thread!.lastEvent != null
? FutureBuilder<User?>(
future: thread!.lastEvent!
.fetchSenderUser(),
builder:
(context, snapshot) {
final user = snapshot
.data ??
event
.senderFromMemoryOrFallback;
return Avatar(
mxContent:
user.avatarUrl,
name: user
.calcDisplayname(),
size: 24,
);
},
)
: const SizedBox.shrink(),
const SizedBox(width: 6),
thread!.lastEvent != null
? Text(
thread!.lastEvent!.text)
: const Text('Thread'),
],
),
onTap: () => context.go(
'/rooms/${event.roomId}/${event.eventId}',
),
),
),
)
: const SizedBox.shrink(),
],
),
),

View File

@ -23,12 +23,14 @@ import 'package:html_unescape/html_unescape.dart';
class SendFileDialog extends StatefulWidget {
final Room room;
final Thread? thread;
final List<XFile> files;
final BuildContext outerContext;
final Event? replyEvent;
const SendFileDialog({
required this.room,
required this.thread,
required this.files,
required this.outerContext,
this.replyEvent,
@ -151,6 +153,8 @@ class SendFileDialogState extends State<SendFileDialog> {
thumbnail: thumbnail,
shrinkImageMaxDimension: compress ? 1600 : null,
extraContent: extraContent,
threadLastEventId: widget.thread?.lastEvent?.eventId ?? widget.thread?.rootEvent.eventId,
threadRootEventId: widget.thread?.rootEvent.eventId
);
} on MatrixException catch (e) {
final retryAfterMs = e.retryAfterMs;

View File

@ -1,5 +1,4 @@
import 'package:uuid/uuid.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:extera_next/generated/l10n/l10n.dart';
import 'package:matrix/matrix.dart';
@ -8,9 +7,11 @@ class SendPollDialog extends StatefulWidget {
final Room room;
final BuildContext outerContext;
final Event? replyEvent;
final Thread? thread;
const SendPollDialog({
required this.room,
required this.thread,
required this.outerContext,
this.replyEvent,
super.key,
@ -74,11 +75,13 @@ class SendPollDialogState extends State<SendPollDialog> {
'm.text': question,
},
'answers': answers
.map((answer) => {
'id': const Uuid().v4(),
'org.matrix.msc1767.text': answer,
'm.text': answer,
})
.map(
(answer) => {
'id': const Uuid().v4(),
'org.matrix.msc1767.text': answer,
'm.text': answer,
},
)
.toList(),
'max_selections': _maxSelections,
'kind': _kind,
@ -86,7 +89,13 @@ class SendPollDialogState extends State<SendPollDialog> {
};
try {
await widget.room.sendEvent(pollContent, type: 'org.matrix.msc3381.poll.start');
await widget.room.sendEvent(
pollContent,
type: 'org.matrix.msc3381.poll.start',
threadLastEventId: widget.thread?.lastEvent?.eventId ??
widget.thread?.rootEvent.eventId,
threadRootEventId: widget.thread?.rootEvent.eventId,
);
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
} catch (e) {
@ -154,7 +163,7 @@ class SendPollDialogState extends State<SendPollDialog> {
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: _maxSelections,
initialValue: _maxSelections,
decoration: InputDecoration(
labelText: L10n.of(context).maxSelections,
border: const OutlineInputBorder(),
@ -170,7 +179,7 @@ class SendPollDialogState extends State<SendPollDialog> {
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _kind,
initialValue: _kind,
decoration: InputDecoration(
labelText: L10n.of(context).pollType,
border: const OutlineInputBorder(),
@ -202,4 +211,4 @@ class SendPollDialogState extends State<SendPollDialog> {
],
);
}
}
}

View File

@ -0,0 +1,47 @@
import 'package:extera_next/generated/l10n/l10n.dart';
import 'package:extera_next/pages/chat/chat.dart';
import 'package:extera_next/widgets/matrix.dart';
import 'package:extera_next/widgets/share_scaffold_dialog.dart';
import 'package:flutter/material.dart';
class ThreadPage extends StatelessWidget {
final String roomId;
final List<ShareItem>? shareItems;
final String? threadRootEventId;
final String? eventId;
const ThreadPage({
super.key,
required this.roomId,
required this.threadRootEventId,
this.eventId,
this.shareItems,
});
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
final room = client.getRoomById(roomId);
if (room == null) {
return Scaffold(
appBar: AppBar(title: Text(L10n.of(context).oopsSomethingWentWrong)),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
),
),
);
}
final thread = room.threads[threadRootEventId];
return ChatPageWithRoom(
key: Key('chat_page_${roomId}_${threadRootEventId}_$eventId'),
room: room,
thread: thread,
shareItems: shareItems,
eventId: eventId,
);
}
}

View File

@ -2171,7 +2171,7 @@ packages:
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
dependency: "direct main"
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff

View File

@ -102,6 +102,7 @@ dependencies:
wakelock_plus: ^1.2.2
webrtc_interface: ^1.0.13
dio: ^5.9.0
uuid: ^4.5.1
dev_dependencies:
flutter_lints: ^3.0.0