feat: Implement threads

This commit is contained in:
krille-chan 2025-11-04 21:08:45 +01:00
parent b5e59d9bb9
commit 380625327a
No known key found for this signature in database
7 changed files with 166 additions and 21 deletions

View file

@ -3431,7 +3431,7 @@
"addAnswerOption": "Add answer option",
"allowMultipleAnswers": "Allow multiple answers",
"pollHasBeenEnded": "Poll has been ended",
"countVotes": "{count} votes",
"countVotes": "{count, plural, =1{One vote} other{{count} votes}}",
"@countVotes": {
"type": "int",
"placeholders": {
@ -3440,5 +3440,17 @@
}
}
},
"answersWillBeVisibleWhenPollHasEnded": "Answers will be visible when poll has ended"
"answersWillBeVisibleWhenPollHasEnded": "Answers will be visible when poll has ended",
"replyInThread": "Reply in thread",
"countReplies": "{count, plural, =1{One reply} other{{count} replies}}",
"@countReplies": {
"type": "int",
"placeholders": {
"count": {
"type": "int"
}
}
},
"thread": "Thread",
"backToMainChat": "Back to main chat"
}

View file

@ -102,6 +102,8 @@ class ChatController extends State<ChatPageWithRoom>
Timeline? timeline;
String? activeThreadId;
late final String readMarkerEventId;
String get roomId => widget.room.id;
@ -130,6 +132,8 @@ class ChatController extends State<ChatPageWithRoom>
files: details.files,
room: room,
outerContext: context,
threadRootEventId: activeThreadId,
threadLastEventId: threadLastEventId,
),
);
}
@ -167,6 +171,25 @@ class ChatController extends State<ChatPageWithRoom>
bool showEmojiPicker = false;
String? get threadLastEventId {
final threadId = activeThreadId;
if (threadId == null) return null;
return timeline?.events
.filterByVisibleInGui(threadId: threadId)
.firstOrNull
?.eventId;
}
void enterThread(String eventId) => setState(() {
activeThreadId = eventId;
selectedEvents.clear();
});
void closeThread() => setState(() {
activeThreadId = null;
selectedEvents.clear();
});
void recreateChat() async {
final room = this.room;
final userId = room.directChatMatrixID;
@ -267,6 +290,8 @@ class ChatController extends State<ChatPageWithRoom>
files: files,
room: room,
outerContext: context,
threadRootEventId: activeThreadId,
threadLastEventId: threadLastEventId,
),
);
}
@ -340,7 +365,9 @@ class ChatController extends State<ChatPageWithRoom>
final Set<String> expandedEventIds = {};
void expandEventsFrom(Event event, bool expand) {
final events = timeline!.events.filterByVisibleInGui();
final events = timeline!.events.filterByVisibleInGui(
threadId: activeThreadId,
);
final start = events.indexOf(event);
setState(() {
for (var i = start; i < events.length; i++) {
@ -367,6 +394,7 @@ class ChatController extends State<ChatPageWithRoom>
: timeline!.events
.filterByVisibleInGui(
exceptionEventId: readMarkerEventId,
threadId: activeThreadId,
)
.indexWhere((e) => e.eventId == readMarkerEventId);
@ -377,6 +405,7 @@ class ChatController extends State<ChatPageWithRoom>
readMarkerEventIndex = timeline!.events
.filterByVisibleInGui(
exceptionEventId: readMarkerEventId,
threadId: activeThreadId,
)
.indexWhere((e) => e.eventId == readMarkerEventId);
}
@ -571,6 +600,7 @@ class ChatController extends State<ChatPageWithRoom>
inReplyTo: replyEvent,
editEventId: editEvent?.eventId,
parseCommands: parseCommands,
threadRootEventId: activeThreadId,
);
sendController.value = TextEditingValue(
text: pendingText,
@ -599,6 +629,8 @@ class ChatController extends State<ChatPageWithRoom>
files: files,
room: room,
outerContext: context,
threadRootEventId: activeThreadId,
threadLastEventId: threadLastEventId,
),
);
}
@ -611,6 +643,8 @@ class ChatController extends State<ChatPageWithRoom>
files: [XFile.fromData(image)],
room: room,
outerContext: context,
threadRootEventId: activeThreadId,
threadLastEventId: threadLastEventId,
),
);
}
@ -627,6 +661,8 @@ class ChatController extends State<ChatPageWithRoom>
files: [file],
room: room,
outerContext: context,
threadRootEventId: activeThreadId,
threadLastEventId: threadLastEventId,
),
);
}
@ -646,6 +682,8 @@ class ChatController extends State<ChatPageWithRoom>
files: [file],
room: room,
outerContext: context,
threadRootEventId: activeThreadId,
threadLastEventId: threadLastEventId,
),
);
}
@ -677,6 +715,7 @@ class ChatController extends State<ChatPageWithRoom>
room.sendFileEvent(
file,
inReplyTo: replyEvent,
threadRootEventId: activeThreadId,
extraContent: {
'info': {
...file.info,
@ -966,6 +1005,7 @@ class ChatController extends State<ChatPageWithRoom>
: timeline!.events
.filterByVisibleInGui(
exceptionEventId: eventId,
threadId: activeThreadId,
)
.indexOf(foundEvent);

View file

@ -36,7 +36,9 @@ class ChatEventList extends StatelessWidget {
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
final events = timeline.events.filterByVisibleInGui();
final events = timeline.events.filterByVisibleInGui(
threadId: controller.activeThreadId,
);
final animateInEventIndex = controller.animateInEventIndex;
// create a map of eventId --> index to greatly improve performance of
@ -95,7 +97,8 @@ class ChatEventList extends StatelessWidget {
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
);
}
if (timeline.canRequestHistory) {
if (timeline.canRequestHistory &&
controller.activeThreadId == null) {
return Builder(
builder: (context) {
WidgetsBinding.instance
@ -165,6 +168,9 @@ class ChatEventList extends StatelessWidget {
scrollController: controller.scrollController,
colors: colors,
isCollapsed: isCollapsed,
enterThread: controller.activeThreadId == null
? controller.enterThread
: null,
onExpand: canExpand
? () => controller.expandEventsFrom(
event,

View file

@ -43,11 +43,15 @@ class ChatView extends StatelessWidget {
tooltip: L10n.of(context).edit,
onPressed: controller.editSelectedEventAction,
),
IconButton(
icon: const Icon(Icons.copy_outlined),
tooltip: L10n.of(context).copy,
onPressed: controller.copyEventsAction,
),
if (controller.selectedEvents.length == 1 &&
controller.activeThreadId == null &&
controller.room.canSendDefaultMessages)
IconButton(
icon: const Icon(Icons.message_outlined),
tooltip: L10n.of(context).replyInThread,
onPressed: () => controller
.enterThread(controller.selectedEvents.single.eventId),
),
if (controller.canPinSelectedEvents)
IconButton(
icon: const Icon(Icons.push_pin_outlined),
@ -75,6 +79,18 @@ class ChatView extends StatelessWidget {
}
},
itemBuilder: (context) => [
PopupMenuItem(
onTap: controller.copyEventsAction,
value: null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.copy_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).copy),
],
),
),
if (controller.canSaveSelectedEvent)
PopupMenuItem(
onTap: () => controller.saveSelectedEvent(context),
@ -157,6 +173,8 @@ class ChatView extends StatelessWidget {
controller.clearSelectedEvents();
} else if (controller.showEmojiPicker) {
controller.emojiPickerAction();
} else if (controller.activeThreadId != null) {
controller.closeThread();
}
},
child: StreamBuilder(
@ -167,6 +185,10 @@ class ChatView extends StatelessWidget {
future: controller.loadTimelineFuture,
builder: (BuildContext context, snapshot) {
var appbarBottomHeight = 0.0;
final activeThreadId = controller.activeThreadId;
if (activeThreadId != null) {
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
}
if (controller.room.pinnedEventIds.isNotEmpty) {
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
}
@ -181,7 +203,9 @@ class ChatView extends StatelessWidget {
: theme.colorScheme.onTertiaryContainer,
),
backgroundColor: controller.selectedEvents.isEmpty
? null
? controller.activeThreadId != null
? theme.colorScheme.secondaryContainer
: null
: theme.colorScheme.tertiaryContainer,
automaticallyImplyLeading: false,
leading: controller.selectMode
@ -213,6 +237,17 @@ class ChatView extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (activeThreadId != null)
SizedBox(
height: ChatAppBarListTile.fixedHeight,
child: Center(
child: TextButton.icon(
onPressed: controller.closeThread,
label: Text(L10n.of(context).backToMainChat),
icon: const Icon(Icons.message),
),
),
),
PinnedEvents(controller),
if (scrollUpBannerEventId != null)
ChatAppBarListTile(

View file

@ -14,6 +14,7 @@ import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/file_description.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/string_color.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -35,6 +36,7 @@ class Message extends StatelessWidget {
final void Function() onSwipe;
final void Function() onMention;
final void Function() onEdit;
final void Function(String eventId)? enterThread;
final bool longPressSelect;
final bool selected;
final bool singleSelected;
@ -70,6 +72,7 @@ class Message extends StatelessWidget {
required this.scrollController,
required this.colors,
this.onExpand,
required this.enterThread,
this.isCollapsed = false,
super.key,
});
@ -194,9 +197,14 @@ class Message extends StatelessWidget {
final showReceiptsRow =
event.hasAggregatedEvents(timeline, RelationshipTypes.reaction);
final threadChildren =
event.aggregatedEvents(timeline, RelationshipTypes.thread);
final showReactionPicker =
singleSelected && event.room.canSendDefaultMessages;
final enterThread = this.enterThread;
return Center(
child: Swipeable(
key: ValueKey(event.eventId),
@ -490,15 +498,10 @@ class Message extends StatelessWidget {
CrossAxisAlignment
.start,
children: <Widget>[
if ({
RelationshipTypes
.reply,
RelationshipTypes
.thread,
}.contains(
event
.relationshipType,
))
if (RelationshipTypes
.reply ==
event
.relationshipType)
FutureBuilder<Event?>(
future: event
.getReplyEvent(
@ -867,6 +870,38 @@ class Message extends StatelessWidget {
child: MessageReactions(event, timeline),
),
),
if (enterThread != null)
AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
alignment: Alignment.bottomCenter,
child: threadChildren.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(top: 2.0, bottom: 4.0),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 400,
),
child: TextButton.icon(
style: TextButton.styleFrom(
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
),
onPressed: () => enterThread(event.eventId),
icon: const Icon(Icons.message),
label: Text(
'${L10n.of(context).countReplies(threadChildren.length)} | ${threadChildren.last.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: true,
)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
if (displayReadMarker)
Row(
children: [

View file

@ -20,11 +20,14 @@ class SendFileDialog extends StatefulWidget {
final Room room;
final List<XFile> files;
final BuildContext outerContext;
final String? threadLastEventId, threadRootEventId;
const SendFileDialog({
required this.room,
required this.files,
required this.outerContext,
required this.threadLastEventId,
required this.threadRootEventId,
super.key,
});
@ -108,6 +111,8 @@ class SendFileDialogState extends State<SendFileDialog> {
thumbnail: thumbnail,
shrinkImageMaxDimension: compress ? 1600 : null,
extraContent: label.isEmpty ? null : {'body': label},
threadRootEventId: widget.threadRootEventId,
threadLastEventId: widget.threadLastEventId,
);
} on MatrixException catch (e) {
final retryAfterMs = e.retryAfterMs;

View file

@ -5,9 +5,21 @@ import 'package:fluffychat/config/setting_keys.dart';
extension VisibleInGuiExtension on List<Event> {
List<Event> filterByVisibleInGui({
String? exceptionEventId,
String? threadId,
}) =>
where(
(event) => event.isVisibleInGui || event.eventId == exceptionEventId,
(event) {
if (threadId != null) {
if ((event.relationshipType != RelationshipTypes.thread ||
event.relationshipEventId != threadId) &&
event.eventId != threadId) {
return false;
}
} else if (event.relationshipType == RelationshipTypes.thread) {
return false;
}
return event.isVisibleInGui || event.eventId == exceptionEventId;
},
).toList();
}