feat(messages): пересылка сообщений
This commit is contained in:
@@ -688,6 +688,55 @@ class MessagesModule {
|
|||||||
return '';
|
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/format.dart';
|
||||||
import 'package:komet/core/utils/logger.dart';
|
import 'package:komet/core/utils/logger.dart';
|
||||||
import 'package:komet/frontend/screens/chats/chat_info_screen.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/screens/chats/poll_create_screen.dart';
|
||||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||||
import 'package:komet/frontend/widgets/chat_menu_overlay.dart';
|
import 'package:komet/frontend/widgets/chat_menu_overlay.dart';
|
||||||
@@ -994,7 +995,49 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _forwardSelected() {
|
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) {
|
Widget _buildComposerArea(BuildContext context) {
|
||||||
@@ -2950,6 +2993,9 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
onReply: message.isControl
|
onReply: message.isControl
|
||||||
? null
|
? null
|
||||||
: () => _startReply(message),
|
: () => _startReply(message),
|
||||||
|
onForward: message.isControl
|
||||||
|
? null
|
||||||
|
: () => _forwardMessages([message]),
|
||||||
child: bubble,
|
child: bubble,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4541,6 +4587,7 @@ class _SelectableMessageRow extends StatefulWidget {
|
|||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback? onEdit;
|
final VoidCallback? onEdit;
|
||||||
final VoidCallback? onReply;
|
final VoidCallback? onReply;
|
||||||
|
final VoidCallback? onForward;
|
||||||
|
|
||||||
const _SelectableMessageRow({
|
const _SelectableMessageRow({
|
||||||
required this.child,
|
required this.child,
|
||||||
@@ -4554,6 +4601,7 @@ class _SelectableMessageRow extends StatefulWidget {
|
|||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
this.onEdit,
|
this.onEdit,
|
||||||
this.onReply,
|
this.onReply,
|
||||||
|
this.onForward,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -4600,6 +4648,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
|||||||
onDelete: widget.onDelete,
|
onDelete: widget.onDelete,
|
||||||
onEdit: widget.onEdit,
|
onEdit: widget.onEdit,
|
||||||
onReply: widget.onReply,
|
onReply: widget.onReply,
|
||||||
|
onForward: widget.onForward,
|
||||||
onDispose: controller.dispose,
|
onDispose: controller.dispose,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4626,6 +4675,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
|||||||
onDelete: widget.onDelete,
|
onDelete: widget.onDelete,
|
||||||
onEdit: widget.onEdit,
|
onEdit: widget.onEdit,
|
||||||
onReply: widget.onReply,
|
onReply: widget.onReply,
|
||||||
|
onForward: widget.onForward,
|
||||||
onDispose: controller.dispose,
|
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? onDelete,
|
||||||
VoidCallback? onEdit,
|
VoidCallback? onEdit,
|
||||||
VoidCallback? onReply,
|
VoidCallback? onReply,
|
||||||
|
VoidCallback? onForward,
|
||||||
MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease,
|
MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease,
|
||||||
}) {
|
}) {
|
||||||
final overlay = Overlay.of(context, rootOverlay: true);
|
final overlay = Overlay.of(context, rootOverlay: true);
|
||||||
@@ -93,6 +94,7 @@ void showMessageActions({
|
|||||||
onDelete: onDelete,
|
onDelete: onDelete,
|
||||||
onEdit: onEdit,
|
onEdit: onEdit,
|
||||||
onReply: onReply,
|
onReply: onReply,
|
||||||
|
onForward: onForward,
|
||||||
onDismiss: () {
|
onDismiss: () {
|
||||||
if (entry.mounted) entry.remove();
|
if (entry.mounted) entry.remove();
|
||||||
onDispose();
|
onDispose();
|
||||||
@@ -115,6 +117,7 @@ class _MessageActionsLayer extends StatefulWidget {
|
|||||||
final VoidCallback? onDelete;
|
final VoidCallback? onDelete;
|
||||||
final VoidCallback? onEdit;
|
final VoidCallback? onEdit;
|
||||||
final VoidCallback? onReply;
|
final VoidCallback? onReply;
|
||||||
|
final VoidCallback? onForward;
|
||||||
|
|
||||||
const _MessageActionsLayer({
|
const _MessageActionsLayer({
|
||||||
required this.snapshot,
|
required this.snapshot,
|
||||||
@@ -129,6 +132,7 @@ class _MessageActionsLayer extends StatefulWidget {
|
|||||||
this.onDelete,
|
this.onDelete,
|
||||||
this.onEdit,
|
this.onEdit,
|
||||||
this.onReply,
|
this.onReply,
|
||||||
|
this.onForward,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -297,7 +301,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
_Action(Symbols.edit, 'Изменить', _edit),
|
_Action(Symbols.edit, 'Изменить', _edit),
|
||||||
if (widget.onReply != null)
|
if (widget.onReply != null)
|
||||||
_Action(Symbols.reply, 'Ответить', _reply),
|
_Action(Symbols.reply, 'Ответить', _reply),
|
||||||
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
|
if (widget.onForward != null)
|
||||||
|
_Action(Symbols.forward, 'Переслать', _forward),
|
||||||
_Action(
|
_Action(
|
||||||
Symbols.delete,
|
Symbols.delete,
|
||||||
'Удалить',
|
'Удалить',
|
||||||
@@ -379,10 +384,10 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
onReply?.call();
|
onReply?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _stub(String name) async {
|
Future<void> _forward() async {
|
||||||
if (!mounted) return;
|
final onForward = widget.onForward;
|
||||||
showCustomNotification(context, '$name — пока в разработке');
|
|
||||||
await _close();
|
await _close();
|
||||||
|
onForward?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user