diff --git a/lib/backend/modules/banners.dart b/lib/backend/modules/banners.dart index 3aa5b1e..cc81d7e 100644 --- a/lib/backend/modules/banners.dart +++ b/lib/backend/modules/banners.dart @@ -120,6 +120,7 @@ class BannersModule { } Future markShown(InformerBanner banner) async { + if (_pinnedId == banner.id) return; final now = DateTime.now().millisecondsSinceEpoch; final current = stateOf(banner.id); _showState[banner.id] = current.copyWith( diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 7add578..39b915e 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1161,12 +1161,14 @@ class ChatsModule { final cachedAt = DateTime.now().millisecondsSinceEpoch; final id = chat['id']; Map existing = const {}; + Map? existingRow; if (preloadedExisting != null) { existing = preloadedExisting; } else if (id is int) { final rows = await AppDatabase.loadChat(accountId, id); if (rows.isNotEmpty) { - existing = {id: CachedChat.fromDbRow(rows.first)}; + existingRow = rows.first; + existing = {id: CachedChat.fromDbRow(existingRow)}; } } final parsed = parseChatRow( @@ -1184,11 +1186,14 @@ class ChatsModule { return null; } final ex = existing[parsed.id]; - if (ex != null && sameChatContent(ex, parsed)) { + final listState = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1); + final membershipUnchanged = + existingRow == null || existingRow['in_list'] == listState; + if (ex != null && sameChatContent(ex, parsed) && membershipUnchanged) { return parsed; } final row = parsed.toDbRow(); - row['in_list'] = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1); + row['in_list'] = listState; await AppDatabase.saveChats([row]); _bump(); return parsed; diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 333c22d..574d7ab 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -727,6 +727,16 @@ class AppDatabase { ); } + static bool chatRowIsInList(Map row) { + final value = row['in_list']; + return value is! int || value != 0; + } + + static Future isChatInList(int accountId, int chatId) async { + final rows = await loadChat(accountId, chatId); + return rows.isNotEmpty && chatRowIsInList(rows.first); + } + static Future>> loadChats( int accountId, { bool includeHidden = false, diff --git a/lib/frontend/screens/chats/chat/chat_controller.dart b/lib/frontend/screens/chats/chat/chat_controller.dart index 6b495bc..f920e34 100644 --- a/lib/frontend/screens/chats/chat/chat_controller.dart +++ b/lib/frontend/screens/chats/chat/chat_controller.dart @@ -348,6 +348,17 @@ class ChatController extends ChangeNotifier { required void Function() onSenderNames, }) async { final onlyVisible = !KometSettings.viewDeleted.value; + final cachedRows = await AppDatabase.loadChat(myId, chatId); + final preview = + cachedRows.isEmpty || !AppDatabase.chatRowIsInList(cachedRows.first); + if (preview) { + onPreview(); + if (cachedRows.isEmpty) { + await chats.ensureChatCached(api, myId, chatId); + } + await chats.subscribeChat(api, chatId); + } + final fullDecoded = await loadInitialFromDb(onlyVisible: onlyVisible); if (isMounted()) { onApplyMerged(fullDecoded); @@ -362,12 +373,6 @@ class ChatController extends ChangeNotifier { } try { - final cachedRows = await AppDatabase.loadChat(myId, chatId); - if (cachedRows.isEmpty) { - onPreview(); - await chats.ensureChatCached(api, myId, chatId); - await chats.subscribeChat(api, chatId); - } final serverMessages = await messagesModule.fetchHistory(myId, chatId); chats.markHistoryFetched(chatId); if (KometSettings.viewDeleted.value) { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 6d72a0d..fd937e6 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -23,10 +23,14 @@ import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; import '../../widgets/springy_tap.dart'; import '../../widgets/formatted_message_text.dart'; +import '../../widgets/informer_banner_tile.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/download_history.dart'; +import '../../../core/utils/link_opener.dart'; import '../../../core/utils/text_format.dart'; +import '../../../core/utils/update_checker.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../models/informer_banner.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -61,8 +65,16 @@ import '../../../core/storage/chat_encryption_store.dart'; import '../../../core/storage/token_storage.dart'; import '../../../core/storage/chat_activity_store.dart'; import '../../../main.dart' - show accountModule, api, messagesModule, storiesModule, appRouteObserver; + show + accountModule, + animojiModule, + api, + appRouteObserver, + bannersModule, + messagesModule, + storiesModule; import '../../widgets/attachment/attachment_sheet.dart'; +import '../../widgets/update_dialog.dart'; import '../stories/story_composer_screen.dart'; import '../stories/story_owner_info.dart'; import '../stories/story_ring.dart'; @@ -228,6 +240,7 @@ class _ChatListScreenState extends State StreamSubscription? _loginSub; StreamSubscription? _typingSub; StreamSubscription? _typingMsgSub; + String? _presentedInformerId; Widget? _cachedChatsBody; Object? _chatsBodyCacheKey; @@ -593,6 +606,7 @@ class _ChatListScreenState extends State KometSettings.hideAllChatsFolder.addListener(_requestReload); KometSettings.showHiddenChats.addListener(_requestReload); ContactsModule.revision.addListener(_requestReload); + bannersModule.activeBanner.addListener(_onActiveInformerChanged); _maybeLoadStories(); _typingSub = api.pushStream .where((p) => p.opcode == Opcode.notifTyping) @@ -719,6 +733,7 @@ class _ChatListScreenState extends State unawaited(_runReload()); } }); + _scheduleInformerPresentation(); } void _requestReload() { @@ -1244,6 +1259,7 @@ class _ChatListScreenState extends State KometSettings.hideAllChatsFolder.removeListener(_requestReload); KometSettings.showHiddenChats.removeListener(_requestReload); ContactsModule.revision.removeListener(_requestReload); + bannersModule.activeBanner.removeListener(_onActiveInformerChanged); _loginSub?.cancel(); _stateSub?.cancel(); _typingSub?.cancel(); @@ -1302,6 +1318,7 @@ class _ChatListScreenState extends State _navPageAnimEnd = index.toDouble(); setState(() => _currentNavIndex = index); _navPageAnimController.forward(from: 0); + if (index == 0) _scheduleInformerPresentation(); } void _toggleFab() { @@ -1316,6 +1333,115 @@ class _ChatListScreenState extends State }); } + void _markInformerPresented(InformerBanner banner) { + if (widget.forwardMode || widget.archiveMode || _currentNavIndex != 0) { + return; + } + final route = ModalRoute.of(context); + if (route != null && !route.isCurrent) return; + if (_presentedInformerId == banner.id) return; + if (bannersModule.activeBanner.value?.id != banner.id) return; + _presentedInformerId = banner.id; + unawaited(_persistInformerPresentation(banner)); + } + + Future _persistInformerPresentation(InformerBanner banner) async { + try { + await bannersModule.markShown(banner); + } catch (_) {} + } + + void _onActiveInformerChanged() { + if (bannersModule.activeBanner.value == null) { + _presentedInformerId = null; + return; + } + _scheduleInformerPresentation(); + } + + void _scheduleInformerPresentation() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final banner = bannersModule.activeBanner.value; + if (banner != null) _markInformerPresented(banner); + }); + } + + Future _closeInformer(InformerBanner banner) async { + Haptics.tap(); + try { + await bannersModule.close(banner); + } catch (_) { + bannersModule.refresh(); + } + } + + Future _openInformer(InformerBanner banner) async { + Haptics.tap(); + try { + await bannersModule.markClicked(banner); + } catch (_) { + bannersModule.refresh(); + } + if (!mounted) return; + + final url = banner.url?.trim(); + if (url != null && url.isNotEmpty) { + await openExternalUrl(context, url); + return; + } + if (!banner.isUpdate) return; + + final result = await UpdateChecker.checkNow(); + if (!mounted) return; + switch (result.status) { + case UpdateCheckStatus.updateAvailable: + await showUpdateDialog(context, result.update!); + return; + case UpdateCheckStatus.upToDate: + showCustomNotification( + context, + AppLocalizations.of(context)!.updateUpToDate, + ); + return; + case UpdateCheckStatus.failed: + showCustomNotification( + context, + AppLocalizations.of(context)!.updateCheckFailed, + ); + return; + } + } + + Widget _buildInformerBanner() { + return ValueListenableBuilder( + valueListenable: bannersModule.activeBanner, + builder: (context, banner, _) { + return ClipRect( + child: AnimatedSize( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: banner == null + ? const SizedBox(width: double.infinity) + : InformerBannerTile( + key: ValueKey(banner.id), + banner: banner, + animojiLoader: animojiModule.fetchById, + onPresented: _markInformerPresented, + onTap: banner.isClickable + ? () => unawaited(_openInformer(banner)) + : null, + onClose: banner.hidesCloseButton + ? null + : () => unawaited(_closeInformer(banner)), + ), + ), + ); + }, + ); + } + Widget _buildPinnedChatsHeader(BuildContext context) { final cs = Theme.of(context).colorScheme; return ColoredBox( @@ -1562,6 +1688,7 @@ class _ChatListScreenState extends State ), ), ), + if (!widget.forwardMode) _buildInformerBanner(), ], ), ); @@ -1898,6 +2025,7 @@ class _ChatListScreenState extends State _currentNavIndex = next; _navDragging = false; }); + if (next == 0) _scheduleInformerPresentation(); }, onHorizontalDragCancel: () { if (!_navDragging) return; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index ea7083d..77fc562 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -215,6 +215,7 @@ class ChatScreen extends StatefulWidget { final String name; final String imageUrl; final String chatType; + final bool? channelSubscribed; final bool embedded; final VoidCallback? onClose; final ForwardRequest? forwardRequest; @@ -230,6 +231,7 @@ class ChatScreen extends StatefulWidget { required this.name, required this.imageUrl, required this.chatType, + this.channelSubscribed, this.embedded = false, this.onClose, this.forwardRequest, @@ -591,6 +593,7 @@ class _ChatScreenState extends State @override void initState() { super.initState(); + _previewChat = widget.channelSubscribed == false; _chatController.chatId = widget.chatId; _chatController.isMounted = () => mounted; unawaited(PushService.clearChatNotification(widget.chatId)); @@ -785,8 +788,15 @@ class _ChatScreenState extends State final chatRows = await chats.getChat(_myId, widget.chatId); if (!mounted) return; if (chatRows.isNotEmpty) { + final channelSubscribed = + widget.chatType != 'CHANNEL' || + await AppDatabase.isChatInList(_myId, widget.chatId); + if (!mounted) return; setState(() { chat = chatRows.first; + if (widget.chatType == 'CHANNEL') { + _previewChat = !channelSubscribed; + } }); _bumpMessages(); _seedPresenceFromChat(); diff --git a/lib/frontend/widgets/informer_banner_tile.dart b/lib/frontend/widgets/informer_banner_tile.dart new file mode 100644 index 0000000..ffd5eb1 --- /dev/null +++ b/lib/frontend/widgets/informer_banner_tile.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../models/animoji.dart'; +import '../../models/informer_banner.dart'; +import 'lottie_image.dart'; + +typedef InformerAnimojiLoader = Future Function(int id); + +class InformerBannerTile extends StatefulWidget { + final InformerBanner banner; + final InformerAnimojiLoader? animojiLoader; + final ValueChanged? onPresented; + final VoidCallback? onTap; + final VoidCallback? onClose; + + const InformerBannerTile({ + super.key, + required this.banner, + this.animojiLoader, + this.onPresented, + this.onTap, + this.onClose, + }); + + @override + State createState() => _InformerBannerTileState(); +} + +class _InformerBannerTileState extends State + with SingleTickerProviderStateMixin { + Future? _animoji; + late final AnimationController _textController; + late final Animation _textOpacity; + late final Animation _textOffset; + + @override + void initState() { + super.initState(); + _textController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + value: widget.banner.animatesText ? 0 : 1, + ); + final curve = CurvedAnimation( + parent: _textController, + curve: Curves.easeOutCubic, + ); + _textOpacity = curve; + _textOffset = Tween( + begin: const Offset(0.035, 0), + end: Offset.zero, + ).animate(curve); + _loadAnimoji(); + _present(); + if (widget.banner.animatesText) _textController.forward(); + } + + @override + void didUpdateWidget(InformerBannerTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.banner.id == widget.banner.id) return; + _loadAnimoji(); + _textController.value = widget.banner.animatesText ? 0 : 1; + _present(); + if (widget.banner.animatesText) _textController.forward(); + } + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + void _loadAnimoji() { + final id = widget.banner.animojiId; + final loader = widget.animojiLoader; + _animoji = id == null || loader == null ? null : loader(id); + } + + void _present() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onPresented?.call(widget.banner); + }); + } + + @override + Widget build(BuildContext context) { + final banner = widget.banner; + final cs = Theme.of(context).colorScheme; + final background = Color.alphaBlend( + cs.primary.withValues(alpha: 0.12), + cs.surfaceContainerLow, + ); + final content = Semantics( + button: widget.onTap != null, + label: [ + banner.title, + banner.description, + ].where((text) => text.isNotEmpty).join('. '), + child: InkWell( + key: ValueKey('informer-banner-${banner.id}'), + onTap: widget.onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 66), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 9, 8, 9), + child: Row( + children: [ + _InformerBannerIcon( + future: _animoji, + tintWithTheme: banner.tintsIconWithTheme, + ), + const SizedBox(width: 14), + Expanded( + child: FadeTransition( + opacity: _textOpacity, + child: SlideTransition( + position: _textOffset, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (banner.title.isNotEmpty) + Text( + banner.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.primary, + fontSize: 14.5, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + if (banner.title.isNotEmpty && + banner.description.isNotEmpty) + const SizedBox(height: 3), + if (banner.description.isNotEmpty) + Text( + banner.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.2, + ), + ), + ], + ), + ), + ), + ), + if (!banner.hidesCloseButton) + IconButton( + key: ValueKey('informer-banner-close-${banner.id}'), + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + onPressed: widget.onClose, + visualDensity: VisualDensity.compact, + iconSize: 19, + color: cs.onSurfaceVariant.withValues(alpha: 0.72), + icon: const Icon(Symbols.cancel, fill: 0, weight: 450), + ), + ], + ), + ), + ), + ), + ); + return Material(color: background, child: content); + } +} + +class _InformerBannerIcon extends StatelessWidget { + final Future? future; + final bool tintWithTheme; + + const _InformerBannerIcon({ + required this.future, + required this.tintWithTheme, + }); + + @override + Widget build(BuildContext context) { + if (future == null) return const _InformerBannerFallbackIcon(); + return FutureBuilder( + future: future, + builder: (context, snapshot) { + final animoji = snapshot.data; + if (animoji == null) return const _InformerBannerFallbackIcon(); + Widget icon = LottieImage( + url: animoji.iconUrl, + lottieUrl: animoji.lottieUrl, + size: 44, + memCacheWidth: 96, + shimmer: false, + eager: true, + ); + if (tintWithTheme) { + icon = ColorFiltered( + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + child: icon, + ); + } + return SizedBox( + key: const ValueKey('informer-banner-animoji'), + width: 44, + height: 44, + child: icon, + ); + }, + ); + } +} + +class _InformerBannerFallbackIcon extends StatelessWidget { + const _InformerBannerFallbackIcon(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + key: const ValueKey('informer-banner-fallback-icon'), + width: 44, + height: 44, + decoration: BoxDecoration(color: cs.primary, shape: BoxShape.circle), + alignment: Alignment.center, + child: Icon( + Symbols.chat_bubble, + color: cs.onPrimary, + size: 23, + fill: 0, + weight: 500, + ), + ); + } +} diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart index 477cdc9..de3a07c 100644 --- a/lib/frontend/widgets/max_link_handler.dart +++ b/lib/frontend/widgets/max_link_handler.dart @@ -106,11 +106,7 @@ Future _openResolvedChat( final profile = await AppDatabase.loadActiveProfile(); final myId = profile?.id ?? 0; - final participants = chat['participants']; - final isMember = - myId != 0 && - participants is Map && - participants.containsKey(myId.toString()); + var isMember = myId != 0 && await AppDatabase.isChatInList(myId, id); await chats.cacheServerChat(chat, myId, inList: isMember); if (!context.mounted) return; @@ -130,12 +126,20 @@ Future _openResolvedChat( if (context.mounted) showCustomNotification(context, error); return; } + isMember = true; + await chats.cacheServerChat(chat, myId, inList: true); if (!context.mounted) return; } pushSwipeable( context, - (_) => ChatScreen(chatId: id, name: title, imageUrl: icon, chatType: type), + (_) => ChatScreen( + chatId: id, + name: title, + imageUrl: icon, + chatType: type, + channelSubscribed: type == 'CHANNEL' ? isMember : null, + ), ); } diff --git a/test/banners_module_test.dart b/test/banners_module_test.dart new file mode 100644 index 0000000..a059213 --- /dev/null +++ b/test/banners_module_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/api.dart'; +import 'package:komet/backend/modules/banners.dart'; +import 'package:komet/models/informer_banner.dart'; + +void main() { + test('a pinned banner records one presentation', () async { + final api = Api(); + addTearDown(api.dispose); + final module = BannersModule(api); + const banner = InformerBanner( + id: 'synthetic-banner', + title: 'Synthetic title', + repeat: 3, + ); + + await module.markShown(banner); + await module.markShown(banner); + + expect(module.stateOf(banner.id).showCounter, 1); + + await module.close(banner); + await module.markShown(banner); + + expect(module.stateOf(banner.id).showCounter, 2); + }); +} diff --git a/test/chat_membership_test.dart b/test/chat_membership_test.dart new file mode 100644 index 0000000..0b94d31 --- /dev/null +++ b/test/chat_membership_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/storage/app_database.dart'; + +void main() { + test('chat list state distinguishes previews from memberships', () { + expect(AppDatabase.chatRowIsInList({'in_list': 0}), isFalse); + expect(AppDatabase.chatRowIsInList({'in_list': 1}), isTrue); + expect(AppDatabase.chatRowIsInList({'in_list': 2}), isTrue); + expect(AppDatabase.chatRowIsInList({}), isTrue); + }); +} diff --git a/test/informer_banner_tile_test.dart b/test/informer_banner_tile_test.dart new file mode 100644 index 0000000..19e9ddd --- /dev/null +++ b/test/informer_banner_tile_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/widgets/informer_banner_tile.dart'; +import 'package:komet/models/animoji.dart'; +import 'package:komet/models/informer_banner.dart'; + +const _banner = InformerBanner( + id: 'synthetic-banner', + title: 'Synthetic title', + description: 'Synthetic description', + settings: BannerSettings.textAnimation, + type: BannerType.link, + url: 'https://example.invalid/synthetic', +); + +Widget _app(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + testWidgets('renders the banner content and reports presentation once', ( + tester, + ) async { + var presentations = 0; + + await tester.pumpWidget( + _app( + InformerBannerTile( + banner: _banner, + onPresented: (_) => presentations++, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Synthetic title'), findsOneWidget); + expect(find.text('Synthetic description'), findsOneWidget); + expect( + find.byKey(const ValueKey('informer-banner-fallback-icon')), + findsOneWidget, + ); + expect(presentations, 1); + + await tester.pumpWidget( + _app( + InformerBannerTile( + banner: _banner, + onPresented: (_) => presentations++, + ), + ), + ); + await tester.pump(); + + expect(presentations, 1); + }); + + testWidgets('handles body and close actions independently', (tester) async { + var taps = 0; + var closes = 0; + + await tester.pumpWidget( + _app( + InformerBannerTile( + banner: _banner, + onTap: () => taps++, + onClose: () => closes++, + ), + ), + ); + await tester.pump(); + + await tester.tap( + find.byKey(const ValueKey('informer-banner-synthetic-banner')), + ); + await tester.pump(); + expect(taps, 1); + expect(closes, 0); + + await tester.tap( + find.byKey(const ValueKey('informer-banner-close-synthetic-banner')), + ); + await tester.pump(); + expect(taps, 1); + expect(closes, 1); + }); + + testWidgets('honors close visibility and resolves the configured animoji', ( + tester, + ) async { + int? requestedId; + const banner = InformerBanner( + id: 'synthetic-themed-banner', + title: 'Synthetic themed title', + settings: BannerSettings.hideCloseButton | BannerSettings.iconThemeColor, + animojiId: 42, + ); + + await tester.pumpWidget( + _app( + InformerBannerTile( + banner: banner, + animojiLoader: (id) async { + requestedId = id; + return const Animoji(id: 42, emoji: '🧪'); + }, + ), + ), + ); + await tester.pump(); + + expect(requestedId, 42); + expect( + find.byKey(const ValueKey('informer-banner-animoji')), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('informer-banner-close-synthetic-themed-banner'), + ), + findsNothing, + ); + expect(find.byType(ColorFiltered), findsOneWidget); + }); +}