diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 61a2dbb..e9259e9 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1,10 +1,20 @@ import 'dart:convert'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; import '../../models/attachment.dart'; +class ContactCache { + static final Map _cache = {}; + + static void put(int id, String name) { + _cache[id] = name; + } + + static String? get(int id) => _cache[id]; +} + class CachedMessage { final String id; final int accountId; @@ -39,11 +49,20 @@ class CachedMessage { List? attachments; if (payload != null) { - final attaches = payload['attaches'] as List?; - if (attaches != null) { - attachments = attaches - .map((a) => MessageAttachment.fromMap(a as Map)) - .toList(); + final linkType = payload['link']?['type'] as String?; + if (linkType == 'FORWARD') { + attachments = [ForwardedMessageAttachment.fromMap(payload)]; + } else { + final attaches = payload['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .map( + (a) => MessageAttachment.fromMap( + Map.from(a as Map), + ), + ) + .toList(); + } } } @@ -158,13 +177,24 @@ class MessagesModule { final id = m['id']?.toString(); if (id == null) return null; - final attaches = m['attaches'] as List?; + final linkRaw = m['link']; + String? linkType; + if (linkRaw is Map) { + linkType = linkRaw['type'] as String?; + } + List? attachments; - if (attaches != null) { - attachments = attaches - .whereType() - .map((a) => MessageAttachment.fromMap(a.cast())) - .toList(); + if (linkType == 'FORWARD') { + final fwdMap = Map.from(m.cast()); + attachments = [ForwardedMessageAttachment.fromMap(fwdMap)]; + } else { + final attaches = m['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } } return CachedMessage( @@ -175,7 +205,7 @@ class MessagesModule { text: m['text'] as String?, time: (m['time'] as int?) ?? 0, status: m['status'] as String?, - payload: m.cast(), + payload: Map.from(m.cast()), attachments: attachments, ); } @@ -313,4 +343,36 @@ class MessagesModule { return null; } } + + Future searchContactById(int contactId) async { + final cached = ContactCache.get(contactId); + if (cached != null) return cached; + + try { + final response = await _api.sendRequest(Opcode.contactInfo, { + 'id': contactId, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final names = data['names'] as List?; + if (names != null && names.isNotEmpty) { + final name = names.first; + if (name is Map) { + final firstName = name['firstName'] as String? ?? ''; + final lastName = name['lastName'] as String?; + final fullName = lastName != null + ? '$firstName $lastName' + : firstName; + ContactCache.put(contactId, fullName); + return fullName; + } + } + } catch (e) { + debugPrint('searchContactById error: $e'); + } + return null; + } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 2944231..1968d3b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5,6 +5,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { @@ -81,6 +82,7 @@ class _ChatScreenState extends State _isLoading = false; }); } + _loadForwardedSenderNames(); } catch (e) { debugPrint('Error fetching history: $e'); if (mounted) { @@ -164,6 +166,62 @@ class _ChatScreenState extends State } } + Future _loadForwardedSenderNames() async { + final forwardIds = {}; + for (final msg in _messages) { + if (msg.attachments != null) { + for (final a in msg.attachments!) { + if (a is ForwardedMessageAttachment) { + if (a.originalSenderName == null) { + forwardIds.add(a.originalSenderId); + } + } + } + } + } + if (forwardIds.isEmpty) return; + + for (final id in forwardIds) { + final name = await messagesModule.searchContactById(id); + if (name != null && mounted) { + setState(() { + for (var i = 0; i < _messages.length; i++) { + final msg = _messages[i]; + if (msg.attachments != null) { + final newAttaches = msg.attachments!.map((a) { + if (a is ForwardedMessageAttachment && + a.originalSenderId == id && + a.originalSenderName == null) { + return ForwardedMessageAttachment( + originalSenderId: id, + originalSenderName: name, + originalMessageId: a.originalMessageId, + originalTime: a.originalTime, + originalText: a.originalText, + originalChatId: a.originalChatId, + originalAttachments: a.originalAttachments, + ); + } + return a; + }).toList(); + _messages[i] = CachedMessage( + id: msg.id, + accountId: msg.accountId, + chatId: msg.chatId, + senderId: msg.senderId, + text: msg.text, + time: msg.time, + status: msg.status, + payload: msg.payload, + attachments: newAttaches, + ); + } + } + }); + } + } + } + void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index c3a5b62..100a1fd 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -78,6 +78,7 @@ class MessageBubble extends StatelessWidget { MessageType get contentType { if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; + if (first is ForwardedMessageAttachment) return MessageType.text; if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; @@ -317,14 +318,26 @@ class MessageBubble extends StatelessWidget { ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final forwarded = _getForwardedAttachment(); + return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: Text( - message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (forwarded != null) ...[ + _buildForwardedHeader(context, forwarded), + const SizedBox(height: 4), + ], + Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ], ), ), const SizedBox(width: 8), @@ -343,12 +356,76 @@ class MessageBubble extends StatelessWidget { ); } + ForwardedMessageAttachment? _getForwardedAttachment() { + if (message.attachments == null || message.attachments!.isEmpty) + return null; + for (final a in message.attachments!) { + if (a is ForwardedMessageAttachment) return a; + } + return null; + } + + Widget _buildForwardedHeader( + BuildContext context, + ForwardedMessageAttachment forwarded, + ) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final origText = forwarded.originalText; + final hasOrigText = origText != null && origText.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (hasOrigText) ...[ + const SizedBox(height: 2), + Text( + origText, + style: TextStyle(color: textColor, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ); + } + Widget _buildAttachmentContent(BuildContext context) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { return _buildTextContent(context); } + final first = attachments.first; + if (first is ForwardedMessageAttachment) { + return _buildTextContent(context); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(context, attachments.first); diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index dbaf79e..3da1153 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -433,6 +433,73 @@ class ControlAttachment extends MessageAttachment { }; } +class ForwardedMessageAttachment extends MessageAttachment { + final int originalSenderId; + final String? originalSenderName; + final String? originalMessageId; + final int? originalTime; + final String? originalText; + final int? originalChatId; + final List? originalAttachments; + + const ForwardedMessageAttachment({ + required this.originalSenderId, + this.originalSenderName, + this.originalMessageId, + this.originalTime, + this.originalText, + this.originalChatId, + this.originalAttachments, + }) : super(type: AttachmentType.photo); + + factory ForwardedMessageAttachment.fromMap(Map map) { + final linkRaw = map['link']; + Map? link; + if (linkRaw is Map) { + link = Map.from(linkRaw); + } + + Map? message; + if (link != null) { + final msgRaw = link['message']; + if (msgRaw is Map) { + message = Map.from(msgRaw); + } + } + + List? originalAttaches; + if (message != null) { + final attaches = message['attaches'] as List?; + if (attaches != null) { + originalAttaches = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + } + + return ForwardedMessageAttachment( + originalSenderId: (message?['sender'] as int?) ?? 0, + originalMessageId: message?['id']?.toString(), + originalTime: message?['time'] as int?, + originalText: message?['text'] as String?, + originalChatId: link?['chatId'] as int?, + originalAttachments: originalAttaches, + ); + } + + @override + Map toMap() => { + '_type': 'FORWARD', + 'originalSenderId': originalSenderId, + 'originalSenderName': originalSenderName, + 'originalMessageId': originalMessageId, + 'originalTime': originalTime, + 'originalText': originalText, + 'originalChatId': originalChatId, + }; +} + class UnknownAttachment extends MessageAttachment { final Map rawData; diff --git a/pubspec.lock b/pubspec.lock index b09c9ff..1f7d572 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: