diff --git a/android/app/src/main/kotlin/ru/komet/app/ChatNotifications.kt b/android/app/src/main/kotlin/ru/komet/app/ChatNotifications.kt new file mode 100644 index 0000000..df3f5ac --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/ChatNotifications.kt @@ -0,0 +1,22 @@ +package ru.komet.app + +import android.content.Intent +import io.flutter.plugin.common.EventChannel + +object ChatNotifications { + const val EXTRA_CHAT = "komet_chat" + + @Volatile + var activeChatId: Long = 0L + + @Volatile + var sink: EventChannel.EventSink? = null + + fun isDisplayed(chatId: Long): Boolean = + AppState.resumed && activeChatId == chatId + + fun chatIdFrom(intent: Intent?): Long { + val id = intent?.getLongExtra(EXTRA_CHAT, 0L) ?: 0L + return if (id > 0L) id else 0L + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt index 1764ee4..e8fb5aa 100644 --- a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt +++ b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt @@ -74,6 +74,13 @@ class KometNotifier(private val ctx: Context) { private fun showMessage(data: Map) { val chatId = data["mc"]?.toLongOrNull() ?: return + val notifId = (chatId and 0x7fffffff).toInt() + if (ChatNotifications.isDisplayed(chatId)) { + manager().cancel(notifId) + clearHistory(chatId) + rebuildSummary(notifId) + return + } val senderId = data["suid"] ?: "" val senderName = data["userName"] ?: data["title"] ?: "MAX" val chatTitle = data["title"] ?: senderName @@ -81,7 +88,6 @@ class KometNotifier(private val ctx: Context) { val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull() ?: System.currentTimeMillis() val isGroup = chatTitle != senderName - val notifId = (chatId and 0x7fffffff).toInt() ensureChannel() @@ -135,12 +141,11 @@ class KometNotifier(private val ctx: Context) { } manager().notify(notifId, builder.build()) - updateSummary(notifId, chatId, senderName, text, ts, active) + updateSummary(notifId, senderName, text, ts, active) } private fun updateSummary( notifId: Int, - chatId: Long, senderName: String, text: String, ts: Long, @@ -153,27 +158,50 @@ class KometNotifier(private val ctx: Context) { val k = keys.next() val id = k.toIntOrNull() ?: continue if (id == notifId) continue - if (activeBefore.contains(id)) kept.put(k, reg.getJSONObject(k)) + val entry = reg.optJSONObject(k) ?: continue + if (activeBefore.contains(id)) kept.put(k, entry) } kept.put( notifId.toString(), JSONObject().put("n", senderName).put("t", text).put("ts", ts), ) saveRegistry(kept) + publishSummary(entriesOf(kept)) + } - if (kept.length() < 2) { - manager().cancel(SUMMARY_ID) - return + private fun rebuildSummary(dismissedId: Int) { + val active = activeIds() + val reg = loadRegistry() + val kept = JSONObject() + val keys = reg.keys() + while (keys.hasNext()) { + val k = keys.next() + val id = k.toIntOrNull() ?: continue + if (id == dismissedId) continue + val entry = reg.optJSONObject(k) ?: continue + if (active.contains(id)) kept.put(k, entry) } + saveRegistry(kept) + publishSummary(entriesOf(kept)) + } + private fun entriesOf(reg: JSONObject): List> { val entries = ArrayList>() - val kk = kept.keys() - while (kk.hasNext()) { - val k = kk.next() - val o = kept.getJSONObject(k) + val keys = reg.keys() + while (keys.hasNext()) { + val o = reg.optJSONObject(keys.next()) ?: continue entries.add(Triple(o.optString("n"), o.optString("t"), o.optLong("ts"))) } entries.sortByDescending { it.third } + return entries + } + + private fun publishSummary(entries: List>) { + if (entries.size < 2) { + manager().cancel(SUMMARY_ID) + return + } + val newest = entries.first() val inbox = NotificationCompat.InboxStyle() for (e in entries.take(6)) inbox.addLine(boldLine(e.first, e.second)) @@ -185,12 +213,12 @@ class KometNotifier(private val ctx: Context) { .setGroup(GROUP_KEY) .setGroupSummary(true) .setAutoCancel(true) - .setWhen(ts) + .setWhen(newest.third) .setShowWhen(true) .setNumber(entries.size) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setContentTitle("Komet") - .setContentText(boldLine(senderName, text)) + .setContentText(boldLine(newest.first, newest.second)) .setStyle(inbox) .build() manager().notify(SUMMARY_ID, summary) @@ -240,11 +268,7 @@ class KometNotifier(private val ctx: Context) { private fun publishShortcut(id: String, chatId: Long, title: String, person: Person) { try { - val intent = (ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) - ?: Intent(Intent.ACTION_VIEW)).apply { - action = Intent.ACTION_VIEW - putExtra("komet_chat", chatId) - } + val intent = chatIntent(chatId).setAction(Intent.ACTION_VIEW) val shortcut = ShortcutInfoCompat.Builder(ctx, id) .setShortLabel(title) .setLongLived(true) @@ -260,12 +284,26 @@ class KometNotifier(private val ctx: Context) { } } - private fun openIntent(notifId: Int, chatId: Long): PendingIntent? { - val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) ?: return null - launch.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - launch.putExtra("komet_chat", chatId) + private fun chatIntent(chatId: Long): Intent { + val launcher = ctx.packageManager + .getLaunchIntentForPackage(ctx.packageName)?.component + val intent = if (launcher != null) { + Intent().setComponent(launcher) + } else { + Intent(ctx, MainActivity::class.java) + } + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ) + intent.putExtra(ChatNotifications.EXTRA_CHAT, chatId) + return intent + } + + private fun openIntent(notifId: Int, chatId: Long): PendingIntent { val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - return PendingIntent.getActivity(ctx, notifId, launch, flags) + return PendingIntent.getActivity(ctx, notifId, chatIntent(chatId), flags) } private fun activeIds(): Set = try { diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 5650c7c..11af736 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -76,6 +76,7 @@ class MainActivity : FlutterActivity() { @Volatile private var exchangingEmitted = false private var pendingCall: Map? = null + private var pendingChat: Long = 0L private companion object { const val LOG_TAG = "VpnBypass" @@ -401,12 +402,49 @@ class MainActivity : FlutterActivity() { CallEvents.sink = null } }) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/notifications", + ).setMethodCallHandler { call, result -> + when (call.method) { + "consumeInitialChat" -> { + stashChatOpen(intent, emit = false) + val chatId = pendingChat + pendingChat = 0L + result.success(if (chatId > 0L) chatId else null) + } + "setActiveChat" -> { + ChatNotifications.activeChatId = longArg(call.argument("chatId")) + result.success(null) + } + "clearActiveChat" -> { + ChatNotifications.activeChatId = 0L + result.success(null) + } + else -> result.notImplemented() + } + } + + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/notification_events", + ).setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ChatNotifications.sink = events + } + + override fun onCancel(arguments: Any?) { + ChatNotifications.sink = null + } + }) } override fun onCreate(savedInstanceState: Bundle?) { if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags() super.onCreate(savedInstanceState) intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) } + stashChatOpen(intent, emit = false) } override fun onNewIntent(intent: Intent) { @@ -416,6 +454,19 @@ class MainActivity : FlutterActivity() { applyCallWindowFlags() stashCall(intent, emit = true) } + stashChatOpen(intent, emit = true) + } + + private fun stashChatOpen(source: Intent?, emit: Boolean) { + val chatId = ChatNotifications.chatIdFrom(source) + if (chatId == 0L) return + source?.removeExtra(ChatNotifications.EXTRA_CHAT) + val sink = ChatNotifications.sink + if (emit && sink != null) { + sink.success(chatId) + } else { + pendingChat = chatId + } } private fun stashCall(intent: Intent, emit: Boolean) { @@ -793,6 +844,7 @@ class MainActivity : FlutterActivity() { override fun onResume() { super.onResume() AppState.resumed = true + stashChatOpen(intent, emit = true) } override fun onPause() { diff --git a/lib/backend/modules/chat_parsing.dart b/lib/backend/modules/chat_parsing.dart index 9cda63f..0cdcd55 100644 --- a/lib/backend/modules/chat_parsing.dart +++ b/lib/backend/modules/chat_parsing.dart @@ -40,6 +40,11 @@ CachedChat? parseChatRow( existing, ); final lastMessage = _resolveLastMessage(chat['lastMessage']); + final previous = existing[id]; + final sameLastMessage = + previous != null && + lastMessage.id != null && + previous.lastMsgId == lastMessage.id; final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing); final presence = _resolvePresence(type, otherId, presenceMap); final adminsOwner = _resolveAdmins(chat); @@ -59,7 +64,10 @@ CachedChat? parseChatRow( lastMsgText: lastMessage.text, lastMsgElements: lastMessage.elements, lastMsgPreview: lastMessage.preview, - lastMsgSenderId: lastMessage.senderId, + lastMsgSenderId: + lastMessage.senderId ?? + (sameLastMessage ? previous.lastMsgSenderId : null), + lastMsgStatus: sameLastMessage ? previous.lastMsgStatus : null, unreadCount: (chat['newMessages'] as int?) ?? 0, lastEventTime: (chat['lastEventTime'] as int?) ?? 0, cachedAt: cachedAt, @@ -146,15 +154,22 @@ _resolveLastMessage(dynamic lastMsg) { ); } return ( - id: lastMsg['id'] as int?, - time: lastMsg['time'] as int?, + id: _asIntOrNull(lastMsg['id']), + time: _asIntOrNull(lastMsg['time']), text: messagePreviewText(lastMsg), elements: messagePreviewElements(lastMsg), preview: messagePreviewMedia(lastMsg), - senderId: lastMsg['sender'] as int?, + senderId: _asIntOrNull(lastMsg['sender']), ); } +int? _asIntOrNull(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + ({int? id, String? text, int? time, bool isPreview}) _resolvePinnedMessage( dynamic pinned, ) { diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 7239889..a03cf06 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1388,11 +1388,16 @@ class ChatsModule { } } + final Set _repairedSenders = {}; + Future> getChats( int accountId, { bool includeHidden = false, }) async { try { + if (_repairedSenders.add(accountId)) { + await AppDatabase.repairLastMessageSenders(accountId); + } final rows = await AppDatabase.loadChats( accountId, includeHidden: includeHidden, diff --git a/lib/backend/modules/stories.dart b/lib/backend/modules/stories.dart index b08963a..18d321a 100644 --- a/lib/backend/modules/stories.dart +++ b/lib/backend/modules/stories.dart @@ -396,44 +396,6 @@ class StoriesModule { unawaited(_persistPreviews()); } - /// Поставить ([reaction] != null) или снять (null) реакцию на историю. - Future react( - StoryOwner owner, - int storyId, - StoryReaction? reaction, - ) async { - if (_api.state != SessionState.online) return false; - try { - final ok = await _api.sendRequestOk(Opcode.storiesReact, { - 'owner': owner.toMap(), - 'storyId': storyId, - if (reaction != null) 'reaction': reaction.toMap(), - }); - if (ok) _applyReactionLocally(owner.ownerId, storyId, reaction); - return ok; - } catch (e) { - logger.w('StoriesModule.react: $e'); - return false; - } - } - - void _applyReactionLocally( - int ownerId, - int storyId, - StoryReaction? reaction, - ) { - final stories = _peerStories[ownerId]; - if (stories == null) return; - final idx = stories.indexWhere((s) => s.id == storyId); - if (idx < 0) return; - stories[idx] = stories[idx].copyWith( - reaction: reaction, - clearReaction: reaction == null, - ); - _bump(); - unawaited(_persistPeers()); - } - /// Публикация фото-истории. [photoToken] — токен уже загруженного фото. /// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, мс. /// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart index 140ef62..cde97b0 100644 --- a/lib/core/cache/info_cache.dart +++ b/lib/core/cache/info_cache.dart @@ -112,7 +112,10 @@ class ContactInfoFetch { static void clear() => _cache.clear(); static void putContact(int id, Map contact) { - _cache.putValue(id, ContactInfo.fromMap(Map.from(contact))); + _cache.putValue( + id, + ContactInfo.fromMap(Map.from(contact)), + ); } static Future> getMany( @@ -243,19 +246,43 @@ class PresenceFetch { if (missing.isNotEmpty) { final fetched = await _fetchBatch(missing); final now = DateTime.now(); + var changed = false; for (final id in missing) { final value = fetched[id]; if (value != null) { _cache.putValue(id, value, at: now); + _live[id] = value; result[id] = value; + changed = true; } else { _cache.markFailed(id, at: now); } } + if (changed) revision.value++; } return result; } + static const _batchSize = 100; + + static Future ensureFor(Iterable ids) async { + final wanted = {}; + for (final id in ids) { + if (id <= 0) continue; + if (_cache.peek(id) != null) continue; + wanted.add(id); + } + if (wanted.isEmpty) return; + final list = wanted.toList(); + for (var i = 0; i < list.length; i += _batchSize) { + final chunk = list.sublist( + i, + i + _batchSize > list.length ? list.length : i + _batchSize, + ); + await getMany(chunk); + } + } + static Future>> _fetchBatch( List ids, ) async { diff --git a/lib/core/push/notification_bridge.dart b/lib/core/push/notification_bridge.dart new file mode 100644 index 0000000..0f506d4 --- /dev/null +++ b/lib/core/push/notification_bridge.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../../backend/api.dart'; +import '../../frontend/widgets/max_link_nav.dart'; +import '../../main.dart'; +import '../utils/logger.dart'; + +class NotificationBridge { + NotificationBridge._(); + static final NotificationBridge instance = NotificationBridge._(); + + static const _method = MethodChannel('ru.komet.app/notifications'); + static const _events = EventChannel('ru.komet.app/notification_events'); + static const _retryDelay = Duration(milliseconds: 300); + static const _maxRetries = 100; + + bool _started = false; + bool _ready = false; + int _pendingChatId = 0; + int _activeChatId = 0; + int _retriesLeft = 0; + Timer? _retry; + + bool get _android { + try { + return Platform.isAndroid; + } catch (_) { + return false; + } + } + + void init() { + if (_started || !_android) return; + _started = true; + _events.receiveBroadcastStream().listen( + _onEvent, + onError: (e) => logger.w('NotificationBridge: events stream error: $e'), + ); + api.stateStream.listen((state) { + if (state == SessionState.online) _flushPending(); + }); + } + + void markReady() { + _ready = true; + _flushPending(); + } + + Future checkInitialChat() async { + if (!_android) return; + try { + _onEvent(await _method.invokeMethod('consumeInitialChat')); + } catch (e) { + logger.w('NotificationBridge.checkInitialChat: $e'); + } + } + + Future setActiveChat(int chatId) async { + if (!_android || chatId <= 0) return; + if (_activeChatId == chatId) return; + _activeChatId = chatId; + try { + await _method.invokeMethod('setActiveChat', {'chatId': chatId}); + } catch (e) { + logger.w('NotificationBridge.setActiveChat: $e'); + } + } + + Future clearActiveChat(int chatId) async { + if (!_android) return; + if (chatId > 0 && _activeChatId != chatId) return; + _activeChatId = 0; + try { + await _method.invokeMethod('clearActiveChat'); + } catch (e) { + logger.w('NotificationBridge.clearActiveChat: $e'); + } + } + + void _onEvent(Object? event) { + final chatId = event is int ? event : int.tryParse(event?.toString() ?? ''); + if (chatId == null || chatId <= 0) return; + _pendingChatId = chatId; + _retriesLeft = _maxRetries; + _flushPending(); + } + + void _flushPending() { + final chatId = _pendingChatId; + if (chatId <= 0) return; + + final context = KometApp.navigatorKey.currentContext; + if (!_ready || context == null || api.state != SessionState.online) { + if (_retriesLeft <= 0) { + _pendingChatId = 0; + return; + } + _retriesLeft--; + _retry ??= Timer(_retryDelay, () { + _retry = null; + _flushPending(); + }); + return; + } + + _pendingChatId = 0; + if (_activeChatId == chatId) return; + unawaited(openChatById(context, chatId)); + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 42ef5e5..658fa2d 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -847,6 +847,25 @@ class AppDatabase { } } + static Future repairLastMessageSenders(int accountId) async { + try { + final db = await _instance; + await db.rawUpdate( + 'UPDATE chats_cache SET last_msg_sender = (' + ' SELECT m.sender_id FROM messages m' + ' WHERE m.account_id = chats_cache.account_id' + ' AND m.chat_id = chats_cache.id' + ' AND m.id = CAST(chats_cache.last_msg_id AS TEXT)' + ') ' + 'WHERE account_id = ? AND last_msg_sender IS NULL ' + 'AND last_msg_id IS NOT NULL', + [accountId], + ); + } catch (e) { + logger.w('Не удалось восстановить отправителей последних сообщений: $e'); + } + } + static Future>> loadChat( int accountId, int chatId, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 3f7a15c..e4cbb20 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -52,6 +52,7 @@ import '../../../core/config/app_animations.dart'; import '../../../core/config/app_frost.dart'; import '../../../core/config/app_spectrum_background.dart'; import '../../../core/config/app_nav_pill_style.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_stories.dart'; import '../../../core/config/app_colors.dart'; @@ -933,6 +934,7 @@ class _ChatListScreenState extends State }); _syncShimmer(); _prefetchContactsForChats(loadedChats); + unawaited(_prefetchPresenceForChats(loadedChats)); if (widget.archiveMode) { if (filteredChats.isNotEmpty) { _archiveHadChats = true; @@ -1007,20 +1009,42 @@ class _ChatListScreenState extends State return 0; } - Future _prefetchContactsForChats(List chats) async { + bool _presencePrefetchRunning = false; + + Set _dialogPeerIds(List chats) { final myId = _profile?.id; final ids = {}; for (final chat in chats) { - if (chat.type == 'DIALOG' && chat.id != 0) { - for (final entry in chat.participants.entries) { - if (entry.key != myId) { - ids.add(entry.key); - break; - } + if (chat.type != 'DIALOG' || chat.id == 0) continue; + for (final entry in chat.participants.entries) { + if (entry.key != myId) { + ids.add(entry.key); + break; } } + } + return ids; + } + + Future _prefetchPresenceForChats(List chats) async { + if (_presencePrefetchRunning) return; + if (_sessionState != SessionState.online) return; + final ids = _dialogPeerIds(chats); + if (ids.isEmpty) return; + _presencePrefetchRunning = true; + try { + await PresenceFetch.ensureFor(ids); + } finally { + _presencePrefetchRunning = false; + } + } + + Future _prefetchContactsForChats(List chats) async { + final myId = _profile?.id; + final ids = _dialogPeerIds(chats); + for (final chat in chats) { final senderId = chat.lastMsgSenderId; - if (senderId != null) ids.add(senderId); + if (senderId != null && senderId != myId) ids.add(senderId); } ids.removeWhere((id) => ContactCache.get(id) != null); ids.removeAll(_inflightContactIds); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 0d36585..5bf178b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -41,6 +41,7 @@ import '../../../core/media/rlottie/rlottie.dart'; import '../calls/call_screen.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; +import '../../../core/push/notification_bridge.dart'; import '../../../core/push/push_service.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/chat_activity_store.dart'; @@ -667,6 +668,9 @@ class _ChatScreenState extends State _chatController.isMounted = () => mounted; if (!_commentsMode) ChatScreen._open.add(this); unawaited(PushService.clearChatNotification(widget.chatId)); + if (!_commentsMode) { + unawaited(NotificationBridge.instance.setActiveChat(widget.chatId)); + } unawaited( animojiModule .ensureLoaded() @@ -2093,6 +2097,9 @@ class _ChatScreenState extends State @override void dispose() { ChatScreen._open.remove(this); + if (!_commentsMode) { + unawaited(NotificationBridge.instance.clearActiveChat(widget.chatId)); + } _chatController.persistSessionCache(); if (_previewChat) { unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false)); diff --git a/lib/frontend/screens/stories/story_viewer_screen.dart b/lib/frontend/screens/stories/story_viewer_screen.dart index ef652b4..32331ab 100644 --- a/lib/frontend/screens/stories/story_viewer_screen.dart +++ b/lib/frontend/screens/stories/story_viewer_screen.dart @@ -10,31 +10,16 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; import '../../../core/utils/haptics.dart'; -import '../../../main.dart' show animojiModule, storiesModule; +import '../../../main.dart' show storiesModule; import '../../../models/story.dart'; -import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; -import '../../widgets/liquid_glass.dart'; -import '../../widgets/lottie_image.dart'; import '../../widgets/small_spinner.dart'; import 'story_owner_info.dart'; import '../../../core/config/app_frost.dart'; import '../../../core/config/app_fonts.dart'; -const _quickReactions = ['❤️', '🔥', '😍', '👏', '😂', '😮']; const Duration _photoDuration = Duration(seconds: 5); -class _ReactionItem { - final String emoji; - final String? lottieUrl; - final String? iconUrl; - - const _ReactionItem(this.emoji, {this.lottieUrl, this.iconUrl}); - - bool get animated => - (lottieUrl?.isNotEmpty ?? false) || (iconUrl?.isNotEmpty ?? false); -} - Offset? storyOriginOf(BuildContext context) { final box = context.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return null; @@ -146,13 +131,6 @@ class _StoryViewerScreenState extends State bool _dragging = false; static const double _dismissThreshold = 120; - final List<_Burst> _bursts = []; - int _burstSeq = 0; - - List<_ReactionItem> _reactions = _quickReactions - .map((e) => _ReactionItem(e)) - .toList(); - VideoPlayerController? _video; StoryPreview get _owner => widget.previews[_ownerIndex]; @@ -176,23 +154,6 @@ class _StoryViewerScreenState extends State if (s == AnimationStatus.completed) _advance(); }); _loadOwner(_ownerIndex, autostart: true); - unawaited(_loadReactions()); - } - - Future _loadReactions() async { - try { - await animojiModule.ensureLoaded(); - } catch (_) { - return; - } - final quick = animojiModule.quickAnimojis; - if (!mounted || quick.isEmpty) return; - setState(() { - _reactions = [ - for (final a in quick) - _ReactionItem(a.emoji, lottieUrl: a.lottieUrl, iconUrl: a.iconUrl), - ]; - }); } @override @@ -360,41 +321,6 @@ class _StoryViewerScreenState extends State } } - void _spawnBurst(_ReactionItem item, Alignment from) { - final id = _burstSeq++; - setState(() => _bursts.add(_Burst(id, item, from))); - } - - void _removeBurst(int id) { - if (!mounted) return; - setState(() => _bursts.removeWhere((b) => b.id == id)); - } - - Future _toggleReaction(_ReactionItem item) async { - final story = _currentStory; - if (story == null || story.id == 0) return; - final isSame = story.reaction?.id == item.emoji; - if (!isSame) { - Haptics.medium(); - _spawnBurst(item, const Alignment(0, 0.55)); - final animoji = animojiModule.findByEmoji(item.emoji); - if (animoji != null) unawaited(animojiModule.noteUsed(animoji)); - } else { - Haptics.tap(); - } - final ok = await storiesModule.react( - story.owner, - story.id, - isSame ? null : StoryReaction(id: item.emoji), - ); - if (!mounted) return; - if (ok) { - setState(() {}); - } else { - showCustomNotification(context, 'Не удалось отправить реакцию'); - } - } - void _onDragStart(DragStartDetails _) { _dragging = true; _setPaused(true); @@ -479,13 +405,6 @@ class _StoryViewerScreenState extends State ); }, ), - for (final burst in _bursts) - _FloatingReaction( - key: ValueKey(burst.id), - item: burst.item, - alignment: burst.from, - onDone: () => _removeBurst(burst.id), - ), ], ), ); @@ -545,7 +464,6 @@ class _StoryViewerScreenState extends State _buildProgressBars(stories.length), _buildHeader(), const Spacer(), - if (story != null) _buildReactionBar(story), ], ), ), @@ -649,51 +567,6 @@ class _StoryViewerScreenState extends State ), ); } - - Widget _buildReactionBar(Story story) { - final current = story.reaction?.id; - return Container( - padding: const EdgeInsets.only(bottom: 6), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [Colors.black54, Colors.transparent], - ), - ), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Center( - child: GlassSurface( - borderRadius: BorderRadius.circular(30), - frostTint: Colors.white.withValues(alpha: 0.12), - frostSigma: 14, - border: Border.all(color: Colors.white.withValues(alpha: 0.18)), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final item in _reactions) - _ReactionButton( - item: item, - selected: current == item.emoji, - onTap: () => _toggleReaction(item), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } } // ─── Cube (3D fold) page transform ──────────────────────────────────────── @@ -796,77 +669,6 @@ class _SegmentBar extends StatelessWidget { } } -// ─── Reaction emoji button ──────────────────────────────────────────────── -class _ReactionButton extends StatefulWidget { - final _ReactionItem item; - final bool selected; - final VoidCallback onTap; - - const _ReactionButton({ - required this.item, - required this.selected, - required this.onTap, - }); - - @override - State<_ReactionButton> createState() => _ReactionButtonState(); -} - -class _ReactionButtonState extends State<_ReactionButton> - with SingleTickerProviderStateMixin { - late final AnimationController _c = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 260), - lowerBound: 0.0, - upperBound: 1.0, - value: 1.0, - ); - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - void _onTap() { - _c.forward(from: 0.0); - widget.onTap(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: _onTap, - behavior: HitTestBehavior.opaque, - child: AnimatedBuilder( - animation: _c, - builder: (context, _) { - final pop = 1.0 + math.sin(_c.value * math.pi) * 0.4; - final scale = (widget.selected ? 1.15 : 1.0) * pop; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Transform.scale( - scale: scale, - child: widget.item.animated - ? LottieImage( - lottieUrl: widget.item.lottieUrl, - url: widget.item.iconUrl, - size: 30, - shimmer: false, - memCacheWidth: 90, - ) - : Text( - widget.item.emoji, - style: const TextStyle(fontSize: 28), - ), - ), - ); - }, - ), - ); - } -} - // ─── Round icon button (close) ──────────────────────────────────────────── class _RoundIconButton extends StatelessWidget { final IconData icon; @@ -919,93 +721,6 @@ class _TopScrim extends StatelessWidget { } } -// ─── Floating reaction burst ────────────────────────────────────────────── -class _Burst { - final int id; - final _ReactionItem item; - final Alignment from; - const _Burst(this.id, this.item, this.from); -} - -class _FloatingReaction extends StatefulWidget { - final _ReactionItem item; - final Alignment alignment; - final VoidCallback onDone; - - const _FloatingReaction({ - super.key, - required this.item, - required this.alignment, - required this.onDone, - }); - - @override - State<_FloatingReaction> createState() => _FloatingReactionState(); -} - -class _FloatingReactionState extends State<_FloatingReaction> - with SingleTickerProviderStateMixin { - late final AnimationController _c = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - late final double _drift = (widget.item.emoji.hashCode % 40 - 20).toDouble(); - - @override - void initState() { - super.initState(); - _c.forward().whenComplete(widget.onDone); - } - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: AnimatedBuilder( - animation: _c, - builder: (context, _) { - final t = _c.value; - final rise = -160.0 * Curves.easeOut.transform(t); - final scale = t < 0.3 - ? Curves.easeOutBack.transform(t / 0.3) * 1.2 - : 1.2 - 0.2 * ((t - 0.3) / 0.7); - final opacity = t < 0.7 ? 1.0 : 1.0 - (t - 0.7) / 0.3; - return Align( - alignment: widget.alignment, - child: Transform.translate( - offset: Offset(_drift * t, rise), - child: Opacity( - opacity: opacity.clamp(0.0, 1.0), - child: Transform.scale( - scale: scale, - child: widget.item.animated - ? LottieImage( - lottieUrl: widget.item.lottieUrl, - url: widget.item.iconUrl, - size: 64, - shimmer: false, - repeat: false, - memCacheWidth: 192, - ) - : Text( - widget.item.emoji, - style: const TextStyle(fontSize: 64), - ), - ), - ), - ), - ); - }, - ), - ); - } -} - String _timeAgo(int epochTime) { final ms = epochTime < 1000000000000 ? epochTime * 1000 : epochTime; final diff = (DateTime.now().millisecondsSinceEpoch - ms) ~/ 1000; diff --git a/lib/main.dart b/lib/main.dart index ccf1086..baa4eaf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,6 +73,7 @@ import 'core/calls/call_bridge.dart'; import 'core/calls/call_controller.dart'; import 'core/links/deep_link_service.dart'; import 'frontend/screens/calls/call_screen.dart'; +import 'core/push/notification_bridge.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -419,6 +420,7 @@ class KometAppState extends State _loginStatusSub = accountModule.loginStatusStream.listen((status) async { if (status == LoginStatus.success) { DeepLinkService.instance.markReady(); + NotificationBridge.instance.markReady(); unawaited(_refreshWallpaperSeed()); CallController.instance.init(api); OutboxService.instance.init(api, messagesModule); @@ -437,8 +439,10 @@ class KometAppState extends State ); CallController.instance.appResumed = true; CallBridge.instance.init(); + NotificationBridge.instance.init(); WidgetsBinding.instance.addPostFrameCallback((_) { CallBridge.instance.checkInitialCall(); + unawaited(NotificationBridge.instance.checkInitialChat()); }); _sessionExpiredSub = api.sessionExpiredStream.listen(( @@ -609,6 +613,7 @@ class KometAppState extends State api.wakeUp(); SelfCheckService.instance.resume(); CallBridge.instance.checkInitialCall(); + unawaited(NotificationBridge.instance.checkInitialChat()); if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return; _rescheduleSwitch(); final next = _effectiveThemeMode; diff --git a/test/chat_row_last_message_test.dart b/test/chat_row_last_message_test.dart new file mode 100644 index 0000000..764c2d4 --- /dev/null +++ b/test/chat_row_last_message_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/chat_parsing.dart'; +import 'package:komet/backend/modules/chats.dart'; + +const int _me = 501; +const int _peer = 777; +const int _chatId = 4242; +const int _lastMsgId = 9000000; + +CachedChat _cached({int? senderId, String? status, int? lastMsgId}) => + CachedChat( + id: _chatId, + accountId: _me, + type: 'DIALOG', + title: 'Диалог', + lastMsgId: lastMsgId ?? _lastMsgId, + lastMsgTime: 1700000000000, + lastMsgText: 'привет', + lastMsgSenderId: senderId, + lastMsgStatus: status, + unreadCount: 0, + lastEventTime: 1700000000000, + cachedAt: 0, + dontDisturbUntil: ChatsModule.muteOff, + isOnline: false, + seenTime: 0, + participants: {_me: 1700000000000, _peer: 0}, + ); + +Map _serverChat({Object? sender}) => { + 'id': _chatId, + 'type': 'DIALOG', + 'participants': {'$_me': 1700000000000, '$_peer': 0}, + 'lastMessage': { + 'id': _lastMsgId, + 'time': 1700000000000, + 'text': 'привет', + if (sender != null) 'sender': sender, + }, +}; + +CachedChat _parse( + Map chat, { + Map existing = const {}, +}) { + final parsed = parseChatRow( + chat, + _me, + _me, + const {}, + const {}, + const {}, + existing, + 0, + ); + expect(parsed, isNotNull); + return parsed!; +} + +void main() { + group('разбор чата из ответа сервера', () { + test('отправитель последнего сообщения берётся из payload', () { + final parsed = _parse(_serverChat(sender: _me)); + expect(parsed.lastMsgSenderId, _me); + }); + + test('отправитель читается и когда сервер прислал его строкой', () { + final parsed = _parse(_serverChat(sender: '$_me')); + expect(parsed.lastMsgSenderId, _me); + expect(parsed.lastMsgId, _lastMsgId); + }); + + test('без sender в payload отправитель берётся из кэша', () { + final parsed = _parse( + _serverChat(), + existing: {_chatId: _cached(senderId: _me, status: 'read')}, + ); + expect(parsed.lastMsgSenderId, _me); + expect(parsed.lastMsgStatus, 'read'); + }); + + test('на новом последнем сообщении кэш не подмешивается', () { + final parsed = _parse( + _serverChat(sender: _peer), + existing: { + _chatId: _cached( + senderId: _me, + status: 'read', + lastMsgId: _lastMsgId - 100, + ), + }, + ); + expect(parsed.lastMsgSenderId, _peer); + expect(parsed.lastMsgStatus, isNull); + }); + }); +}