работа с закрепленными
This commit is contained in:
@@ -43,6 +43,7 @@ CachedChat? parseChatRow(
|
|||||||
final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing);
|
final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing);
|
||||||
final presence = _resolvePresence(type, otherId, presenceMap);
|
final presence = _resolvePresence(type, otherId, presenceMap);
|
||||||
final adminsOwner = _resolveAdmins(chat);
|
final adminsOwner = _resolveAdmins(chat);
|
||||||
|
final pinned = _resolvePinnedMessage(chat['pinnedMessage']);
|
||||||
|
|
||||||
return CachedChat(
|
return CachedChat(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -66,6 +67,10 @@ CachedChat? parseChatRow(
|
|||||||
options: titleIcon.options,
|
options: titleIcon.options,
|
||||||
owner: adminsOwner.owner,
|
owner: adminsOwner.owner,
|
||||||
admins: adminsOwner.admins,
|
admins: adminsOwner.admins,
|
||||||
|
pinnedMsgId: pinned.id,
|
||||||
|
pinnedMsgText: pinned.text,
|
||||||
|
pinnedMsgTime: pinned.time,
|
||||||
|
pinnedMsgIsPreview: pinned.isPreview,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e("Ошибка при парсинге чата: $e");
|
logger.e("Ошибка при парсинге чата: $e");
|
||||||
@@ -130,6 +135,24 @@ _resolveLastMessage(dynamic lastMsg) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
({int? id, String? text, int? time, bool isPreview}) _resolvePinnedMessage(
|
||||||
|
dynamic pinned,
|
||||||
|
) {
|
||||||
|
if (pinned is! Map) {
|
||||||
|
return (id: null, text: null, time: null, isPreview: false);
|
||||||
|
}
|
||||||
|
final rawId = pinned['id'];
|
||||||
|
final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '');
|
||||||
|
if (id == null) return (id: null, text: null, time: null, isPreview: false);
|
||||||
|
final preview = pinnedMessagePreview(pinned);
|
||||||
|
return (
|
||||||
|
id: id,
|
||||||
|
text: preview.text,
|
||||||
|
time: pinned['time'] as int?,
|
||||||
|
isPreview: preview.isPreview,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite(
|
({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite(
|
||||||
Map<dynamic, dynamic> chatsConfig,
|
Map<dynamic, dynamic> chatsConfig,
|
||||||
int id,
|
int id,
|
||||||
@@ -270,6 +293,10 @@ bool sameChatContent(CachedChat a, CachedChat b) {
|
|||||||
if (a.title != b.title) return false;
|
if (a.title != b.title) return false;
|
||||||
if (a.iconUrl != b.iconUrl) return false;
|
if (a.iconUrl != b.iconUrl) return false;
|
||||||
if (a.owner != b.owner) return false;
|
if (a.owner != b.owner) return false;
|
||||||
|
if (a.pinnedMsgId != b.pinnedMsgId) return false;
|
||||||
|
if (a.pinnedMsgText != b.pinnedMsgText) return false;
|
||||||
|
if (a.pinnedMsgTime != b.pinnedMsgTime) return false;
|
||||||
|
if (a.pinnedMsgIsPreview != b.pinnedMsgIsPreview) return false;
|
||||||
if (a.dontDisturbUntil != b.dontDisturbUntil) return false;
|
if (a.dontDisturbUntil != b.dontDisturbUntil) return false;
|
||||||
if (a.favIndex != b.favIndex) return false;
|
if (a.favIndex != b.favIndex) return false;
|
||||||
if (a.lastMsgId != b.lastMsgId) return false;
|
if (a.lastMsgId != b.lastMsgId) return false;
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
String? attachPreviewLabel(dynamic attaches) {
|
String? attachPreviewLabel(dynamic attaches) {
|
||||||
if (attaches is! List || attaches.isEmpty) return null;
|
final first = _firstPreviewAttach(attaches);
|
||||||
final first = attaches.first;
|
if (first == null) return null;
|
||||||
if (first is! Map) return null;
|
|
||||||
final type = (first['_type'] as String? ?? '').toUpperCase();
|
final type = (first['_type'] as String? ?? '').toUpperCase();
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'PHOTO':
|
case 'PHOTO':
|
||||||
return 'Фото';
|
return 'Фото';
|
||||||
case 'VIDEO':
|
case 'VIDEO':
|
||||||
return 'Видео';
|
return _isVideoNote(first) ? 'Видео-сообщение' : 'Видео';
|
||||||
case 'AUDIO':
|
case 'AUDIO':
|
||||||
return 'Голосовое сообщение';
|
return 'Голосовое сообщение';
|
||||||
case 'FILE':
|
case 'FILE':
|
||||||
@@ -52,6 +51,23 @@ String? attachPreviewLabel(dynamic attaches) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map? _firstPreviewAttach(dynamic attaches) {
|
||||||
|
if (attaches is! List || attaches.isEmpty) return null;
|
||||||
|
for (final attach in attaches) {
|
||||||
|
if (attach is! Map) continue;
|
||||||
|
final type = (attach['_type'] as String? ?? '').toUpperCase();
|
||||||
|
if (type == 'INLINE_KEYBOARD') continue;
|
||||||
|
return attach;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isVideoNote(Map attach) {
|
||||||
|
final raw = attach['videoType'];
|
||||||
|
if (raw is int) return raw == 1;
|
||||||
|
return raw?.toString() == '1';
|
||||||
|
}
|
||||||
|
|
||||||
String? _controlPreviewLabel(Map c) {
|
String? _controlPreviewLabel(Map c) {
|
||||||
final title = c['title']?.toString();
|
final title = c['title']?.toString();
|
||||||
if (title != null && title.isNotEmpty) return title;
|
if (title != null && title.isNotEmpty) return title;
|
||||||
@@ -90,6 +106,60 @@ String? messagePreviewText(Map msg) {
|
|||||||
return _bodyPreviewText(msg);
|
return _bodyPreviewText(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
({String? text, bool isPreview}) pinnedMessagePreview(Map msg) {
|
||||||
|
final link = msg['link'];
|
||||||
|
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
|
||||||
|
final original = link['message'];
|
||||||
|
if (original is Map) {
|
||||||
|
final inner = pinnedMessagePreview(original);
|
||||||
|
return inner.text != null && inner.text!.isNotEmpty
|
||||||
|
? (text: '↪ ${inner.text}', isPreview: inner.isPreview)
|
||||||
|
: (text: '↪ пересланное сообщение', isPreview: true);
|
||||||
|
}
|
||||||
|
return (text: '↪ пересланное сообщение', isPreview: true);
|
||||||
|
}
|
||||||
|
return _pinnedBodyPreview(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
({String? text, bool isPreview}) _pinnedBodyPreview(Map msg) {
|
||||||
|
final text = msg['text']?.toString();
|
||||||
|
if (text != null && text.isNotEmpty) return (text: text, isPreview: false);
|
||||||
|
final label = _pinnedAttachPreviewLabel(msg['attaches']);
|
||||||
|
return (text: label, isPreview: label != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _pinnedAttachPreviewLabel(dynamic attaches) {
|
||||||
|
final first = _firstPreviewAttach(attaches);
|
||||||
|
if (first == null) return null;
|
||||||
|
final type = (first['_type'] as String? ?? '').toUpperCase();
|
||||||
|
switch (type) {
|
||||||
|
case 'PHOTO':
|
||||||
|
return 'фото';
|
||||||
|
case 'VIDEO':
|
||||||
|
return _isVideoNote(first) ? 'кружок' : 'видео';
|
||||||
|
case 'AUDIO':
|
||||||
|
return 'голосовое сообщение';
|
||||||
|
case 'FILE':
|
||||||
|
return 'файл';
|
||||||
|
case 'STICKER':
|
||||||
|
return 'стикер';
|
||||||
|
case 'SHARE':
|
||||||
|
return 'ссылка';
|
||||||
|
case 'POLL':
|
||||||
|
return 'голосование';
|
||||||
|
case 'LOCATION':
|
||||||
|
return 'геопозиция';
|
||||||
|
case 'CONTACT':
|
||||||
|
return 'контакт';
|
||||||
|
case 'CALL':
|
||||||
|
return 'звонок';
|
||||||
|
case 'CONTROL':
|
||||||
|
return _controlPreviewLabel(first)?.toLowerCase();
|
||||||
|
default:
|
||||||
|
return 'вложение';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String? _bodyPreviewText(Map msg) {
|
String? _bodyPreviewText(Map msg) {
|
||||||
final text = msg['text']?.toString();
|
final text = msg['text']?.toString();
|
||||||
if (text != null && text.isNotEmpty) return text;
|
if (text != null && text.isNotEmpty) return text;
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class CachedChat {
|
|||||||
final Set<String> options;
|
final Set<String> options;
|
||||||
final int? owner;
|
final int? owner;
|
||||||
final Set<int> admins;
|
final Set<int> admins;
|
||||||
|
final int? pinnedMsgId;
|
||||||
|
final String? pinnedMsgText;
|
||||||
|
final int? pinnedMsgTime;
|
||||||
|
final bool pinnedMsgIsPreview;
|
||||||
|
|
||||||
CachedChat({
|
CachedChat({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -83,6 +87,10 @@ class CachedChat {
|
|||||||
this.options = const {},
|
this.options = const {},
|
||||||
this.owner,
|
this.owner,
|
||||||
this.admins = const {},
|
this.admins = const {},
|
||||||
|
this.pinnedMsgId,
|
||||||
|
this.pinnedMsgText,
|
||||||
|
this.pinnedMsgTime,
|
||||||
|
this.pinnedMsgIsPreview = false,
|
||||||
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
|
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
|
||||||
? lastMsgText.replaceAll('\n', ' ')
|
? lastMsgText.replaceAll('\n', ' ')
|
||||||
: lastMsgText;
|
: lastMsgText;
|
||||||
@@ -110,6 +118,15 @@ class CachedChat {
|
|||||||
|
|
||||||
bool iAmAdmin(int myId) => owner == myId || admins.contains(myId);
|
bool iAmAdmin(int myId) => owner == myId || admins.contains(myId);
|
||||||
|
|
||||||
|
bool get hasPinnedMessage => pinnedMsgId != null;
|
||||||
|
|
||||||
|
bool get isGroupChat => type == 'CHAT' || type == 'GROUP';
|
||||||
|
|
||||||
|
bool canPinMessages(int myId) {
|
||||||
|
if (!isGroupChat) return false;
|
||||||
|
return iAmAdmin(myId) || options.contains('ALL_CAN_PIN_MESSAGE');
|
||||||
|
}
|
||||||
|
|
||||||
bool get isMuted {
|
bool get isMuted {
|
||||||
if (dontDisturbUntil == ChatsModule.muteOff) return false;
|
if (dontDisturbUntil == ChatsModule.muteOff) return false;
|
||||||
if (dontDisturbUntil < 0) return true;
|
if (dontDisturbUntil < 0) return true;
|
||||||
@@ -141,6 +158,10 @@ class CachedChat {
|
|||||||
options: _decodeOptions(row['options']),
|
options: _decodeOptions(row['options']),
|
||||||
owner: row['owner'] as int?,
|
owner: row['owner'] as int?,
|
||||||
admins: _decodeAdmins(row['admins']),
|
admins: _decodeAdmins(row['admins']),
|
||||||
|
pinnedMsgId: row['pinned_msg_id'] as int?,
|
||||||
|
pinnedMsgText: row['pinned_msg_text'] as String?,
|
||||||
|
pinnedMsgTime: row['pinned_msg_time'] as int?,
|
||||||
|
pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
static Set<String> _decodeOptions(dynamic raw) {
|
static Set<String> _decodeOptions(dynamic raw) {
|
||||||
@@ -182,6 +203,10 @@ class CachedChat {
|
|||||||
'options': options.isEmpty ? null : options.join(','),
|
'options': options.isEmpty ? null : options.join(','),
|
||||||
'owner': owner,
|
'owner': owner,
|
||||||
'admins': admins.isEmpty ? null : admins.join(','),
|
'admins': admins.isEmpty ? null : admins.join(','),
|
||||||
|
'pinned_msg_id': pinnedMsgId,
|
||||||
|
'pinned_msg_text': pinnedMsgText,
|
||||||
|
'pinned_msg_time': pinnedMsgTime,
|
||||||
|
'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
static const Object _keep = Object();
|
static const Object _keep = Object();
|
||||||
@@ -207,6 +232,10 @@ class CachedChat {
|
|||||||
Set<String>? options,
|
Set<String>? options,
|
||||||
Object? owner = _keep,
|
Object? owner = _keep,
|
||||||
Set<int>? admins,
|
Set<int>? admins,
|
||||||
|
Object? pinnedMsgId = _keep,
|
||||||
|
Object? pinnedMsgText = _keep,
|
||||||
|
Object? pinnedMsgTime = _keep,
|
||||||
|
bool? pinnedMsgIsPreview,
|
||||||
}) {
|
}) {
|
||||||
return CachedChat(
|
return CachedChat(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -243,6 +272,16 @@ class CachedChat {
|
|||||||
options: options ?? this.options,
|
options: options ?? this.options,
|
||||||
owner: identical(owner, _keep) ? this.owner : owner as int?,
|
owner: identical(owner, _keep) ? this.owner : owner as int?,
|
||||||
admins: admins ?? this.admins,
|
admins: admins ?? this.admins,
|
||||||
|
pinnedMsgId: identical(pinnedMsgId, _keep)
|
||||||
|
? this.pinnedMsgId
|
||||||
|
: pinnedMsgId as int?,
|
||||||
|
pinnedMsgText: identical(pinnedMsgText, _keep)
|
||||||
|
? this.pinnedMsgText
|
||||||
|
: pinnedMsgText as String?,
|
||||||
|
pinnedMsgTime: identical(pinnedMsgTime, _keep)
|
||||||
|
? this.pinnedMsgTime
|
||||||
|
: pinnedMsgTime as int?,
|
||||||
|
pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -700,10 +739,45 @@ class ChatsModule {
|
|||||||
}
|
}
|
||||||
if (unread != null) newRow['unread_count'] = unread;
|
if (unread != null) newRow['unread_count'] = unread;
|
||||||
|
|
||||||
|
final pinned = _extractPinnedMessage(msg);
|
||||||
|
if (pinned != null) {
|
||||||
|
newRow['pinned_msg_id'] = pinned.id;
|
||||||
|
newRow['pinned_msg_text'] = pinned.text;
|
||||||
|
newRow['pinned_msg_time'] = pinned.time;
|
||||||
|
newRow['pinned_msg_is_preview'] = pinned.isPreview ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
await AppDatabase.saveChats([newRow]);
|
await AppDatabase.saveChats([newRow]);
|
||||||
_bump();
|
_bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
({int? id, String? text, int? time, bool isPreview})? _extractPinnedMessage(
|
||||||
|
Map msg,
|
||||||
|
) {
|
||||||
|
final attaches = msg['attaches'];
|
||||||
|
if (attaches is! List) return null;
|
||||||
|
for (final a in attaches.whereType<Map>()) {
|
||||||
|
if ((a['_type'] as String?) != 'CONTROL') continue;
|
||||||
|
final event = a['event']?.toString();
|
||||||
|
if (event != 'pin' && event != 'unpin') continue;
|
||||||
|
final pinned = a['pinnedMessage'];
|
||||||
|
if (event == 'unpin' || pinned is! Map) {
|
||||||
|
return (id: null, text: null, time: null, isPreview: false);
|
||||||
|
}
|
||||||
|
final rawId = pinned['id'];
|
||||||
|
final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '');
|
||||||
|
if (id == null) return null;
|
||||||
|
final preview = pinnedMessagePreview(pinned.cast<dynamic, dynamic>());
|
||||||
|
return (
|
||||||
|
id: id,
|
||||||
|
text: preview.text,
|
||||||
|
time: pinned['time'] as int?,
|
||||||
|
isPreview: preview.isPreview,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _reconcileLastMessage(
|
Future<void> _reconcileLastMessage(
|
||||||
int accountId,
|
int accountId,
|
||||||
int chatId,
|
int chatId,
|
||||||
@@ -1269,6 +1343,39 @@ class ChatsModule {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> setPinnedMessage(
|
||||||
|
Api api, {
|
||||||
|
required int chatId,
|
||||||
|
required int? messageId,
|
||||||
|
bool notify = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final packet = await api.sendRequest(Opcode.chatUpdate, {
|
||||||
|
'chatId': chatId,
|
||||||
|
'notifyPin': notify,
|
||||||
|
'pinMessageId': messageId ?? 0,
|
||||||
|
});
|
||||||
|
if (!packet.isOk) {
|
||||||
|
return messageFromErrorPayload(packet.payload);
|
||||||
|
}
|
||||||
|
final data = packet.payload;
|
||||||
|
final chat = data is Map ? data['chat'] : null;
|
||||||
|
if (chat is Map) {
|
||||||
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
|
if (accountId != null) {
|
||||||
|
await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} on PacketError catch (e) {
|
||||||
|
logger.w('setPinnedMessage $chatId: ${e.message}');
|
||||||
|
return e.message;
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('setPinnedMessage $chatId: $e');
|
||||||
|
return 'Не удалось изменить закрепление';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> togglePin(
|
Future<String?> togglePin(
|
||||||
Api api, {
|
Api api, {
|
||||||
required List<int> chatIds,
|
required List<int> chatIds,
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ class AppDatabase {
|
|||||||
await _migrateLegacyDb(target);
|
await _migrateLegacyDb(target);
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
target,
|
target,
|
||||||
version: 17,
|
version: 19,
|
||||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||||
onCreate: (db, _) => _createTables(db),
|
onCreate: (db, _) => _createTables(db),
|
||||||
onUpgrade: (db, oldVersion, newVersion) async {
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
@@ -296,6 +296,34 @@ class AppDatabase {
|
|||||||
await _createChatParticipantsIndex(db);
|
await _createChatParticipantsIndex(db);
|
||||||
await _backfillChatParticipants(db);
|
await _backfillChatParticipants(db);
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 18) {
|
||||||
|
await _addColumnIfMissing(
|
||||||
|
db,
|
||||||
|
'chats_cache',
|
||||||
|
'pinned_msg_id',
|
||||||
|
'INTEGER',
|
||||||
|
);
|
||||||
|
await _addColumnIfMissing(
|
||||||
|
db,
|
||||||
|
'chats_cache',
|
||||||
|
'pinned_msg_text',
|
||||||
|
'TEXT',
|
||||||
|
);
|
||||||
|
await _addColumnIfMissing(
|
||||||
|
db,
|
||||||
|
'chats_cache',
|
||||||
|
'pinned_msg_time',
|
||||||
|
'INTEGER',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (oldVersion < 19) {
|
||||||
|
await _addColumnIfMissing(
|
||||||
|
db,
|
||||||
|
'chats_cache',
|
||||||
|
'pinned_msg_is_preview',
|
||||||
|
'INTEGER NOT NULL DEFAULT 0',
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -444,6 +472,10 @@ class AppDatabase {
|
|||||||
owner INTEGER,
|
owner INTEGER,
|
||||||
admins TEXT,
|
admins TEXT,
|
||||||
in_list INTEGER NOT NULL DEFAULT 1,
|
in_list INTEGER NOT NULL DEFAULT 1,
|
||||||
|
pinned_msg_id INTEGER,
|
||||||
|
pinned_msg_text TEXT,
|
||||||
|
pinned_msg_time INTEGER,
|
||||||
|
pinned_msg_is_preview INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (id, account_id)
|
PRIMARY KEY (id, account_id)
|
||||||
)
|
)
|
||||||
''';
|
''';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:komet/backend/modules/chat_preview.dart';
|
||||||
import 'package:komet/backend/modules/chats.dart';
|
import 'package:komet/backend/modules/chats.dart';
|
||||||
import 'package:komet/backend/modules/file_uploader.dart';
|
import 'package:komet/backend/modules/file_uploader.dart';
|
||||||
import 'package:komet/backend/modules/upload_notification_service.dart';
|
import 'package:komet/backend/modules/upload_notification_service.dart';
|
||||||
@@ -17,10 +18,12 @@ import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
|||||||
import 'package:komet/frontend/screens/contacts/contact_profile_screen.dart';
|
import 'package:komet/frontend/screens/contacts/contact_profile_screen.dart';
|
||||||
import 'package:komet/frontend/screens/chats/chat_list_screen.dart';
|
import 'package:komet/frontend/screens/chats/chat_list_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/animated_text_swap.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';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../backend/api.dart';
|
import '../../../backend/api.dart';
|
||||||
import '../../../backend/modules/messages.dart';
|
import '../../../backend/modules/messages.dart';
|
||||||
import '../../../backend/modules/complaints.dart';
|
import '../../../backend/modules/complaints.dart';
|
||||||
@@ -568,10 +571,130 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _canPinMessage(CachedMessage message) {
|
||||||
|
if (message.isControl) return false;
|
||||||
|
if (int.tryParse(message.id) == null) return false;
|
||||||
|
return chat?.canPinMessages(_myId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _togglePinMessage(CachedMessage message) async {
|
||||||
|
final messageId = int.tryParse(message.id);
|
||||||
|
if (messageId == null) return;
|
||||||
|
final previousChat = chat;
|
||||||
|
final willUnpin = chat?.pinnedMsgId == messageId;
|
||||||
|
if (willUnpin) {
|
||||||
|
_applyPinnedMessageLocally();
|
||||||
|
} else {
|
||||||
|
final preview = _pinnedPreviewFor(message);
|
||||||
|
_applyPinnedMessageLocally(
|
||||||
|
messageId: messageId,
|
||||||
|
text: preview.text,
|
||||||
|
time: message.time,
|
||||||
|
isPreview: preview.isPreview,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final error = await chats.setPinnedMessage(
|
||||||
|
api,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
messageId: willUnpin ? null : messageId,
|
||||||
|
notify: !willUnpin,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (error != null) {
|
||||||
|
if (previousChat != null) setState(() => chat = previousChat);
|
||||||
|
showCustomNotification(context, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
willUnpin ? 'Сообщение откреплено' : 'Сообщение закреплено',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unpinCurrentMessage() async {
|
||||||
|
final previousChat = chat;
|
||||||
|
_applyPinnedMessageLocally();
|
||||||
|
final error = await chats.setPinnedMessage(
|
||||||
|
api,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
messageId: null,
|
||||||
|
notify: false,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (error != null) {
|
||||||
|
if (previousChat != null) setState(() => chat = previousChat);
|
||||||
|
showCustomNotification(context, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showCustomNotification(context, 'Сообщение откреплено');
|
||||||
|
}
|
||||||
|
|
||||||
|
({String? text, bool isPreview}) _pinnedPreviewFor(CachedMessage message) {
|
||||||
|
final payload = message.payload;
|
||||||
|
if (payload != null) return pinnedMessagePreview(payload);
|
||||||
|
return pinnedMessagePreview({
|
||||||
|
'text': message.text,
|
||||||
|
'attaches':
|
||||||
|
message.attachments?.map((a) => a.toMap()).toList() ?? const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyPinnedMessageLocally({
|
||||||
|
int? messageId,
|
||||||
|
String? text,
|
||||||
|
int? time,
|
||||||
|
bool isPreview = false,
|
||||||
|
}) {
|
||||||
|
final current = chat;
|
||||||
|
if (current == null) return;
|
||||||
|
setState(() {
|
||||||
|
chat = current.copyWith(
|
||||||
|
pinnedMsgId: messageId,
|
||||||
|
pinnedMsgText: text,
|
||||||
|
pinnedMsgTime: time,
|
||||||
|
pinnedMsgIsPreview: isPreview,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _jumpToPinnedMessage() {
|
||||||
|
final id = chat?.pinnedMsgId;
|
||||||
|
final time = chat?.pinnedMsgTime;
|
||||||
|
if (id == null) return;
|
||||||
|
unawaited(_openPinnedMessage(id.toString(), time ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openPinnedMessage(String messageId, int time) async {
|
||||||
|
if (!_messages.any((m) => m.id == messageId)) {
|
||||||
|
var guard = 0;
|
||||||
|
while (mounted &&
|
||||||
|
guard < 60 &&
|
||||||
|
_hasMoreHistory &&
|
||||||
|
!_messages.any((m) => m.id == messageId) &&
|
||||||
|
(_messages.isEmpty || _messages.first.time > time)) {
|
||||||
|
guard++;
|
||||||
|
final before = _messages.isEmpty ? 0 : _messages.first.time;
|
||||||
|
await _loadMoreHistory();
|
||||||
|
if (!mounted) return;
|
||||||
|
final after = _messages.isEmpty ? 0 : _messages.first.time;
|
||||||
|
if (after == before) break;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
if (!mounted) return;
|
||||||
|
}
|
||||||
|
if (_messages.any((m) => m.id == messageId)) {
|
||||||
|
_scrollToLoadedMessage(messageId);
|
||||||
|
} else {
|
||||||
|
showCustomNotification(context, 'Сообщение не загружено');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool _badgeRefreshing = false;
|
bool _badgeRefreshing = false;
|
||||||
bool _badgeRefreshQueued = false;
|
bool _badgeRefreshQueued = false;
|
||||||
|
|
||||||
void _onChatsBump() {
|
void _onChatsBump() {
|
||||||
|
unawaited(_reloadChatMeta());
|
||||||
if (_badgeRefreshing) {
|
if (_badgeRefreshing) {
|
||||||
_badgeRefreshQueued = true;
|
_badgeRefreshQueued = true;
|
||||||
return;
|
return;
|
||||||
@@ -579,6 +702,27 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
unawaited(_runBadgeRefresh());
|
unawaited(_runBadgeRefresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _reloadChatMeta() async {
|
||||||
|
if (_myId == 0) return;
|
||||||
|
final rows = await chats.getChat(_myId, widget.chatId);
|
||||||
|
if (!mounted || rows.isEmpty) return;
|
||||||
|
final fresh = rows.first;
|
||||||
|
final current = chat;
|
||||||
|
if (current != null &&
|
||||||
|
current.pinnedMsgId == fresh.pinnedMsgId &&
|
||||||
|
current.pinnedMsgText == fresh.pinnedMsgText &&
|
||||||
|
current.pinnedMsgTime == fresh.pinnedMsgTime &&
|
||||||
|
current.pinnedMsgIsPreview == fresh.pinnedMsgIsPreview &&
|
||||||
|
current.owner == fresh.owner &&
|
||||||
|
current.options.length == fresh.options.length &&
|
||||||
|
current.options.containsAll(fresh.options) &&
|
||||||
|
current.admins.length == fresh.admins.length &&
|
||||||
|
current.admins.containsAll(fresh.admins)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => chat = fresh);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _runBadgeRefresh() async {
|
Future<void> _runBadgeRefresh() async {
|
||||||
_badgeRefreshing = true;
|
_badgeRefreshing = true;
|
||||||
try {
|
try {
|
||||||
@@ -3108,10 +3252,26 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget? _buildPinnedBanner({required bool floating}) {
|
||||||
|
final pinned = chat;
|
||||||
|
if (pinned == null || !pinned.hasPinnedMessage) return null;
|
||||||
|
return _PinnedMessageBanner(
|
||||||
|
text: pinned.pinnedMsgText,
|
||||||
|
isPreview: pinned.pinnedMsgIsPreview,
|
||||||
|
floating: floating,
|
||||||
|
onTap: _jumpToPinnedMessage,
|
||||||
|
onUnpin: pinned.canPinMessages(_myId)
|
||||||
|
? () => unawaited(_unpinCurrentMessage())
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildColorBody() {
|
Widget _buildColorBody() {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final banner = _buildPinnedBanner(floating: false);
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
?banner,
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
@@ -3150,6 +3310,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
Widget _buildUnderlapBody() {
|
Widget _buildUnderlapBody() {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final vignette = AppChatChrome.current.value == ChatChromeStyle.none;
|
final vignette = AppChatChrome.current.value == ChatChromeStyle.none;
|
||||||
|
final glossy = AppVisualStyle.current.value == VisualStyle.glossy;
|
||||||
|
final bannerTop =
|
||||||
|
MediaQuery.paddingOf(context).top +
|
||||||
|
(glossy ? _glossyHeaderHeight : kToolbarHeight) +
|
||||||
|
8;
|
||||||
|
final banner = _buildPinnedBanner(floating: true);
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -3185,6 +3351,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (banner != null)
|
||||||
|
Positioned(top: bannerTop, left: 8, right: 8, child: banner),
|
||||||
ValueListenableBuilder<double>(
|
ValueListenableBuilder<double>(
|
||||||
valueListenable: _composerHeight,
|
valueListenable: _composerHeight,
|
||||||
builder: (context, height, _) => Positioned(
|
builder: (context, height, _) => Positioned(
|
||||||
@@ -3377,6 +3545,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
onMarkUnread: message.isControl
|
onMarkUnread: message.isControl
|
||||||
? null
|
? null
|
||||||
: () => _markMessageUnread(message),
|
: () => _markMessageUnread(message),
|
||||||
|
onPin: _canPinMessage(message)
|
||||||
|
? () => _togglePinMessage(message)
|
||||||
|
: null,
|
||||||
|
isPinned: () => chat?.pinnedMsgId == int.tryParse(message.id),
|
||||||
loadReportReasons: canReport
|
loadReportReasons: canReport
|
||||||
? () => _loadReportReasons(reportTypeId)
|
? () => _loadReportReasons(reportTypeId)
|
||||||
: null,
|
: null,
|
||||||
@@ -4474,6 +4646,175 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PinnedMessageBanner extends StatelessWidget {
|
||||||
|
final String? text;
|
||||||
|
final bool isPreview;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback? onUnpin;
|
||||||
|
final bool floating;
|
||||||
|
|
||||||
|
const _PinnedMessageBanner({
|
||||||
|
required this.text,
|
||||||
|
required this.isPreview,
|
||||||
|
required this.onTap,
|
||||||
|
this.onUnpin,
|
||||||
|
this.floating = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final content = Material(
|
||||||
|
color: floating
|
||||||
|
? cs.surfaceContainerHigh.withValues(alpha: 0.92)
|
||||||
|
: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: floating ? BorderRadius.circular(16) : null,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 3,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.primary,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)!.pinnedMessageTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
_PinnedMessageText(
|
||||||
|
text: text,
|
||||||
|
isPreview: isPreview,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onUnpin != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||||
|
iconSize: 20,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: onUnpin,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!floating) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: cs.outlineVariant.withValues(alpha: 0.4),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PinnedMessageText extends StatefulWidget {
|
||||||
|
final String? text;
|
||||||
|
final bool isPreview;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _PinnedMessageText({
|
||||||
|
required this.text,
|
||||||
|
required this.isPreview,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PinnedMessageText> createState() => _PinnedMessageTextState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PinnedMessageTextState extends State<_PinnedMessageText> {
|
||||||
|
late String? _primaryText;
|
||||||
|
late bool _primaryIsPreview;
|
||||||
|
late String? _secondaryText;
|
||||||
|
late bool _secondaryIsPreview;
|
||||||
|
bool _showSecondary = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_primaryText = widget.text;
|
||||||
|
_primaryIsPreview = widget.isPreview;
|
||||||
|
_secondaryText = widget.text;
|
||||||
|
_secondaryIsPreview = widget.isPreview;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _PinnedMessageText oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.text == oldWidget.text &&
|
||||||
|
widget.isPreview == oldWidget.isPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_showSecondary) {
|
||||||
|
_primaryText = widget.text;
|
||||||
|
_primaryIsPreview = widget.isPreview;
|
||||||
|
} else {
|
||||||
|
_secondaryText = widget.text;
|
||||||
|
_secondaryIsPreview = widget.isPreview;
|
||||||
|
}
|
||||||
|
_showSecondary = !_showSecondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ClipRect(
|
||||||
|
child: AnimatedTextSwap(
|
||||||
|
showAlternate: _showSecondary,
|
||||||
|
alternate: _buildText(context, _secondaryText, _secondaryIsPreview),
|
||||||
|
child: _buildText(context, _primaryText, _primaryIsPreview),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildText(BuildContext context, String? text, bool isPreview) {
|
||||||
|
final label = text == null || text.isEmpty
|
||||||
|
? AppLocalizations.of(context)!.msgActionsNoText
|
||||||
|
: text;
|
||||||
|
return Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.color,
|
||||||
|
fontSize: 14,
|
||||||
|
fontStyle: isPreview ? FontStyle.italic : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SelectableMessageRow extends StatefulWidget {
|
class _SelectableMessageRow extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final CachedMessage message;
|
final CachedMessage message;
|
||||||
@@ -4488,6 +4829,8 @@ class _SelectableMessageRow extends StatefulWidget {
|
|||||||
final VoidCallback? onReply;
|
final VoidCallback? onReply;
|
||||||
final VoidCallback? onForward;
|
final VoidCallback? onForward;
|
||||||
final VoidCallback? onMarkUnread;
|
final VoidCallback? onMarkUnread;
|
||||||
|
final VoidCallback? onPin;
|
||||||
|
final bool Function() isPinned;
|
||||||
final Future<List<({int id, String title})>> Function()? loadReportReasons;
|
final Future<List<({int id, String title})>> Function()? loadReportReasons;
|
||||||
final Future<bool> Function(int reasonId)? onReport;
|
final Future<bool> Function(int reasonId)? onReport;
|
||||||
|
|
||||||
@@ -4505,6 +4848,8 @@ class _SelectableMessageRow extends StatefulWidget {
|
|||||||
this.onReply,
|
this.onReply,
|
||||||
this.onForward,
|
this.onForward,
|
||||||
this.onMarkUnread,
|
this.onMarkUnread,
|
||||||
|
this.onPin,
|
||||||
|
required this.isPinned,
|
||||||
this.loadReportReasons,
|
this.loadReportReasons,
|
||||||
this.onReport,
|
this.onReport,
|
||||||
});
|
});
|
||||||
@@ -4519,6 +4864,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
|||||||
final GlobalKey _boundaryKey = GlobalKey();
|
final GlobalKey _boundaryKey = GlobalKey();
|
||||||
Offset? _lastTapDown;
|
Offset? _lastTapDown;
|
||||||
|
|
||||||
|
bool _isPinnedNow() => widget.isPinned();
|
||||||
|
|
||||||
void _openMenu() {
|
void _openMenu() {
|
||||||
final ctx = _boundaryKey.currentContext;
|
final ctx = _boundaryKey.currentContext;
|
||||||
if (ctx == null) return;
|
if (ctx == null) return;
|
||||||
@@ -4558,6 +4905,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
|||||||
onReply: widget.onReply,
|
onReply: widget.onReply,
|
||||||
onForward: widget.onForward,
|
onForward: widget.onForward,
|
||||||
onMarkUnread: widget.onMarkUnread,
|
onMarkUnread: widget.onMarkUnread,
|
||||||
|
onPin: widget.onPin,
|
||||||
|
isPinned: _isPinnedNow(),
|
||||||
onDispose: controller.dispose,
|
onDispose: controller.dispose,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4589,6 +4938,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
|||||||
onReply: widget.onReply,
|
onReply: widget.onReply,
|
||||||
onForward: widget.onForward,
|
onForward: widget.onForward,
|
||||||
onMarkUnread: widget.onMarkUnread,
|
onMarkUnread: widget.onMarkUnread,
|
||||||
|
onPin: widget.onPin,
|
||||||
|
isPinned: _isPinnedNow(),
|
||||||
onDispose: controller.dispose,
|
onDispose: controller.dispose,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ void showMessageActions({
|
|||||||
VoidCallback? onReply,
|
VoidCallback? onReply,
|
||||||
VoidCallback? onForward,
|
VoidCallback? onForward,
|
||||||
VoidCallback? onMarkUnread,
|
VoidCallback? onMarkUnread,
|
||||||
|
VoidCallback? onPin,
|
||||||
|
bool isPinned = false,
|
||||||
MessageActionsInteraction interaction =
|
MessageActionsInteraction interaction =
|
||||||
MessageActionsInteraction.dragAndRelease,
|
MessageActionsInteraction.dragAndRelease,
|
||||||
}) {
|
}) {
|
||||||
@@ -108,6 +110,8 @@ void showMessageActions({
|
|||||||
onReply: onReply,
|
onReply: onReply,
|
||||||
onForward: onForward,
|
onForward: onForward,
|
||||||
onMarkUnread: onMarkUnread,
|
onMarkUnread: onMarkUnread,
|
||||||
|
onPin: onPin,
|
||||||
|
isPinned: isPinned,
|
||||||
onDismiss: () {
|
onDismiss: () {
|
||||||
if (entry.mounted) entry.remove();
|
if (entry.mounted) entry.remove();
|
||||||
onDispose();
|
onDispose();
|
||||||
@@ -135,6 +139,8 @@ class _MessageActionsLayer extends StatefulWidget {
|
|||||||
final VoidCallback? onReply;
|
final VoidCallback? onReply;
|
||||||
final VoidCallback? onForward;
|
final VoidCallback? onForward;
|
||||||
final VoidCallback? onMarkUnread;
|
final VoidCallback? onMarkUnread;
|
||||||
|
final VoidCallback? onPin;
|
||||||
|
final bool isPinned;
|
||||||
|
|
||||||
const _MessageActionsLayer({
|
const _MessageActionsLayer({
|
||||||
required this.snapshot,
|
required this.snapshot,
|
||||||
@@ -154,6 +160,8 @@ class _MessageActionsLayer extends StatefulWidget {
|
|||||||
this.onReply,
|
this.onReply,
|
||||||
this.onForward,
|
this.onForward,
|
||||||
this.onMarkUnread,
|
this.onMarkUnread,
|
||||||
|
this.onPin,
|
||||||
|
this.isPinned = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -392,6 +400,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
_Action(Symbols.edit, l10n.msgActionsEdit, _edit),
|
_Action(Symbols.edit, l10n.msgActionsEdit, _edit),
|
||||||
if (widget.onReply != null)
|
if (widget.onReply != null)
|
||||||
_Action(Symbols.reply, l10n.msgActionsReply, _reply),
|
_Action(Symbols.reply, l10n.msgActionsReply, _reply),
|
||||||
|
if (widget.onPin != null)
|
||||||
|
_Action(
|
||||||
|
widget.isPinned ? Symbols.keep_off : Symbols.push_pin,
|
||||||
|
widget.isPinned ? l10n.msgActionsUnpin : l10n.msgActionsPin,
|
||||||
|
_pin,
|
||||||
|
),
|
||||||
if (widget.onForward != null)
|
if (widget.onForward != null)
|
||||||
_Action(Symbols.forward, l10n.msgActionsForward, _forward),
|
_Action(Symbols.forward, l10n.msgActionsForward, _forward),
|
||||||
if (widget.onMarkUnread != null)
|
if (widget.onMarkUnread != null)
|
||||||
@@ -409,7 +423,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
_showReportView,
|
_showReportView,
|
||||||
destructive: true,
|
destructive: true,
|
||||||
),
|
),
|
||||||
_Action(Symbols.delete, l10n.msgActionsDelete, _delete, destructive: true),
|
_Action(
|
||||||
|
Symbols.delete,
|
||||||
|
l10n.msgActionsDelete,
|
||||||
|
_delete,
|
||||||
|
destructive: true,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +522,10 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
if (text != null && text.isNotEmpty) {
|
if (text != null && text.isNotEmpty) {
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showCustomNotification(context, AppLocalizations.of(context)!.msgActionsCopied);
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.msgActionsCopied,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await _close();
|
await _close();
|
||||||
}
|
}
|
||||||
@@ -538,6 +560,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
onMarkUnread?.call();
|
onMarkUnread?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pin() async {
|
||||||
|
final onPin = widget.onPin;
|
||||||
|
await _close();
|
||||||
|
onPin?.call();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
|||||||
@@ -847,6 +847,10 @@ class MessageBubble extends StatelessWidget {
|
|||||||
text =
|
text =
|
||||||
'${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату';
|
'${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату';
|
||||||
break;
|
break;
|
||||||
|
case 'pin':
|
||||||
|
text =
|
||||||
|
'${ContactCache.get(message.senderId) ?? 'Пользователь'} закрепил(а) сообщение';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
text = control.title;
|
text = control.title;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,9 @@
|
|||||||
"msgActionsReply": "Reply",
|
"msgActionsReply": "Reply",
|
||||||
"msgActionsForward": "Forward",
|
"msgActionsForward": "Forward",
|
||||||
"msgActionsMarkUnread": "Mark as unread",
|
"msgActionsMarkUnread": "Mark as unread",
|
||||||
|
"msgActionsPin": "Pin",
|
||||||
|
"msgActionsUnpin": "Unpin",
|
||||||
|
"pinnedMessageTitle": "Pinned message",
|
||||||
"msgActionsEditHistory": "Edit history",
|
"msgActionsEditHistory": "Edit history",
|
||||||
"msgActionsReport": "Report",
|
"msgActionsReport": "Report",
|
||||||
"msgActionsDelete": "Delete",
|
"msgActionsDelete": "Delete",
|
||||||
|
|||||||
@@ -1118,6 +1118,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Mark as unread'**
|
/// **'Mark as unread'**
|
||||||
String get msgActionsMarkUnread;
|
String get msgActionsMarkUnread;
|
||||||
|
|
||||||
|
/// No description provided for @msgActionsPin.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pin'**
|
||||||
|
String get msgActionsPin;
|
||||||
|
|
||||||
|
/// No description provided for @msgActionsUnpin.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unpin'**
|
||||||
|
String get msgActionsUnpin;
|
||||||
|
|
||||||
|
/// No description provided for @pinnedMessageTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pinned message'**
|
||||||
|
String get pinnedMessageTitle;
|
||||||
|
|
||||||
/// No description provided for @msgActionsEditHistory.
|
/// No description provided for @msgActionsEditHistory.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -536,6 +536,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get msgActionsMarkUnread => 'Mark as unread';
|
String get msgActionsMarkUnread => 'Mark as unread';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get msgActionsPin => 'Pin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get msgActionsUnpin => 'Unpin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pinnedMessageTitle => 'Pinned message';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get msgActionsEditHistory => 'Edit history';
|
String get msgActionsEditHistory => 'Edit history';
|
||||||
|
|
||||||
|
|||||||
@@ -539,6 +539,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get msgActionsMarkUnread => 'Непрочитанное';
|
String get msgActionsMarkUnread => 'Непрочитанное';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get msgActionsPin => 'Закрепить';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get msgActionsUnpin => 'Открепить';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pinnedMessageTitle => 'Закреплённое сообщение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get msgActionsEditHistory => 'История изменений';
|
String get msgActionsEditHistory => 'История изменений';
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,9 @@
|
|||||||
"msgActionsReply": "Ответить",
|
"msgActionsReply": "Ответить",
|
||||||
"msgActionsForward": "Переслать",
|
"msgActionsForward": "Переслать",
|
||||||
"msgActionsMarkUnread": "Непрочитанное",
|
"msgActionsMarkUnread": "Непрочитанное",
|
||||||
|
"msgActionsPin": "Закрепить",
|
||||||
|
"msgActionsUnpin": "Открепить",
|
||||||
|
"pinnedMessageTitle": "Закреплённое сообщение",
|
||||||
"msgActionsEditHistory": "История изменений",
|
"msgActionsEditHistory": "История изменений",
|
||||||
"msgActionsReport": "Пожаловаться",
|
"msgActionsReport": "Пожаловаться",
|
||||||
"msgActionsDelete": "Удалить",
|
"msgActionsDelete": "Удалить",
|
||||||
|
|||||||
Reference in New Issue
Block a user