feat: Implement threads
This commit is contained in:
parent
b5e59d9bb9
commit
380625327a
7 changed files with 166 additions and 21 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue