feat: работа с шифрованным текстом/фото
This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:komet/core/config/app_frost.dart';
|
||||
import 'package:komet/frontend/widgets/encryption_lock_badge.dart';
|
||||
import 'package:komet/frontend/widgets/glossy_pill.dart';
|
||||
import 'package:komet/frontend/widgets/online_dot.dart';
|
||||
|
||||
@@ -18,6 +19,7 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
final String chatType;
|
||||
final bool isOfficial;
|
||||
final bool encrypted;
|
||||
final int myId;
|
||||
final ValueListenable<String> headerStatus;
|
||||
final ValueListenable<int> scheduledCount;
|
||||
@@ -42,6 +44,7 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
required this.imageUrl,
|
||||
required this.chatType,
|
||||
required this.isOfficial,
|
||||
this.encrypted = false,
|
||||
required this.myId,
|
||||
required this.headerStatus,
|
||||
required this.scheduledCount,
|
||||
@@ -373,6 +376,7 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
final otherId = chatId ^ myId;
|
||||
final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
avatar,
|
||||
if (showDot)
|
||||
@@ -385,6 +389,12 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
size: dotSize,
|
||||
),
|
||||
),
|
||||
if (encrypted)
|
||||
Positioned(
|
||||
left: -2,
|
||||
bottom: -2,
|
||||
child: EncryptionLockBadge(size: dotSize + 4),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/storage/chat_encryption_store.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/primary_loading_button.dart';
|
||||
import '../../widgets/settings_card.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
|
||||
class ChatEncryptionScreen extends StatefulWidget {
|
||||
final int accountId;
|
||||
final int chatId;
|
||||
|
||||
const ChatEncryptionScreen({
|
||||
super.key,
|
||||
required this.accountId,
|
||||
required this.chatId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatEncryptionScreen> createState() => _ChatEncryptionScreenState();
|
||||
}
|
||||
|
||||
class _ChatEncryptionScreenState extends State<ChatEncryptionScreen> {
|
||||
final _keyController = TextEditingController();
|
||||
final ValueNotifier<bool> _saving = ValueNotifier(false);
|
||||
|
||||
bool _loading = true;
|
||||
bool _enabled = false;
|
||||
bool _keyVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyController.dispose();
|
||||
_saving.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final store = ChatEncryptionStore.instance;
|
||||
await store.load();
|
||||
final key = await store.readKey(widget.accountId, widget.chatId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_enabled = store.isEnabled(widget.accountId, widget.chatId);
|
||||
_keyController.text = key ?? '';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (widget.accountId == 0) {
|
||||
showCustomNotification(context, 'Профиль ещё не загружен');
|
||||
return;
|
||||
}
|
||||
final key = _keyController.text.trim();
|
||||
if (_enabled && key.isEmpty) {
|
||||
showCustomNotification(context, 'Введите ключ шифрования');
|
||||
return;
|
||||
}
|
||||
_saving.value = true;
|
||||
final store = ChatEncryptionStore.instance;
|
||||
if (key.isEmpty) {
|
||||
await store.deleteKey(widget.accountId, widget.chatId);
|
||||
} else {
|
||||
await store.writeKey(widget.accountId, widget.chatId, key);
|
||||
}
|
||||
await store.setEnabled(widget.accountId, widget.chatId, _enabled);
|
||||
if (!mounted) return;
|
||||
_saving.value = false;
|
||||
showCustomNotification(
|
||||
context,
|
||||
_enabled ? 'Шифрование включено' : 'Шифрование отключено',
|
||||
);
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.surface,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface, weight: 400),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
'Шифрование сообщений',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _loading
|
||||
? Center(child: SmallSpinner(size: 36, color: cs.primary))
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsCard(
|
||||
children: [
|
||||
SettingsToggleTile(
|
||||
icon: _enabled ? Symbols.lock : Symbols.lock_open,
|
||||
label: 'Шифровать сообщения',
|
||||
subtitle:
|
||||
'Текст сообщений в этом чате будет зашифрован '
|
||||
'ключом ниже',
|
||||
value: _enabled,
|
||||
onChanged: (v) => setState(() => _enabled = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
padding: const EdgeInsets.all(20),
|
||||
depth: 6,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ключ',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _keyController,
|
||||
obscureText: !_keyVisible,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите ключ',
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_keyVisible
|
||||
? Symbols.visibility_off
|
||||
: Symbols.visibility,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _keyVisible = !_keyVisible),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Ключ хранится только на этом устройстве. '
|
||||
'Собеседник должен ввести такой же ключ, иначе он '
|
||||
'не прочитает сообщения.',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: PrimaryLoadingButton(
|
||||
loading: _saving,
|
||||
onPressed: _save,
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import 'create_channel_flow.dart';
|
||||
import 'create_group_flow.dart';
|
||||
import '../contacts/add_contact_sheet.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../../core/crypto/message_decryption_cache.dart';
|
||||
import '../../widgets/decrypted_text.dart';
|
||||
import '../../widgets/encryption_lock_badge.dart';
|
||||
import '../../widgets/online_dot.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
@@ -52,6 +55,7 @@ import '../../../backend/modules/folders.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/draft_store.dart';
|
||||
import '../../../core/storage/archived_chats_store.dart';
|
||||
import '../../../core/storage/chat_encryption_store.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../core/storage/chat_activity_store.dart';
|
||||
import '../../../main.dart'
|
||||
@@ -579,6 +583,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
});
|
||||
chats.chatsChanged.addListener(_onChatsChanged);
|
||||
ArchivedChatsStore.instance.revision.addListener(_onArchivedChanged);
|
||||
ChatEncryptionStore.instance.revision.addListener(_onEncryptionChanged);
|
||||
DraftStore.instance.revision.addListener(_onDraftsChanged);
|
||||
AppStories.current.addListener(_onStoriesEnabledChanged);
|
||||
storiesModule.storiesChanged.addListener(_onStoriesDataChanged);
|
||||
@@ -624,6 +629,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (mounted) _requestReload();
|
||||
}
|
||||
|
||||
void _onEncryptionChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onStoriesEnabledChanged() {
|
||||
if (!mounted) return;
|
||||
if (!AppStories.current.value) {
|
||||
@@ -1225,6 +1234,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
_settleTimer?.cancel();
|
||||
chats.chatsChanged.removeListener(_onChatsChanged);
|
||||
ArchivedChatsStore.instance.revision.removeListener(_onArchivedChanged);
|
||||
ChatEncryptionStore.instance.revision.removeListener(_onEncryptionChanged);
|
||||
DraftStore.instance.revision.removeListener(_onDraftsChanged);
|
||||
AppStories.current.removeListener(_onStoriesEnabledChanged);
|
||||
storiesModule.storiesChanged.removeListener(_onStoriesDataChanged);
|
||||
@@ -1645,6 +1655,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
messageRanges: isPlaceholder
|
||||
? const []
|
||||
: chat.lastMsgFormatRanges,
|
||||
previewMessageId: isPlaceholder ? null : chat.lastMsgId,
|
||||
previewCipherText: isPlaceholder
|
||||
? null
|
||||
: chat.lastMsgTextOneLine,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -1654,6 +1668,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
: null;
|
||||
|
||||
String fullMsg = "";
|
||||
String senderPrefix = "";
|
||||
List<FormatRange> messageRanges = const [];
|
||||
if (isPlaceholder) {
|
||||
fullMsg = 'зайдите в чат для подгрузки';
|
||||
@@ -1661,6 +1676,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
var prefixLen = 0;
|
||||
if (sender?.isNotEmpty == true && chat.id != 0) {
|
||||
final prefix = "$sender: ";
|
||||
senderPrefix = prefix;
|
||||
fullMsg += prefix;
|
||||
prefixLen = prefix.length;
|
||||
}
|
||||
@@ -1702,6 +1718,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
ownStatus: _ownStatusFor(chat, isPlaceholder),
|
||||
ownRead: chat.lastMsgReadByOthers,
|
||||
messageRanges: messageRanges,
|
||||
previewMessageId: isPlaceholder ? null : chat.lastMsgId,
|
||||
previewPrefix: senderPrefix,
|
||||
previewCipherText: isPlaceholder
|
||||
? null
|
||||
: chat.lastMsgText,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2550,19 +2571,55 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
String? ownStatus,
|
||||
bool ownRead = false,
|
||||
List<FormatRange> messageRanges = const [],
|
||||
int? previewMessageId,
|
||||
String previewPrefix = '',
|
||||
String? previewCipherText,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isSelected = _selectedChats.contains(id);
|
||||
final isEncrypted = ChatEncryptionStore.instance.isEnabled(
|
||||
_profile?.id ?? 0,
|
||||
int.tryParse(id) ?? 0,
|
||||
);
|
||||
final Widget? statusIcon = (ownStatus != null && draft == null)
|
||||
? _ownStatusIcon(cs, ownStatus, ownRead)
|
||||
: null;
|
||||
final Widget messageLine = _buildPreviewLine(
|
||||
cs,
|
||||
message,
|
||||
messageRanges,
|
||||
draft,
|
||||
messageItalic,
|
||||
);
|
||||
final canDecryptPreview =
|
||||
isEncrypted &&
|
||||
draft == null &&
|
||||
previewMessageId != null &&
|
||||
(previewCipherText?.isNotEmpty ?? false);
|
||||
final Widget messageLine = canDecryptPreview
|
||||
? DecryptedContent(
|
||||
accountId: _profile?.id ?? 0,
|
||||
chatId: int.tryParse(id) ?? 0,
|
||||
messageId: previewMessageId.toString(),
|
||||
cipherText: previewCipherText!,
|
||||
builder: (decryption) => switch (decryption?.state) {
|
||||
null => _buildPreviewLine(
|
||||
cs,
|
||||
message,
|
||||
messageRanges,
|
||||
draft,
|
||||
messageItalic,
|
||||
),
|
||||
MessageDecryptionState.wrongKey => _buildPreviewLine(
|
||||
cs,
|
||||
'$previewPrefix' 'неверный ключ',
|
||||
const [],
|
||||
draft,
|
||||
true,
|
||||
),
|
||||
MessageDecryptionState.decrypted => _buildPreviewLine(
|
||||
cs,
|
||||
'$previewPrefix${decryption!.plaintext}',
|
||||
const [],
|
||||
draft,
|
||||
messageItalic,
|
||||
),
|
||||
},
|
||||
)
|
||||
: _buildPreviewLine(cs, message, messageRanges, draft, messageItalic);
|
||||
|
||||
final Widget avatarCircle = CircleAvatar(
|
||||
radius: 24,
|
||||
@@ -2645,8 +2702,15 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
avatarCircle,
|
||||
if (isEncrypted)
|
||||
const Positioned(
|
||||
left: -2,
|
||||
bottom: -2,
|
||||
child: EncryptionLockBadge(size: 18),
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
right: -2,
|
||||
|
||||
@@ -40,6 +40,10 @@ import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/push/push_service.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/chat_activity_store.dart';
|
||||
import '../../../core/crypto/chat_crypto_service.dart';
|
||||
import '../../../core/crypto/encrypted_photo.dart';
|
||||
import '../../../core/crypto/message_decryption_cache.dart';
|
||||
import '../../../core/storage/chat_encryption_store.dart';
|
||||
import '../../../core/storage/chat_wallpaper_store.dart';
|
||||
import '../../../core/storage/draft_store.dart';
|
||||
import '../../../core/storage/archived_chats_store.dart';
|
||||
@@ -103,6 +107,7 @@ import '../../widgets/chat_wallpaper_view.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/liquid_glass.dart';
|
||||
import 'scheduled_messages_screen.dart';
|
||||
import 'chat_encryption_screen.dart';
|
||||
import 'chat_wallpaper_preview_screen.dart';
|
||||
|
||||
class _DateSeparatorItem {
|
||||
@@ -263,6 +268,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final GlobalKey _listKey = GlobalKey();
|
||||
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
||||
bool _isLoading = true;
|
||||
bool _encryptionEnabled = false;
|
||||
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
||||
late final StickerPanelController _stickers;
|
||||
final ValueNotifier<UploadStatus> _uploadStatus = ValueNotifier(
|
||||
@@ -750,6 +756,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_restoreDraft();
|
||||
unawaited(_loadPeerKind());
|
||||
unawaited(_loadWallpaper());
|
||||
unawaited(_loadEncryption());
|
||||
unawaited(_refreshBadge());
|
||||
|
||||
try {
|
||||
@@ -1870,6 +1877,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_applyEffectiveWallpaper,
|
||||
);
|
||||
}
|
||||
if (_encryptionListening) {
|
||||
ChatEncryptionStore.instance.revision.removeListener(_applyEncryption);
|
||||
}
|
||||
_headerStatusNotifier.dispose();
|
||||
_otherReadTime.dispose();
|
||||
_chatController.dispose();
|
||||
@@ -2960,6 +2970,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
imageUrl: widget.imageUrl,
|
||||
chatType: widget.chatType,
|
||||
isOfficial: chat?.isOfficial ?? false,
|
||||
encrypted: _encryptionEnabled,
|
||||
myId: _myId,
|
||||
headerStatus: _headerStatusNotifier,
|
||||
scheduledCount: _scheduledCount,
|
||||
@@ -3049,6 +3060,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
label: 'Очистить историю',
|
||||
onTap: _clearHistory,
|
||||
),
|
||||
ChatMenuItem(
|
||||
icon: _encryptionEnabled ? Symbols.lock : Symbols.lock_open,
|
||||
label: 'Шифрование сообщений',
|
||||
onTap: _openEncryptionSettings,
|
||||
),
|
||||
ChatMenuItem(
|
||||
icon: Symbols.delete,
|
||||
label: 'Удалить чат',
|
||||
@@ -3113,6 +3129,43 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
bool _encryptionListening = false;
|
||||
|
||||
Future<void> _loadEncryption() async {
|
||||
await ChatEncryptionStore.instance.load();
|
||||
if (!mounted) return;
|
||||
if (!_encryptionListening) {
|
||||
_encryptionListening = true;
|
||||
ChatEncryptionStore.instance.revision.addListener(_applyEncryption);
|
||||
}
|
||||
_applyEncryption();
|
||||
}
|
||||
|
||||
void _applyEncryption() {
|
||||
if (!mounted) return;
|
||||
final enabled = ChatEncryptionStore.instance.isEnabled(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
);
|
||||
if (enabled != _encryptionEnabled) {
|
||||
setState(() => _encryptionEnabled = enabled);
|
||||
}
|
||||
if (enabled && _myId != 0) {
|
||||
unawaited(ChatCryptoService.instance.warmKey(_myId, widget.chatId));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openEncryptionSettings() async {
|
||||
if (_myId == 0) return;
|
||||
await pushSwipeable(
|
||||
context,
|
||||
(context) =>
|
||||
ChatEncryptionScreen(accountId: _myId, chatId: widget.chatId),
|
||||
);
|
||||
if (!mounted) return;
|
||||
_applyEncryption();
|
||||
}
|
||||
|
||||
bool _wallpaperListening = false;
|
||||
|
||||
Future<void> _loadWallpaper() async {
|
||||
@@ -3482,6 +3535,36 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<String?> _encryptOutgoing(String text) async {
|
||||
if (!_encryptionEnabled || _myId == 0) return text;
|
||||
final result = await ChatCryptoService.instance.encrypt(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
text,
|
||||
);
|
||||
if (result.isOk) {
|
||||
if (result.text!.length > kMaxEncryptedMessageLength) {
|
||||
if (mounted) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Слишком длинное сообщение. Разделите на несколько',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return result.text;
|
||||
}
|
||||
if (mounted) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
result.failure == CryptoFailure.noKey
|
||||
? 'Не задан ключ шифрования'
|
||||
: 'Не удалось зашифровать сообщение',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final content = _messageController.buildContent();
|
||||
final rawText = content.text;
|
||||
@@ -3505,6 +3588,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
}
|
||||
|
||||
final wireText = await _encryptOutgoing(text);
|
||||
if (wireText == null || !mounted) return;
|
||||
final encrypted = wireText != text;
|
||||
|
||||
final tempId = _nextTempId();
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final online = api.state == SessionState.online;
|
||||
@@ -3531,7 +3618,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_replyTo.value = null;
|
||||
_replySourceChatId = null;
|
||||
|
||||
final elements = _trimmedElements(content.elements, rawText, text);
|
||||
final elements = encrypted
|
||||
? const <Map<String, dynamic>>[]
|
||||
: _trimmedElements(content.elements, rawText, text);
|
||||
final Map<String, dynamic>? composedPayload =
|
||||
(replyPayload == null && elements.isEmpty)
|
||||
? null
|
||||
@@ -3542,11 +3631,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: text,
|
||||
text: wireText,
|
||||
time: now,
|
||||
status: online ? 'sending' : 'pending',
|
||||
payload: composedPayload,
|
||||
);
|
||||
if (encrypted) MessageDecryptionCache.instance.seed(tempId, text);
|
||||
|
||||
_hasText.value = false;
|
||||
_lastSentId = tempId;
|
||||
@@ -3565,7 +3655,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
widget.chatId,
|
||||
messageId: tempId,
|
||||
time: now,
|
||||
text: text,
|
||||
text: wireText,
|
||||
status: composed.status ?? 'sending',
|
||||
elements: elements,
|
||||
),
|
||||
@@ -3587,14 +3677,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_myId,
|
||||
widget.chatId,
|
||||
widget.commentPostId!,
|
||||
text,
|
||||
wireText,
|
||||
replyToMessageId: replyId,
|
||||
elements: elements,
|
||||
)
|
||||
: await messagesModule.sendMessage(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
text,
|
||||
wireText,
|
||||
replyToMessageId: replyId,
|
||||
replySourceChatId: replySourceChatId,
|
||||
elements: elements,
|
||||
@@ -3607,11 +3697,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: text,
|
||||
text: wireText,
|
||||
time: now,
|
||||
status: 'sent',
|
||||
payload: composedPayload,
|
||||
);
|
||||
if (encrypted) {
|
||||
MessageDecryptionCache.instance.adopt(tempId, sent.id);
|
||||
}
|
||||
_messages[index] = sent;
|
||||
_bumpMessages();
|
||||
if (!_commentsMode) {
|
||||
@@ -3622,7 +3715,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
widget.chatId,
|
||||
messageId: sent.id,
|
||||
time: now,
|
||||
text: text,
|
||||
text: wireText,
|
||||
status: 'sent',
|
||||
elements: elements,
|
||||
),
|
||||
@@ -5591,13 +5684,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
String tempId,
|
||||
String status, {
|
||||
FileAttachment? attachment,
|
||||
String? realId,
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx == -1) return;
|
||||
final old = _messages[idx];
|
||||
_messages[idx] = CachedMessage(
|
||||
id: tempId,
|
||||
id: realId != null && realId.isNotEmpty ? realId : tempId,
|
||||
accountId: old.accountId,
|
||||
chatId: old.chatId,
|
||||
senderId: old.senderId,
|
||||
@@ -5621,12 +5715,16 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
_showAttachmentPanel.value = false;
|
||||
try {
|
||||
final ok = await messagesModule.sendFileMessage(
|
||||
final realId = await messagesModule.sendFileMessage(
|
||||
widget.chatId,
|
||||
entry.fileId,
|
||||
token: entry.token,
|
||||
);
|
||||
_updateFileMessageStatus(tempId, ok ? 'sent' : 'error');
|
||||
_updateFileMessageStatus(
|
||||
tempId,
|
||||
realId != null ? 'sent' : 'error',
|
||||
realId: realId,
|
||||
);
|
||||
} catch (_) {
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
}
|
||||
@@ -5635,13 +5733,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
Future<bool> _sendFileById(int fileId) async {
|
||||
final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId));
|
||||
try {
|
||||
final ok = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
||||
final realId = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
||||
final ok = realId != null;
|
||||
if (!mounted) return ok;
|
||||
if (ok) {
|
||||
FileHistoryCache.add(
|
||||
FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()),
|
||||
);
|
||||
_updateFileMessageStatus(tempId, 'sent');
|
||||
_updateFileMessageStatus(tempId, 'sent', realId: realId);
|
||||
_showAttachmentPanel.value = false;
|
||||
} else {
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
@@ -5675,11 +5774,17 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
? _sendPhotos
|
||||
: (picked, caption) =>
|
||||
_sendScheduledPhotos(picked, caption, scheduledTime),
|
||||
onPickFile: scheduledTime == null
|
||||
? _pickAndUploadFile
|
||||
: () => _pickAndUploadFile(scheduledTime: scheduledTime),
|
||||
onShareLocation: _shareLocation,
|
||||
onCreatePoll: _createPoll,
|
||||
onPickFile: _encryptionEnabled
|
||||
? () => _refuseUnencrypted('Файлы')
|
||||
: (scheduledTime == null
|
||||
? _pickAndUploadFile
|
||||
: () => _pickAndUploadFile(scheduledTime: scheduledTime)),
|
||||
onShareLocation: _encryptionEnabled
|
||||
? () => _refuseUnencrypted('Геолокацию')
|
||||
: _shareLocation,
|
||||
onCreatePoll: _encryptionEnabled
|
||||
? () => _refuseUnencrypted('Опросы')
|
||||
: _createPoll,
|
||||
);
|
||||
if (!mounted || !hadKeyboard) return;
|
||||
_messageFocusNode.requestFocus();
|
||||
@@ -5689,6 +5794,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
Future<void> _sendPhotos(List<PickedPhoto> picked, String caption) async {
|
||||
if (_myId == 0) return;
|
||||
if (_encryptionEnabled) return _sendEncryptedPhotos(picked, caption);
|
||||
final videos = picked.where((ph) => ph.item.isVideo).toList();
|
||||
final photos = picked.where((ph) => !ph.item.isVideo).toList();
|
||||
if (photos.isEmpty && videos.isEmpty) return;
|
||||
@@ -6208,11 +6314,88 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_photoUploadProgress.remove(tempId)?.dispose();
|
||||
}
|
||||
|
||||
void _refuseUnencrypted(String what) {
|
||||
if (!mounted) return;
|
||||
_showAttachmentPanel.value = false;
|
||||
showCustomNotification(context, '$what пока нельзя зашифровать');
|
||||
}
|
||||
|
||||
Future<void> _sendEncryptedPhotos(
|
||||
List<PickedPhoto> picked,
|
||||
String caption,
|
||||
) async {
|
||||
final photos = picked.where((ph) => !ph.item.isVideo).toList();
|
||||
if (photos.length != picked.length && mounted) {
|
||||
showCustomNotification(context, 'Видео пока нельзя зашифровать');
|
||||
}
|
||||
if (photos.isEmpty) return;
|
||||
|
||||
for (final photo in photos) {
|
||||
final source =
|
||||
photo.editedFile ??
|
||||
photo.item.localFile ??
|
||||
await photo.item.originFile();
|
||||
if (source == null || !mounted) continue;
|
||||
|
||||
_showAttachmentPanel.value = false;
|
||||
_uploadStatus.value = const UploadStatus(active: true);
|
||||
final stamp = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
final prepared = await prepareEncryptedPhoto(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
source: source,
|
||||
stamp: stamp,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!prepared.isOk) {
|
||||
_uploadStatus.value = const UploadStatus();
|
||||
showCustomNotification(
|
||||
context,
|
||||
prepared.failure == CryptoFailure.noKey
|
||||
? 'Не задан ключ шифрования'
|
||||
: 'Не удалось зашифровать фото',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final encrypted = prepared.file!;
|
||||
await _uploadAsFile(
|
||||
source: encrypted,
|
||||
filename: 'photo_$stamp$kEncryptedPhotoExtension',
|
||||
size: await encrypted.length(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
if (caption.isNotEmpty) {
|
||||
final wire = await _encryptOutgoing(caption);
|
||||
if (wire != null && mounted) {
|
||||
await messagesModule.sendMessage(_myId, widget.chatId, wire);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadFile({int? scheduledTime}) async {
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final file = result.files.first;
|
||||
if (file.path == null) return;
|
||||
final picked = result.files.first;
|
||||
if (picked.path == null) return;
|
||||
await _uploadAsFile(
|
||||
source: File(picked.path!),
|
||||
filename: picked.name,
|
||||
size: picked.size,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _uploadAsFile({
|
||||
required File source,
|
||||
required String filename,
|
||||
required int size,
|
||||
int? scheduledTime,
|
||||
}) async {
|
||||
final file = (name: filename, size: size);
|
||||
final done = Completer<void>();
|
||||
|
||||
_showAttachmentPanel.value = false;
|
||||
_uploadStatus.value = UploadStatus(active: true, total: file.size);
|
||||
@@ -6237,7 +6420,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_uploadSub = fileUploader
|
||||
.upload(
|
||||
chatId: widget.chatId,
|
||||
file: File(file.path!),
|
||||
file: source,
|
||||
filename: file.name,
|
||||
totalSize: file.size,
|
||||
scheduledTime: scheduledTime,
|
||||
@@ -6269,7 +6452,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
speedBps: notifSpeedBps,
|
||||
);
|
||||
}
|
||||
case UploadDone(:final fileId, :final token, :final url):
|
||||
case UploadDone(
|
||||
:final fileId,
|
||||
:final token,
|
||||
:final url,
|
||||
:final messageId,
|
||||
):
|
||||
stopNotif();
|
||||
FileHistoryCache.add(
|
||||
FileHistoryEntry(
|
||||
@@ -6292,6 +6480,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_updateFileMessageStatus(
|
||||
tempId!,
|
||||
'sent',
|
||||
realId: messageId,
|
||||
attachment: FileAttachment(
|
||||
fileId: fileId,
|
||||
fileToken: token,
|
||||
@@ -6326,6 +6515,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
_uploadStatus.value = const UploadStatus();
|
||||
_uploadSub = null;
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
onError: (Object e) {
|
||||
if (!mounted) return;
|
||||
@@ -6334,8 +6524,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
|
||||
_uploadStatus.value = const UploadStatus();
|
||||
_uploadSub = null;
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
);
|
||||
return done.future;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,8 +288,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _SendByIdSheet(
|
||||
onSend: (id) async {
|
||||
final ok = await messagesModule.sendFileMessage(chatId, id);
|
||||
if (!ok) return false;
|
||||
final sentId = await messagesModule.sendFileMessage(chatId, id);
|
||||
if (sentId == null) return false;
|
||||
final newest = await CloudStorageModule.fetchLatestFile(
|
||||
messagesModule,
|
||||
accountId,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -8,8 +10,11 @@ import '../../../../core/utils/file_download.dart';
|
||||
import '../../../../core/utils/media_cache.dart';
|
||||
import '../../../../core/utils/format.dart';
|
||||
import '../../../../core/utils/haptics.dart';
|
||||
import '../../../../core/crypto/chat_crypto_service.dart';
|
||||
import '../../../../core/crypto/encrypted_photo.dart';
|
||||
import '../../../../models/attachment.dart';
|
||||
import '../../custom_notification.dart';
|
||||
import '../../photo_viewer.dart';
|
||||
import 'bubble_context.dart';
|
||||
|
||||
class FileBubble extends StatelessWidget {
|
||||
@@ -167,7 +172,86 @@ class FileBubble extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
return fill ? inner : IntrinsicWidth(child: inner);
|
||||
final body = fill ? inner : IntrinsicWidth(child: inner);
|
||||
if (!_isViewableImage(name)) return body;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openInViewer(ctx.context, name, cacheName),
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isViewableImage(String name) =>
|
||||
name.toLowerCase().endsWith('.png');
|
||||
|
||||
Future<void> _openInViewer(
|
||||
BuildContext context,
|
||||
String name,
|
||||
String cacheName,
|
||||
) async {
|
||||
final fileId = file.fileId;
|
||||
if (fileId == null) return;
|
||||
Haptics.tap();
|
||||
|
||||
final wasCached = (await MediaCache.existing(cacheName)) != null;
|
||||
if (!wasCached) MediaDownloadProgress.set(cacheName, 0);
|
||||
File? local;
|
||||
try {
|
||||
final url = await messagesModule.getFileUrl(
|
||||
messageId: ctx.message.id,
|
||||
chatId: ctx.message.chatId,
|
||||
fileId: fileId,
|
||||
);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
local = await MediaCache.getOrDownload(
|
||||
cacheName,
|
||||
url,
|
||||
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!wasCached) MediaDownloadProgress.set(cacheName, null);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (local == null) {
|
||||
showCustomNotification(context, 'Не удалось загрузить файл');
|
||||
return;
|
||||
}
|
||||
|
||||
final shown = await _decryptIfNeeded(local, cacheName);
|
||||
if (!context.mounted) return;
|
||||
if (shown == null) {
|
||||
showCustomNotification(context, 'Неверный ключ');
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PhotoViewerScreen(
|
||||
photos: [PhotoAttachment(localPath: shown.path)],
|
||||
chatId: ctx.message.chatId,
|
||||
message: ctx.message,
|
||||
isFile: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<File?> _decryptIfNeeded(File local, String cacheName) async {
|
||||
final accountId = ctx.message.accountId;
|
||||
final chatId = ctx.message.chatId;
|
||||
if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return local;
|
||||
if (!await ChatCryptoService.instance.looksEncryptedImage(local.path)) {
|
||||
return local;
|
||||
}
|
||||
final result = await openEncryptedPhoto(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
encrypted: local,
|
||||
cacheName: cacheName,
|
||||
);
|
||||
return result.file;
|
||||
}
|
||||
|
||||
Future<void> _downloadFile(
|
||||
@@ -194,8 +278,10 @@ class FileBubble extends StatelessWidget {
|
||||
fileId: fileId,
|
||||
),
|
||||
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
|
||||
onReady: () {
|
||||
if (!cached) MediaDownloadProgress.set(cacheName, null);
|
||||
},
|
||||
);
|
||||
if (!cached) MediaDownloadProgress.set(cacheName, null);
|
||||
if (!context.mounted) return;
|
||||
if (!result.ok) {
|
||||
showCustomNotification(
|
||||
|
||||
@@ -922,8 +922,8 @@ class _FileRow extends StatelessWidget {
|
||||
fileId: fileId,
|
||||
),
|
||||
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
|
||||
onReady: () => MediaDownloadProgress.set(cacheName, null),
|
||||
);
|
||||
MediaDownloadProgress.set(cacheName, null);
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/crypto/message_decryption_cache.dart';
|
||||
|
||||
class DecryptedContent extends StatefulWidget {
|
||||
final int accountId;
|
||||
final int chatId;
|
||||
final String messageId;
|
||||
final String cipherText;
|
||||
final Widget Function(MessageDecryption? decryption) builder;
|
||||
|
||||
const DecryptedContent({
|
||||
super.key,
|
||||
required this.accountId,
|
||||
required this.chatId,
|
||||
required this.messageId,
|
||||
required this.cipherText,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DecryptedContent> createState() => _DecryptedContentState();
|
||||
}
|
||||
|
||||
class _DecryptedContentState extends State<DecryptedContent> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_request();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DecryptedContent old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.messageId != widget.messageId ||
|
||||
old.cipherText != widget.cipherText) {
|
||||
_request();
|
||||
}
|
||||
}
|
||||
|
||||
void _request() {
|
||||
MessageDecryptionCache.instance.request(
|
||||
accountId: widget.accountId,
|
||||
chatId: widget.chatId,
|
||||
messageId: widget.messageId,
|
||||
cipherText: widget.cipherText,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<MessageDecryption?>(
|
||||
valueListenable: MessageDecryptionCache.instance.listenableFor(
|
||||
widget.messageId,
|
||||
),
|
||||
builder: (context, decryption, _) => widget.builder(decryption),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class EncryptionLockBadge extends StatelessWidget {
|
||||
final double size;
|
||||
final Color? borderColor;
|
||||
|
||||
const EncryptionLockBadge({super.key, this.size = 16, this.borderColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: borderColor ?? cs.surface, width: 1.5),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Symbols.lock,
|
||||
size: size * 0.62,
|
||||
weight: 700,
|
||||
fill: 1,
|
||||
color: cs.onPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import '../../backend/modules/messages.dart';
|
||||
import '../screens/webapp/web_app_screen.dart';
|
||||
import '../../core/config/app_bubble_behavior.dart';
|
||||
import '../../core/config/app_bubble_shape.dart';
|
||||
import '../../core/crypto/message_decryption_cache.dart';
|
||||
import 'decrypted_text.dart';
|
||||
import '../../core/utils/bubble_radius.dart';
|
||||
import '../../core/utils/link_opener.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
@@ -1237,7 +1239,18 @@ class MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextContent(BubbleContext ctx) {
|
||||
Widget _buildTextContent(BubbleContext ctx) => DecryptedContent(
|
||||
accountId: message.accountId,
|
||||
chatId: message.chatId,
|
||||
messageId: message.id,
|
||||
cipherText: message.text ?? '',
|
||||
builder: (decryption) => _buildTextContentBody(ctx, decryption),
|
||||
);
|
||||
|
||||
Widget _buildTextContentBody(
|
||||
BubbleContext ctx,
|
||||
MessageDecryption? decryption,
|
||||
) {
|
||||
final attachments = message.attachments;
|
||||
final isForwardedContact =
|
||||
attachments != null &&
|
||||
@@ -1267,20 +1280,43 @@ class MessageBubble extends StatelessWidget {
|
||||
: null,
|
||||
);
|
||||
final ranges = message.formatRanges;
|
||||
final baseTextWidget = isForwarded
|
||||
? _buildForwardedInlineText(ctx, forwarded)
|
||||
: (FormattedMessageText.isFormatted(message.text, ranges)
|
||||
? FormattedMessageText(
|
||||
text: message.text!,
|
||||
ranges: ranges,
|
||||
style: textStyle,
|
||||
)
|
||||
: Text(message.text ?? '', style: textStyle));
|
||||
final decryptedText = decryption?.plaintext;
|
||||
final Widget baseTextWidget;
|
||||
if (decryption?.state == MessageDecryptionState.wrongKey) {
|
||||
baseTextWidget = Text(
|
||||
'неверный ключ',
|
||||
style: textStyle.copyWith(
|
||||
color: ctx.cs.error,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
);
|
||||
} else if (decryptedText != null) {
|
||||
baseTextWidget = Text(decryptedText, style: textStyle);
|
||||
} else if (isForwarded) {
|
||||
baseTextWidget = _buildForwardedInlineText(ctx, forwarded);
|
||||
} else if (FormattedMessageText.isFormatted(message.text, ranges)) {
|
||||
baseTextWidget = FormattedMessageText(
|
||||
text: message.text!,
|
||||
ranges: ranges,
|
||||
style: textStyle,
|
||||
);
|
||||
} else {
|
||||
baseTextWidget = Text(message.text ?? '', style: textStyle);
|
||||
}
|
||||
final textWidget = _wrapSelectable(baseTextWidget);
|
||||
|
||||
final metaWidget = Text(
|
||||
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
|
||||
style: TextStyle(color: ctx.dim, fontSize: 10),
|
||||
final metaWidget = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (decryption?.isDecrypted ?? false) ...[
|
||||
Icon(Symbols.lock, size: 11, weight: 700, fill: 1, color: ctx.dim),
|
||||
const SizedBox(width: 3),
|
||||
],
|
||||
Text(
|
||||
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
|
||||
style: TextStyle(color: ctx.dim, fontSize: 10),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (hasReactions) {
|
||||
@@ -1353,7 +1389,8 @@ class MessageBubble extends StatelessWidget {
|
||||
final name = reply.senderId == myId
|
||||
? 'Вы'
|
||||
: (ContactCache.get(reply.senderId) ?? 'Сообщение');
|
||||
final preview = reply.previewText();
|
||||
final rawPreview = reply.previewText();
|
||||
final quotedId = reply.messageId;
|
||||
|
||||
final quote = Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
|
||||
@@ -1376,14 +1413,28 @@ class MessageBubble extends StatelessWidget {
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (preview.isNotEmpty)
|
||||
Text(
|
||||
preview,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: textColor.withValues(alpha: 0.85),
|
||||
fontSize: 13,
|
||||
if (rawPreview.isNotEmpty)
|
||||
DecryptedContent(
|
||||
accountId: message.accountId,
|
||||
chatId: message.chatId,
|
||||
messageId: quotedId ?? '',
|
||||
cipherText: quotedId == null ? '' : rawPreview,
|
||||
builder: (decryption) => Text(
|
||||
decryption?.state == MessageDecryptionState.wrongKey
|
||||
? 'неверный ключ'
|
||||
: (decryption?.plaintext ?? rawPreview),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: decryption?.state == MessageDecryptionState.wrongKey
|
||||
? cs.error
|
||||
: textColor.withValues(alpha: 0.85),
|
||||
fontSize: 13,
|
||||
fontStyle:
|
||||
decryption?.state == MessageDecryptionState.wrongKey
|
||||
? FontStyle.italic
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -80,6 +80,10 @@ class PhotoViewerScreen extends StatefulWidget {
|
||||
final CachedMessage? message;
|
||||
final PhotoViewerActions? actions;
|
||||
|
||||
/// The opened item is a file attachment rendered as an image, so the counter
|
||||
/// reads "FILE of N" — it has no position within the chat's photo feed.
|
||||
final bool isFile;
|
||||
|
||||
const PhotoViewerScreen({
|
||||
super.key,
|
||||
required this.photos,
|
||||
@@ -87,6 +91,7 @@ class PhotoViewerScreen extends StatefulWidget {
|
||||
this.chatId,
|
||||
this.message,
|
||||
this.actions,
|
||||
this.isFile = false,
|
||||
});
|
||||
|
||||
PhotoViewerScreen.single(String baseUrl, {super.key})
|
||||
@@ -94,7 +99,8 @@ class PhotoViewerScreen extends StatefulWidget {
|
||||
initialIndex = 0,
|
||||
chatId = null,
|
||||
message = null,
|
||||
actions = null;
|
||||
actions = null,
|
||||
isFile = false;
|
||||
|
||||
@override
|
||||
State<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
|
||||
@@ -632,7 +638,9 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
||||
children: [
|
||||
if (_feedLoaded)
|
||||
Text(
|
||||
l10n.photoViewerCounter(_total - _index, _total),
|
||||
widget.isFile
|
||||
? l10n.photoViewerCounterFile(_total)
|
||||
: l10n.photoViewerCounter(_total - _index, _total),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
|
||||
Reference in New Issue
Block a user