feat(messages): пересылка сообщений
This commit is contained in:
@@ -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 '';
|
||||
}
|
||||
|
||||
/// Загружает отложенные (запланированные) сообщения чата.
|
||||
///
|
||||
/// В отличие от обычной истории, отложенные сообщения не сохраняются
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user