Files
Client/lib/view/pages/talk/widgets/chat_tile.dart
T
MineTecandClaude Fable 5.1 279b973e89 hardened API decoding against server fields the models did not expect
The Talk room list crashed for a new account because the server omits
lastMessage for rooms without a visible message and the model required it.
Beyond that single field, a review of the Talk models against the OpenAPI
spec of the installed version (23.0.8) found the same crash class in several
places: closed enums over open server values (inCall bitmask, rich-object
types, actor and message types), a deleted parent sent as {id, deleted},
and the autocomplete status arriving as an object.

- json_serializable `checked: true`: decode errors now name class and field
- lists (rooms, messages, participants, autocomplete) decode per entry;
  a malformed entry is skipped and reported via ClientErrorReporter
- unknownEnumValue fallbacks for all response enums of Talk and breakers
- lastMessage nullable; inCall as bitmask; deleted parent placeholder;
  autocomplete status object/string; fixed shareWithDisplayNameUnique key
- GetRoomParams no longer sends null query parameters

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrepDTL4qDe35TuS29sLW3
2026-09-29 16:35:41 +02:00

282 lines
9.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.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';
import '../../../../routing/app_routes.dart';
import '../../../../session/session_manager.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../utils/haptics.dart';
import '../../../../widget/a11y/a11y_labels.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/debug/debug_tile.dart';
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';
import 'room_actions.dart';
class ChatTile extends StatefulWidget {
final GetRoomResponseObject data;
final bool disableContextActions;
final bool hasDraft;
/// When set, replaces the default tap-into-chat behaviour. Used by the
/// share-intent picker to surface the room selection without opening the
/// chat view itself.
final void Function(GetRoomResponseObject room)? onTapOverride;
const ChatTile({
super.key,
required this.data,
this.disableContextActions = false,
this.hasDraft = false,
this.onTapOverride,
});
@override
State<ChatTile> createState() => _ChatTileState();
}
class _ChatTileState extends State<ChatTile> {
String? selfUsername = SessionManager().current?.nextcloud?.username;
@override
void initState() {
super.initState();
if (selfUsername != null) return;
SessionManager().waitForLoad().then((session) {
if (!mounted) return;
setState(() => selfUsername = session?.nextcloud?.username);
});
}
void _refreshList() => context.read<ChatListBloc>().refresh();
/// 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(GetChatResponseObject last) {
final key = (last.id, last.message, last.markdown);
if (key == _previewFor) return _preview;
final text = RichObjectStringProcessor.parseToString(
last.message.replaceAll('\n', ' '),
last.messageParameters,
);
_previewFor = key;
return _preview = last.markdown ? markdownToPlainText(text) : text;
}
Object? _previewFor;
String _preview = '';
String _subtitle() {
final last = widget.data.lastMessage;
if (last == null) return 'Noch keine Nachrichten';
return '${last.sentAt.formatRelative()}: ${_lastMessagePreview(last)}';
}
Future<void> _setCurrentAsRead() async {
final token = widget.data.token;
final lastId = widget.data.lastMessage?.id;
if (lastId == null) return;
context.read<ChatListBloc>().markRoomAsRead(token, lastId);
unawaited(NotificationTasks.clearNotificationsForChat(token));
await context.read<ChatBloc>().sendServerReadMarker(token, lastId);
if (!mounted) return;
_refreshList();
}
@override
Widget build(BuildContext context) {
// 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(
id: isGroup ? widget.data.token : widget.data.name,
isGroup: isGroup,
);
return ListTile(
style: ListTileStyle.list,
tileColor:
currentToken == widget.data.token &&
TalkNavigator.isSecondaryVisible(context)
? Theme.of(context).primaryColor.withAlpha(100)
: null,
leading: Stack(
children: [
// Der Name steht bereits im Titel der Zeile – das Avatarbild selbst
// muss der Screenreader nicht ansagen. Nur bei Gruppen ist der
// Hinweis „Gruppe" nützlich (sonst nicht vom Einzelchat zu
// unterscheiden).
Semantics(
label: isGroup ? A11yLabels.group : null,
child: ExcludeSemantics(child: circleAvatar),
),
if (widget.data.isFavorite)
Positioned(
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withAlpha(200),
borderRadius: BorderRadius.circular(90.0),
),
child: const Icon(
Icons.star,
color: Colors.amberAccent,
size: 15,
semanticLabel: A11yLabels.favorite,
),
),
),
],
),
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
widget.data.displayName,
overflow: TextOverflow.ellipsis,
),
),
if (widget.hasDraft) ...[
const SizedBox(width: 5),
const Icon(
Icons.edit_outlined,
size: 15,
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(_subtitle(), overflow: TextOverflow.ellipsis),
trailing: widget.data.unreadMessages <= 0
? null
: Semantics(
label: '${widget.data.unreadMessages} ${A11yLabels.unread}',
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(30),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: Text(
'${widget.data.unreadMessages}',
style: const TextStyle(color: Colors.white, fontSize: 15),
textAlign: TextAlign.center,
),
),
),
),
onTap: () {
if (widget.onTapOverride != null) {
widget.onTapOverride!(widget.data);
return;
}
if (selfUsername == null) return;
// openChatView is the single entry point for opening a chat —
// it handles optimistic mark-as-read, tray cleanup, push, and
// setToken in one place so the notification-tap path gets the
// same treatment as a tile tap.
AppRoutes.openChatView(
context,
room: widget.data,
selfId: selfUsername!,
avatar: circleAvatar,
overrideToSingleSubScreen: true,
);
},
onLongPress: () {
if (widget.disableContextActions) return;
Haptics.longPress();
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
if (widget.data.unreadMessages > 0)
AsyncListTile(
leading: const Icon(Icons.mark_chat_read_outlined),
title: const Text('Als gelesen markieren'),
onPressed: _setCurrentAsRead,
)
else
AsyncListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: const Text('Als ungelesen markieren'),
onPressed: () async {
await SetReadMarker(widget.data.token, false).run();
if (mounted) _refreshList();
},
),
FavoriteToggleTile(
isFavorite: widget.data.isFavorite,
onPressed: () async {
await SetFavorite(
widget.data.token,
!widget.data.isFavorite,
).run();
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'),
onTap: () {
Navigator.of(sheetCtx).pop();
confirmLeaveRoom(context, widget.data.token);
},
),
DebugTile(sheetCtx).jsonData(widget.data.toJson()),
],
);
},
);
}
}