feat(messages): пересылка сообщений

This commit is contained in:
klockky
2026-06-22 23:57:43 +03:00
parent 77a1228457
commit e6d16a62de
4 changed files with 348 additions and 5 deletions
+49
View File
@@ -688,6 +688,55 @@ class MessagesModule {
return '';
}
/// Пересылает сообщение [messageId] из чата [sourceChatId] в [targetChatId].
///
/// Пересылка — это отдельное сообщение без текста и вложений, со ссылкой
/// `link.type = FORWARD`, указывающей на оригинал. Сервер сам подставит
/// тело оригинала в ответе.
Future<String> forwardMessage(
int targetChatId,
int sourceChatId,
int messageId, {
bool notify = true,
}) async {
final message = <String, dynamic>{
'isLive': false,
'detectShare': false,
'elements': [],
'attaches': [],
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'link': {
'type': 'FORWARD',
'chatId': sourceChatId,
'messageId': messageId,
},
};
final payload = {
'chatId': targetChatId,
'message': message,
'notify': notify,
};
final response = await _api.sendRequest(Opcode.msgSend, payload);
if (!response.isOk) {
final msg = (response.payload is Map)
? (response.payload['localizedMessage'] ??
response.payload['message'] ??
'Ошибка пересылки')
: 'Ошибка пересылки';
throw Exception(msg.toString());
}
final data = response.payload;
if (data is Map) {
final msgMap = data['message'];
if (msgMap is Map) {
final id = msgMap['id'];
if (id != null) return id.toString();
}
}
return '';
}
/// Загружает отложенные (запланированные) сообщения чата.
///
/// В отличие от обычной истории, отложенные сообщения не сохраняются
+51 -1
View File
@@ -16,6 +16,7 @@ import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/core/utils/format.dart';
import 'package:komet/core/utils/logger.dart';
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:komet/frontend/screens/chats/forward_picker_screen.dart';
import 'package:komet/frontend/screens/chats/poll_create_screen.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/chat_menu_overlay.dart';
@@ -994,7 +995,49 @@ class _ChatScreenState extends State<ChatScreen>
}
void _forwardSelected() {
showCustomNotification(context, 'Пересылка — пока в разработке');
final msgs = _selectedMessages(_selectedIds.value);
_clearSelection();
unawaited(_forwardMessages(msgs));
}
Future<void> _forwardMessages(List<CachedMessage> msgs) async {
final forwardable =
msgs.where((m) => !m.id.startsWith('temp_')).toList();
if (forwardable.isEmpty) {
showCustomNotification(context, 'Нечего пересылать');
return;
}
final target = await showForwardPicker(
context: context,
accountId: _myId,
messageCount: forwardable.length,
);
if (target == null || !mounted) return;
final ordered = [...forwardable]..sort((a, b) => a.time.compareTo(b.time));
var ok = 0;
for (final m in ordered) {
final mid = int.tryParse(m.id);
if (mid == null) continue;
try {
await messagesModule.forwardMessage(target.chatId, widget.chatId, mid);
ok++;
} catch (_) {}
}
if (!mounted) return;
if (ok == 0) {
Haptics.error();
showCustomNotification(context, 'Не удалось переслать');
return;
}
Haptics.send();
showCustomNotification(
context,
target.chatId == widget.chatId
? 'Переслано'
: 'Переслано в «${target.name}»',
);
}
Widget _buildComposerArea(BuildContext context) {
@@ -2950,6 +2993,9 @@ class _ChatScreenState extends State<ChatScreen>
onReply: message.isControl
? null
: () => _startReply(message),
onForward: message.isControl
? null
: () => _forwardMessages([message]),
child: bubble,
);
@@ -4541,6 +4587,7 @@ class _SelectableMessageRow extends StatefulWidget {
final VoidCallback onDelete;
final VoidCallback? onEdit;
final VoidCallback? onReply;
final VoidCallback? onForward;
const _SelectableMessageRow({
required this.child,
@@ -4554,6 +4601,7 @@ class _SelectableMessageRow extends StatefulWidget {
required this.onDelete,
this.onEdit,
this.onReply,
this.onForward,
});
@override
@@ -4600,6 +4648,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
onDelete: widget.onDelete,
onEdit: widget.onEdit,
onReply: widget.onReply,
onForward: widget.onForward,
onDispose: controller.dispose,
);
}
@@ -4626,6 +4675,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
onDelete: widget.onDelete,
onEdit: widget.onEdit,
onReply: widget.onReply,
onForward: widget.onForward,
onDispose: controller.dispose,
);
}
@@ -0,0 +1,239 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/messages.dart' show ContactCache;
class ForwardTarget {
final int chatId;
final String name;
final String imageUrl;
final String chatType;
const ForwardTarget({
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
});
}
Future<ForwardTarget?> showForwardPicker({
required BuildContext context,
required int accountId,
int messageCount = 1,
}) {
return showModalBottomSheet<ForwardTarget>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) =>
_ForwardPickerSheet(accountId: accountId, messageCount: messageCount),
);
}
class _ForwardPickerSheet extends StatefulWidget {
final int accountId;
final int messageCount;
const _ForwardPickerSheet({
required this.accountId,
required this.messageCount,
});
@override
State<_ForwardPickerSheet> createState() => _ForwardPickerSheetState();
}
class _ForwardPickerSheetState extends State<_ForwardPickerSheet> {
final TextEditingController _searchController = TextEditingController();
List<ForwardTarget> _all = const [];
String _query = '';
bool _loading = true;
@override
void initState() {
super.initState();
_searchController.addListener(() {
setState(() => _query = _searchController.text.trim().toLowerCase());
});
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _load() async {
final chats = await ChatsModule.getChats(widget.accountId);
final targets = <ForwardTarget>[];
for (final chat in chats) {
if (chat.type == 'CHANNEL' && chat.owner != widget.accountId) continue;
targets.add(_targetFor(chat));
}
targets.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
if (!mounted) return;
setState(() {
_all = targets;
_loading = false;
});
}
ForwardTarget _targetFor(CachedChat chat) {
if (chat.id == 0) {
return ForwardTarget(
chatId: 0,
name: 'Избранное',
imageUrl: '',
chatType: chat.type,
);
}
if (chat.type == 'DIALOG') {
int otherId = widget.accountId;
for (final entry in chat.participants.entries) {
if (entry.key != widget.accountId) {
otherId = entry.key;
break;
}
}
return ForwardTarget(
chatId: chat.id,
name: ContactCache.get(otherId) ?? chat.title ?? 'Пользователь',
imageUrl: ContactCache.getAvatar(otherId) ?? chat.iconUrl ?? '',
chatType: chat.type,
);
}
return ForwardTarget(
chatId: chat.id,
name: chat.title ?? 'Чат',
imageUrl: chat.iconUrl ?? '',
chatType: chat.type,
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final filtered = _query.isEmpty
? _all
: _all.where((t) => t.name.toLowerCase().contains(_query)).toList();
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.92,
expand: false,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: cs.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
const SizedBox(height: 10),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Row(
children: [
Text(
widget.messageCount > 1
? 'Переслать (${widget.messageCount})'
: 'Переслать',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Поиск чата',
prefixIcon: const Icon(Symbols.search),
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.zero,
),
),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: filtered.isEmpty
? Center(
child: Text(
'Ничего не найдено',
style: TextStyle(color: cs.onSurfaceVariant),
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.only(bottom: 8),
itemCount: filtered.length,
itemBuilder: (context, index) {
final t = filtered[index];
return ListTile(
leading: CircleAvatar(
radius: 22,
backgroundColor: cs.surfaceContainerHighest,
backgroundImage: t.imageUrl.isNotEmpty
? CachedNetworkImageProvider(
t.imageUrl,
maxWidth: 132,
maxHeight: 132,
)
: null,
child: t.imageUrl.isEmpty
? Icon(
t.chatId == 0
? Symbols.bookmark
: Symbols.person,
color: cs.onSurfaceVariant,
)
: null,
),
title: Text(
t.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => Navigator.of(context).pop(t),
);
},
),
),
],
),
);
},
),
);
}
}
@@ -76,6 +76,7 @@ void showMessageActions({
VoidCallback? onDelete,
VoidCallback? onEdit,
VoidCallback? onReply,
VoidCallback? onForward,
MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
@@ -93,6 +94,7 @@ void showMessageActions({
onDelete: onDelete,
onEdit: onEdit,
onReply: onReply,
onForward: onForward,
onDismiss: () {
if (entry.mounted) entry.remove();
onDispose();
@@ -115,6 +117,7 @@ class _MessageActionsLayer extends StatefulWidget {
final VoidCallback? onDelete;
final VoidCallback? onEdit;
final VoidCallback? onReply;
final VoidCallback? onForward;
const _MessageActionsLayer({
required this.snapshot,
@@ -129,6 +132,7 @@ class _MessageActionsLayer extends StatefulWidget {
this.onDelete,
this.onEdit,
this.onReply,
this.onForward,
});
@override
@@ -297,7 +301,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
_Action(Symbols.edit, 'Изменить', _edit),
if (widget.onReply != null)
_Action(Symbols.reply, 'Ответить', _reply),
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
if (widget.onForward != null)
_Action(Symbols.forward, 'Переслать', _forward),
_Action(
Symbols.delete,
'Удалить',
@@ -379,10 +384,10 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
onReply?.call();
}
Future<void> _stub(String name) async {
if (!mounted) return;
showCustomNotification(context, '$name — пока в разработке');
Future<void> _forward() async {
final onForward = widget.onForward;
await _close();
onForward?.call();
}
@override