From b8a3cd30af7412233b7eb1a7e75ed866554330f5 Mon Sep 17 00:00:00 2001 From: TiaraBasori Date: Tue, 13 Oct 2026 21:26:59 +0800 Subject: [PATCH] feat(channels): label All-feed posts with a topic chip The aggregated feed showed every post as an unmarked stream, so posts from different topics were indistinguishable. Render a _TopicChip row above each post when _selectedThreadId == null (with a 10px bottom gap before the sender header): - 20px TopicIconSurface (custom emoji or tinted hashtag tile) + 6px gap + single-line 14px w600 topic name in topic.iconColor ?? AppTheme.avatarColor(topic.name) + optional 4px gap and 12px HeroAppIcons.lock (c.textTertiary) for closed topics, padded 2px vertically inside an opaque GestureDetector (topic-post-chip-). - Tapping routes through _selectTopic(post.topic.id). - Topic-scoped feeds (_selectedThreadId != null) omit the chip because the header already names the topic. --- lib/channels/topic_chat_view.dart | 68 +++++++++ test/topic_post_topic_chip_test.dart | 202 +++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 test/topic_post_topic_chip_test.dart diff --git a/lib/channels/topic_chat_view.dart b/lib/channels/topic_chat_view.dart index 79f68dfc..d505da1f 100644 --- a/lib/channels/topic_chat_view.dart +++ b/lib/channels/topic_chat_view.dart @@ -42,6 +42,7 @@ import '../theme/theme_controller.dart'; import 'forum_topic_fields.dart'; import 'topic_list_actions.dart'; import 'topic_list_host.dart'; +import 'topic_list_row.dart'; import 'topic_navigation.dart'; import 'topic_post_content.dart'; @@ -2003,6 +2004,8 @@ class _TopicChatViewState extends State { chatId: widget.chat.id, post: post, sender: _senderCache[post.message.senderId], + showTopicChip: _selectedThreadId == null, + onOpenTopic: () => _selectTopic(post.topic.id), onLike: () => _addReaction(post, '❤️'), onPickReaction: () => _showReactionPicker(post), onComments: () => _openComments(post), @@ -2080,6 +2083,8 @@ class _TopicPostRow extends StatelessWidget { required this.onPickReaction, required this.onComments, required this.onShare, + this.showTopicChip = false, + this.onOpenTopic, this.sender, }); @@ -2091,6 +2096,12 @@ class _TopicPostRow extends StatelessWidget { final VoidCallback onComments; final VoidCallback onShare; + /// The aggregated "All" feed labels every post with its topic; a single + /// topic's feed already names itself in the header, so the chip is + /// redundant there. + final bool showTopicChip; + final VoidCallback? onOpenTopic; + @override Widget build(BuildContext context) { final c = context.colors; @@ -2105,6 +2116,10 @@ class _TopicPostRow extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (showTopicChip) ...[ + _TopicChip(topic: post.topic, onOpen: onOpenTopic), + const SizedBox(height: 10), + ], Row( children: [ PhotoAvatar(title: name, photo: sender?.photo, size: 48), @@ -2171,6 +2186,59 @@ class _TopicPostRow extends StatelessWidget { post.message.buttonRows.isNotEmpty; } +/// Topic label above a post in the aggregated feed: a 20px `TopicIconSurface` +/// (custom emoji or tinted hashtag tile), 6px gap, single-line 14px w600 topic +/// name tinted with `topic.iconColor ?? AppTheme.avatarColor(topic.name)`, and +/// an optional 12px `HeroAppIcons.lock` (`c.textTertiary`, 4px gap) for closed +/// topics. Tapping it routes through `_selectTopic(post.topic.id)`. +class _TopicChip extends StatelessWidget { + const _TopicChip({required this.topic, this.onOpen}); + + final _ForumTopic topic; + final VoidCallback? onOpen; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final tint = topic.iconColor ?? AppTheme.avatarColor(topic.name); + return GestureDetector( + key: ValueKey('topic-post-chip-${topic.id}'), + behavior: HitTestBehavior.opaque, + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TopicIconSurface( + size: 20, + iconCustomEmojiId: topic.iconCustomEmojiId, + tint: tint, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + topic.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: tint, + ), + ), + ), + if (topic.isClosed) ...[ + const SizedBox(width: 4), + AppIcon(HeroAppIcons.lock, size: 12, color: c.textTertiary), + ], + ], + ), + ), + ); + } +} + class _PostActions extends StatelessWidget { const _PostActions({ required this.message, diff --git a/test/topic_post_topic_chip_test.dart b/test/topic_post_topic_chip_test.dart new file mode 100644 index 00000000..6118944b --- /dev/null +++ b/test/topic_post_topic_chip_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mithka/channels/topic_chat_view.dart'; +import 'package:mithka/l10n/app_localizations.dart'; +import 'package:mithka/tdlib/td_models.dart'; +import 'package:mithka/theme/theme_controller.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('the aggregated feed labels every post with its topic chip', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + final theme = ThemeController(preferences); + addTearDown(theme.dispose); + final requests = >[]; + final chat = ChatSummary( + id: -10042, + title: 'Forum', + lastMessage: 'Latest', + lastMessageId: 90, + date: 90, + unreadCount: 0, + order: 1, + isMuted: false, + isForum: true, + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: TopicChatView( + chat: chat, + showBackButton: false, + query: (request) async { + requests.add(Map.from(request)); + return switch (request['@type']) { + 'getForumTopics' => _forumTopicsResponse(), + 'getForumTopicHistory' => _historyResponse( + int.parse((request['forum_topic_id'] as num).toString()), + ), + _ => { + '@type': 'messages', + 'messages': const >[], + }, + }; + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Both posts carry their topic's name in a chip in the "All" feed. + // Each topic's stubbed history holds two messages, so each chip + // renders twice. + expect(find.byKey(const ValueKey('topic-post-chip-1')), findsNWidgets(2)); + expect(find.byKey(const ValueKey('topic-post-chip-2')), findsNWidgets(2)); + expect( + find.text('Post in General 101', findRichText: true), + findsOneWidget, + ); + expect(find.text('Post in Design 201', findRichText: true), findsOneWidget); + + // Tapping a post's chip opens that topic: the chip disappears because + // the feed becomes topic-scoped. + await tester.tap(find.byKey(const ValueKey('topic-post-chip-2')).first); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('topic-post-chip-1')), findsNothing); + expect(find.byKey(const ValueKey('topic-post-chip-2')), findsNothing); + expect(find.text('Post in Design 201', findRichText: true), findsOneWidget); + // Topic 2's messages were already cached by the All-feed preview, so + // the only topic-2 history request is that initial limit-6 probe; the + // scoping itself is asserted by the chips disappearing above. + expect( + requests.where((request) => request['@type'] == 'getForumTopicHistory'), + contains( + equals({ + '@type': 'getForumTopicHistory', + 'chat_id': -10042, + 'forum_topic_id': 2, + 'from_message_id': 0, + 'offset': 0, + 'limit': 6, + }), + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('a topic-scoped feed does not label its posts', (tester) async { + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + final theme = ThemeController(preferences); + addTearDown(theme.dispose); + final chat = ChatSummary( + id: -10042, + title: 'Forum', + lastMessage: 'Latest', + lastMessageId: 90, + date: 90, + unreadCount: 0, + order: 1, + isMuted: false, + isForum: true, + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: TopicChatView( + chat: chat, + initialThreadId: 2, + showBackButton: false, + query: (request) async { + return switch (request['@type']) { + 'getForumTopics' => _forumTopicsResponse(), + 'getForumTopicHistory' => _historyResponse(2), + _ => { + '@type': 'messages', + 'messages': const >[], + }, + }; + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('topic-post-chip-2')), findsNothing); + expect(find.text('Design'), findsWidgets); + expect(tester.takeException(), isNull); + }); +} + +Map _forumTopicsResponse() => { + '@type': 'forumTopics', + 'topics': [ + _topic(1, 'General', 20, color: 1), + _topic(2, 'Design', 40, color: 2), + ], +}; + +Map _topic( + int id, + String name, + int lastMessageDate, { + int? color, +}) => { + '@type': 'forumTopic', + 'info': { + '@type': 'forumTopicInfo', + 'forum_topic_id': id, + 'name': name, + if (color != null) 'icon': {'@type': 'chatTheme', 'color': color}, + }, + 'unread_count': 0, + 'order': -lastMessageDate, + 'last_message': _message(id * 100, lastMessageDate, topic: name), +}; + +Map _historyResponse(int topicId) => { + '@type': 'messages', + 'messages': [ + _message( + topicId * 100 + 1, + topicId * 10 + 1, + topic: topicId == 1 ? 'General' : 'Design', + ), + _message( + topicId * 100 + 2, + topicId * 10 + 2, + topic: topicId == 1 ? 'General' : 'Design', + ), + ], +}; + +Map _message(int id, int date, {required String topic}) => { + '@type': 'message', + 'id': id, + 'chat_id': -10042, + 'date': date, + 'is_outgoing': false, + 'content': { + '@type': 'messageText', + 'text': {'@type': 'formattedText', 'text': 'Post in $topic $id'}, + }, +};