merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
+37 -13
View File
@@ -59,6 +59,13 @@ class _ChatBubbleState extends State<ChatBubble>
with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> {
late ChatMessage message;
// The parsed message and its widget are only rebuilt when their inputs
// change. The bubble itself rebuilds on every chat emit, swipe frame and
// keyboard frame; re-running rich-object parsing, linkify/Markdown and emoji
// detection each time is what makes long chats stutter.
Object? _messageKey;
late Widget _messageWidget;
Offset _position = Offset.zero;
Offset _dragStartPosition = Offset.zero;
bool _swipeActionArmed = false;
@@ -185,12 +192,34 @@ class _ChatBubbleState extends State<ChatBubble>
}
}
@override
Widget build(BuildContext context) {
void _updateMessage(BuildContext context) {
final style = _messageTextStyle(context);
final renderMarkdown =
widget.bubbleData.markdown &&
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment;
final key = (
widget.bubbleData,
widget.highlightQuery,
style,
renderMarkdown,
);
if (key == _messageKey) return;
_messageKey = key;
message = ChatMessage(
originalMessage: widget.bubbleData.message,
originalData: widget.bubbleData.messageParameters,
);
_messageWidget = message.getWidget(
highlightQuery: widget.highlightQuery,
style: style,
renderMarkdown: renderMarkdown,
);
}
@override
Widget build(BuildContext context) {
_updateMessage(context);
final showActorDisplayName =
_rendersAsCommentBubble &&
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
@@ -277,14 +306,7 @@ class _ChatBubbleState extends State<ChatBubble>
actorText: actorText,
actorWidget: actorWidget,
timeText: timeText,
messageWidget: message.getWidget(
highlightQuery: widget.highlightQuery,
style: _messageTextStyle(context),
renderMarkdown:
widget.bubbleData.markdown &&
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment,
),
messageWidget: _messageWidget,
parent: parent,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
@@ -350,10 +372,12 @@ class _BubbleContent extends StatelessWidget {
Widget build(BuildContext context) => MergeSemantics(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
maxWidth: MediaQuery.sizeOf(context).width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
? actorText.measuredWidth(context)
: timeText.measuredWidth(context) +
(isSender ? spacing + timeIconSize : 0) +
3,
),
child: Stack(
children: [
@@ -34,7 +34,7 @@ class ChatBubbleReactions extends StatelessWidget {
return Transform.translate(
offset: const Offset(0, -10),
child: Container(
width: MediaQuery.of(context).size.width,
width: MediaQuery.sizeOf(context).width,
margin: const EdgeInsets.only(left: 15, right: 15),
child: Wrap(
alignment: isSender ? WrapAlignment.end : WrapAlignment.start,
+24 -11
View File
@@ -83,12 +83,20 @@ class _ChatTextfieldState extends State<ChatTextfield> {
);
}
/// Called per keystroke, so the text itself is saved silently; only a draft
/// appearing or disappearing notifies listeners (the chat list's marker).
void _setDraft(String text) {
final talkSettings = settings.val(write: true).talkSettings;
final drafts = settings.val().talkSettings.drafts;
final hadDraft = drafts.containsKey(widget.sendToToken);
if (text.isNotEmpty) {
talkSettings.drafts[widget.sendToToken] = text;
drafts[widget.sendToToken] = text;
} else {
talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken);
drafts.remove(widget.sendToToken);
}
if (hadDraft != text.isNotEmpty) {
settings.val(write: true);
} else {
settings.saveSilently();
}
}
@@ -276,17 +284,22 @@ class _ChatTextfieldState extends State<ChatTextfield> {
@override
Widget build(BuildContext context) {
final chatBloc = context.watch<ChatBloc>();
final chatState = chatBloc.state.data;
final chatBloc = context.read<ChatBloc>();
// Only the reply reference is rendered from the chat state; loading flags
// and paging emits don't need to rebuild the input.
final (referenceMessageId, chatResponse) = context.select(
(ChatBloc b) => (
b.state.data?.referenceMessageId,
b.state.data?.chatResponse,
),
);
Widget replyBanner = const SizedBox.shrink();
if (chatState != null &&
chatState.referenceMessageId != null &&
chatState.chatResponse != null) {
if (referenceMessageId != null && chatResponse != null) {
try {
final referenceMessage = chatState.chatResponse!
.sortByTimestamp()
.firstWhere((e) => e.id == chatState.referenceMessageId);
final referenceMessage = chatResponse.data.firstWhere(
(e) => e.id == referenceMessageId,
);
replyBanner = Row(
children: [
Expanded(
+39 -3
View File
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../extensions/date_time.dart';
import '../../../../notification/notification_tasks.dart';
@@ -22,6 +23,7 @@ import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart';
import '../data/talk_markdown.dart';
import '../talk_navigator.dart';
import 'notification_level_sheet.dart';
class ChatTile extends StatefulWidget {
final GetRoomResponseObject data;
@@ -62,15 +64,25 @@ class _ChatTileState extends State<ChatTile> {
/// One-line preview of the last message: rich-object placeholders resolved,
/// newlines flattened and — for Markdown messages — formatting stripped so
/// the list shows readable text rather than raw markers.
///
/// Memoised per last message: the tile rebuilds on every chat-list emit and
/// the Markdown strip is a full parse. Keyed by content, since every refresh
/// delivers new message objects.
String _lastMessagePreview() {
final last = widget.data.lastMessage;
final key = (last.id, last.message, last.markdown);
if (key == _previewFor) return _preview;
final text = RichObjectStringProcessor.parseToString(
last.message.replaceAll('\n', ' '),
last.messageParameters,
);
return last.markdown ? markdownToPlainText(text) : text;
_previewFor = key;
return _preview = last.markdown ? markdownToPlainText(text) : text;
}
Object? _previewFor;
String _preview = '';
Future<void> _setCurrentAsRead() async {
final token = widget.data.token;
final lastId = widget.data.lastMessage.id;
@@ -83,7 +95,11 @@ class _ChatTileState extends State<ChatTile> {
@override
Widget build(BuildContext context) {
final chatBloc = context.watch<ChatBloc>();
// Only the open token matters here (split-view highlight); watching the
// whole bloc rebuilt every tile on each message of the open chat.
final currentToken = context.select(
(ChatBloc b) => b.state.data?.currentToken,
);
final isGroup =
widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
final circleAvatar = UserAvatar(
@@ -94,7 +110,7 @@ class _ChatTileState extends State<ChatTile> {
return ListTile(
style: ListTileStyle.list,
tileColor:
chatBloc.state.data?.currentToken == widget.data.token &&
currentToken == widget.data.token &&
TalkNavigator.isSecondaryVisible(context)
? Theme.of(context).primaryColor.withAlpha(100)
: null,
@@ -147,6 +163,15 @@ class _ChatTileState extends State<ChatTile> {
semanticLabel: A11yLabels.draft,
),
],
if (widget.data.isMuted) ...[
const SizedBox(width: 5),
Icon(
NotificationLevel.neverNotify.icon,
size: 15,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: A11yLabels.muted,
),
],
],
),
subtitle: Text(
@@ -234,6 +259,17 @@ class _ChatTileState extends State<ChatTile> {
if (mounted) _refreshList();
},
),
NotificationLevelTile(
level: widget.data.effectiveNotificationLevel,
onTap: () {
Navigator.of(sheetCtx).pop();
showNotificationLevelSheet(
context,
widget.data,
current: widget.data.effectiveNotificationLevel,
);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Talk-Chat verlassen'),
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/details_bottom_sheet.dart';
extension NotificationLevelPresentation on NotificationLevel {
// defaultLevel never reaches the UI (see effectiveNotificationLevel), but
// the switch has to stay exhaustive.
({String label, String description, IconData icon}) get _presentation =>
switch (this) {
NotificationLevel.alwaysNotify || NotificationLevel.defaultLevel => (
label: 'Alle Nachrichten',
description: 'Bei jeder neuen Nachricht',
icon: Icons.notifications_outlined,
),
NotificationLevel.notifyOnMention => (
label: 'Nur Erwähnungen',
description: 'Nur wenn du erwähnt wirst',
icon: Icons.alternate_email,
),
NotificationLevel.neverNotify => (
label: 'Aus',
description: 'Keine Benachrichtigungen',
icon: Icons.notifications_off_outlined,
),
};
String get label => _presentation.label;
String get description => _presentation.description;
IconData get icon => _presentation.icon;
}
/// Lets the user pick the room's Talk notification level. Nextcloud applies
/// it server-side, so no push for the room is sent at all when muted.
/// Returns the new level, or null when nothing changed.
Future<NotificationLevel?> showNotificationLevelSheet(
BuildContext context,
GetRoomResponseObject room, {
required NotificationLevel current,
}) async {
final bloc = context.read<ChatListBloc>();
NotificationLevel? selected;
await showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Benachrichtigungen'),
subtitle: Text(room.displayName, overflow: TextOverflow.ellipsis),
),
children: (_) => [
for (final level in selectableNotificationLevels)
AsyncListTile(
leading: Icon(
level == current
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: Text(level.label),
subtitle: Text(level.description),
onPressed: () async {
if (level == current) return;
await bloc.setNotificationLevel(room.token, level);
selected = level;
},
),
],
);
return selected;
}
class NotificationLevelTile extends StatelessWidget {
final NotificationLevel level;
final VoidCallback onTap;
final Widget? trailing;
const NotificationLevelTile({
required this.level,
required this.onTap,
this.trailing,
super.key,
});
@override
Widget build(BuildContext context) => ListTile(
leading: Icon(level.icon),
title: const Text('Benachrichtigungen'),
subtitle: Text(level.label),
trailing: trailing,
onTap: onTap,
);
}