Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions lib/channels/topic_chat_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -2003,6 +2004,8 @@ class _TopicChatViewState extends State<TopicChatView> {
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),
Expand Down Expand Up @@ -2080,6 +2083,8 @@ class _TopicPostRow extends StatelessWidget {
required this.onPickReaction,
required this.onComments,
required this.onShare,
this.showTopicChip = false,
this.onOpenTopic,
this.sender,
});

Expand All @@ -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;
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
202 changes: 202 additions & 0 deletions test/topic_post_topic_chip_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <Map<String, dynamic>>[];
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<ThemeController>.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<String, dynamic>.from(request));
return switch (request['@type']) {
'getForumTopics' => _forumTopicsResponse(),
'getForumTopicHistory' => _historyResponse(
int.parse((request['forum_topic_id'] as num).toString()),
),
_ => {
'@type': 'messages',
'messages': const <Map<String, dynamic>>[],
},
};
},
),
),
),
);
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<ThemeController>.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 <Map<String, dynamic>>[],
},
};
},
),
),
),
);
await tester.pumpAndSettle();

expect(find.byKey(const ValueKey('topic-post-chip-2')), findsNothing);
expect(find.text('Design'), findsWidgets);
expect(tester.takeException(), isNull);
});
}

Map<String, dynamic> _forumTopicsResponse() => {
'@type': 'forumTopics',
'topics': [
_topic(1, 'General', 20, color: 1),
_topic(2, 'Design', 40, color: 2),
],
};

Map<String, dynamic> _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<String, dynamic> _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<String, dynamic> _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'},
},
};
Loading