Files
Client/lib/api/marianumcloud/talk/chat/get_chat_response.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

203 lines
5.3 KiB
Dart

import 'package:json_annotation/json_annotation.dart';
import '../../../../extensions/date_time.dart';
import '../../../api_response.dart';
import '../../../json_lenient.dart';
import '../room/get_room_response.dart';
part 'get_chat_response.g.dart';
@JsonSerializable(explicitToJson: true)
class GetChatResponse extends ApiResponse {
@JsonKey(fromJson: _messagesFromJson)
Set<GetChatResponseObject> data;
GetChatResponse(this.data);
factory GetChatResponse.fromJson(Map<String, dynamic> json) =>
_$GetChatResponseFromJson(json);
Map<String, dynamic> toJson() => _$GetChatResponseToJson(this);
List<GetChatResponseObject> sortByTimestamp() {
var sorted = data.toList();
sorted.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return sorted;
}
}
Set<GetChatResponseObject> _messagesFromJson(Object? raw) =>
decodeSetLenient(raw, GetChatResponseObject.fromJson, what: 'message');
@JsonSerializable(explicitToJson: true)
class GetChatResponseObject {
int id;
String token;
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageActorType.unknown)
GetRoomResponseObjectMessageActorType actorType;
String actorId;
String actorDisplayName;
int timestamp;
String systemMessage;
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageType.unknown)
GetRoomResponseObjectMessageType messageType;
bool isReplyable;
String referenceId;
String message;
Map<String, int>? reactions;
List<String>? reactionsSelf;
@JsonKey(fromJson: _fromJson)
Map<String, RichObjectString>? messageParameters;
/// The quoted message. When the original is no longer available (expired,
/// purged) the server only sends `{id, deleted: true}`; that becomes a
/// deleted-comment placeholder so the quote still renders.
@JsonKey(fromJson: _parentFromJson)
GetChatResponseObject? parent;
/// Whether the server flagged this message to be rendered as Markdown
/// (capability `markdown-messages`). Absent on older servers/messages, in
/// which case it stays `false` and the body renders as plain text.
bool markdown;
GetChatResponseObject(
this.id,
this.token,
this.actorType,
this.actorId,
this.actorDisplayName,
this.timestamp,
this.systemMessage,
this.messageType,
this.isReplyable,
this.referenceId,
this.message,
this.messageParameters,
this.reactions,
this.reactionsSelf,
this.parent, [
this.markdown = false,
]);
factory GetChatResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetChatResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this);
/// [timestamp] is in seconds.
DateTime get sentAt => DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
static GetChatResponseObject getDateDummy(int timestamp) => getTextDummy(
DateTime.fromMillisecondsSinceEpoch(
timestamp * 1000,
).formatDateRelativeShort(),
);
static GetChatResponseObject deletedPlaceholder(int id) =>
GetChatResponseObject(
id,
'',
GetRoomResponseObjectMessageActorType.user,
'',
'',
0,
'',
GetRoomResponseObjectMessageType.deletedComment,
false,
'',
'Nachricht wurde gelöscht',
null,
null,
null,
null,
);
static GetChatResponseObject getTextDummy(String text) =>
GetChatResponseObject(
0,
'',
GetRoomResponseObjectMessageActorType.user,
'',
'',
0,
'',
GetRoomResponseObjectMessageType.system,
false,
'',
text,
null,
null,
null,
null,
);
}
GetChatResponseObject? _parentFromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
if (json['deleted'] == true) {
return GetChatResponseObject.deletedPlaceholder(
(json['id'] as num?)?.toInt() ?? 0,
);
}
return GetChatResponseObject.fromJson(json);
}
Map<String, RichObjectString>? _fromJson(dynamic json) {
if (json is Map<String, dynamic>) {
final data = <String, RichObjectString>{};
for (final entry in json.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
data[entry.key] = RichObjectString.fromJson(value);
}
}
return data;
}
return null;
}
@JsonSerializable(explicitToJson: true)
class RichObjectString {
@JsonKey(unknownEnumValue: RichObjectStringObjectType.unknown)
RichObjectStringObjectType type;
String id;
String name;
String? path;
String? link;
RichObjectString(this.type, this.id, this.name, this.path, this.link);
factory RichObjectString.fromJson(Map<String, dynamic> json) =>
_$RichObjectStringFromJson(json);
Map<String, dynamic> toJson() => _$RichObjectStringToJson(this);
}
enum RichObjectStringObjectType {
@JsonValue('user')
user,
@JsonValue('group')
group,
@JsonValue('file')
file,
@JsonValue('guest')
guest,
@JsonValue('highlight')
highlight,
@JsonValue('talk-poll')
talkPoll,
@JsonValue('geo-location')
geoLocation,
@JsonValue('call')
call,
@JsonValue('user-group')
userGroup,
@JsonValue('circle')
circle,
@JsonValue('email')
email,
/// Anything else (deck cards, contacts, calendar events, ...); the
/// placeholder still resolves to the object's name.
unknown,
}