diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 9048fa98de..bec01f45c1 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -116,6 +116,10 @@ "@subscribeFailedTitle": { "description": "Error title when subscribing to a channel failed." }, + "actionSheetOptionViewChannelMembers": "View channel members", + "@actionSheetOptionViewChannelMembers": { + "description": "Label for navigating to a channel's members page." + }, "actionSheetOptionMarkChannelAsRead": "Mark channel as read", "@actionSheetOptionMarkChannelAsRead": { "description": "Label for marking a channel as read." diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart index c21a6c8752..995dfe49f5 100644 --- a/lib/api/route/channels.dart +++ b/lib/api/route/channels.dart @@ -127,3 +127,25 @@ Future updateUserTopic(ApiConnection connection, { 'visibility_policy': visibilityPolicy, }); } + +/// https://zulip.com/api/get-subscribers +Future getSubscribers(ApiConnection connection, { + required int streamId, +}) { + return connection.get('getSubscribers', GetSubscribersResult.fromJson, + 'streams/$streamId/members', null); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class GetSubscribersResult { + final List subscribers; + + GetSubscribersResult({ + required this.subscribers, + }); + + factory GetSubscribersResult.fromJson(Map json) => + _$GetSubscribersResultFromJson(json); + + Map toJson() => _$GetSubscribersResultToJson(this); +} \ No newline at end of file diff --git a/lib/api/route/channels.g.dart b/lib/api/route/channels.g.dart index c43f0f50f0..5e2b4a27e9 100644 --- a/lib/api/route/channels.g.dart +++ b/lib/api/route/channels.g.dart @@ -30,3 +30,15 @@ GetStreamTopicsEntry _$GetStreamTopicsEntryFromJson( Map _$GetStreamTopicsEntryToJson( GetStreamTopicsEntry instance, ) => {'max_id': instance.maxId, 'name': instance.name}; + +GetSubscribersResult _$GetSubscribersResultFromJson( + Map json, +) => GetSubscribersResult( + subscribers: (json['subscribers'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$GetSubscribersResultToJson( + GetSubscribersResult instance, +) => {'subscribers': instance.subscribers}; diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 7a08e9d986..b8e1699a86 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -305,6 +305,12 @@ abstract class ZulipLocalizations { /// **'Failed to subscribe'** String get subscribeFailedTitle; + /// Label for navigating to a channel's members page. + /// + /// In en, this message translates to: + /// **'View channel members'** + String get actionSheetOptionViewChannelMembers; + /// Label for marking a channel as read. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 759cd06a01..da87447572 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -103,6 +103,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Failed to subscribe'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 4975e37387..1ac79c5778 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -104,6 +104,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Konnte nicht abonnieren'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Kanal als gelesen markieren'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 4ad9cf4f51..d05ce1f387 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -103,6 +103,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Failed to subscribe'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 402619efec..7081c6da16 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -105,6 +105,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Failed to subscribe'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Marquer le canal comme lu'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index ce18a1d568..3b30266b95 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -104,6 +104,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Iscrizione non riuscita'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Segna il canale come letto'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 4e75bf6568..9b3d974d3a 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -102,6 +102,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get subscribeFailedTitle => 'チャンネルへの参加に失敗しました'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'チャンネルを既読にする'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 344bb0372b..7a3d6871b2 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -103,6 +103,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Failed to subscribe'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 45e9fb2cac..079f1c1789 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -104,6 +104,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Subskrypcja bez powodzenia'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Oznacz kanał jako przeczytany'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 817d6b03f8..70c90dd5d5 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -104,6 +104,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Подписаться не удалось'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Отметить канал как прочитанный'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 2504894b25..b5661a71ab 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -103,6 +103,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Failed to subscribe'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 933971c93a..0b99c080a1 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -103,6 +103,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Naročnina ni uspela'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Označi kanal kot prebran'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 24076bc706..ada994f33d 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -105,6 +105,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Не вдалося підписатися'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Позначити канал як прочитаний'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 4084a19aba..810b88775b 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -103,6 +103,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get subscribeFailedTitle => 'Failed to subscribe'; + @override + String get actionSheetOptionViewChannelMembers => 'View channel members'; + @override String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 30bbf433f5..a1d22963da 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -19,6 +19,7 @@ import '../model/internal_link.dart'; import '../model/narrow.dart'; import 'actions.dart'; import 'button.dart'; +import 'channel_subscribers.dart'; import 'color.dart'; import 'compose_box.dart'; import 'content.dart'; @@ -504,6 +505,7 @@ void showChannelActionSheet(BuildContext context, { && channel != null && store.selfHasContentAccess(channel)) [SubscribeButton(pageContext: pageContext, channelId: channelId)], [ + ChannelMembersButton(pageContext: pageContext, channelId: channelId), if (unreadCount > 0) MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId), if (showTopicListButton) @@ -580,6 +582,26 @@ class MarkChannelAsReadButton extends ActionSheetMenuItemButton { } } +class ChannelMembersButton extends ActionSheetMenuItemButton { + const ChannelMembersButton({super.key, required this.channelId, required super.pageContext}); + + final int channelId; + + @override + IconData get icon => ZulipIcons.two_person; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionViewChannelMembers; + } + + @override + void onPressed() { + Navigator.push(pageContext, + ChannelMembersPage.buildRoute(context: pageContext, streamId: channelId)); + } +} + class TopicListButton extends ActionSheetMenuItemButton { const TopicListButton({ super.key, diff --git a/lib/widgets/channel_subscribers.dart b/lib/widgets/channel_subscribers.dart new file mode 100644 index 0000000000..5bef83aeb4 --- /dev/null +++ b/lib/widgets/channel_subscribers.dart @@ -0,0 +1,390 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../api/route/channels.dart' as channels_api; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/autocomplete.dart'; +import '../model/store.dart'; +import 'app_bar.dart'; +import 'page.dart'; +import 'profile.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; + +class ChannelMembersPage extends StatefulWidget { + const ChannelMembersPage({super.key, required this.streamId}); + + final int streamId; + + static Route buildRoute({ + int? accountId, + BuildContext? context, + required int streamId, + }) { + return MaterialAccountWidgetRoute( + accountId: accountId,context: context, + page: ChannelMembersPage(streamId: streamId), + ); + } + + @override + State createState() => _ChannelMembersPageState(); +} + +class _ChannelMembersPageState extends State + with PerAccountStoreAwareStateMixin { + late TextEditingController searchController; + late ScrollController membersScrollController; + + List? subscriberIds; + List filteredMembers = []; + bool loading = true; + String? errorMessage; + + @override + void initState() { + super.initState(); + searchController = TextEditingController()..addListener(_handleSearchUpdate); + membersScrollController = ScrollController(); + } + + @override + void dispose() { + searchController.dispose(); + membersScrollController.dispose(); + super.dispose(); + } + + @override + void onNewStore() { + _fetchSubscribers(); + } + + Future _fetchSubscribers() async { + setState(() { + loading = true; + errorMessage = null; + }); + + try { + final store = PerAccountStoreWidget.of(context); + final result = await channels_api.getSubscribers(store.connection, streamId: widget.streamId); + if (!mounted) return; + + final sorted = _getSortedMembers(store, result.subscribers); + setState(() { + subscriberIds = sorted; + _updateFilteredMembers(store); + loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + errorMessage = e.toString(); + loading = false; + }); + } + } + + void _handleSearchUpdate() { + final store = PerAccountStoreWidget.of(context); + _updateFilteredMembers(store); + } + + void _updateFilteredMembers(PerAccountStore store) { + if (subscriberIds == null) return; + + final normalizedQuery = AutocompleteQuery.lowercaseAndStripDiacritics( + searchController.text, + ); + + final result = []; + for (final userId in subscriberIds!) { + final user = store.getUser(userId); + if (user == null) continue; + + final normalizedName = AutocompleteQuery.lowercaseAndStripDiacritics(user.fullName); + final normalizedEmail = AutocompleteQuery.lowercaseAndStripDiacritics(user.email); + + if (normalizedName.contains(normalizedQuery) || + normalizedEmail.contains(normalizedQuery)) { + result.add(userId); + } + } + + setState(() { + filteredMembers = result; + }); + + if (membersScrollController.hasClients) { + // Jump to the first results for the new query. + membersScrollController.jumpTo(0); + } + } + + List _getSortedMembers(PerAccountStore store, List memberIds) { + final sorted = List.from(memberIds) + ..sort((a, b) { + if (a == store.selfUserId) return -1; + if (b == store.selfUserId) return 1; + final userA = store.getUser(a); + final userB = store.getUser(b); + if (userA == null || userB == null) return 0; + return userA.fullName.compareTo(userB.fullName); + }); + return sorted; + } + + @override + Widget build(BuildContext context) { + final memberCount = subscriberIds?.length ?? 0; + + if (loading) { + return Scaffold( + appBar: ZulipAppBar(title: Text("$memberCount members"),centerTitle: true), + body: const Center(child: CircularProgressIndicator())); + } + + if (errorMessage != null) { + return Scaffold( + appBar: ZulipAppBar(title: Text("$memberCount members"),centerTitle: true), + body: Center(child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(errorMessage ?? 'Error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _fetchSubscribers, + child: const Text('Retry')), + ], + ))); + } + + return Scaffold( + appBar: ZulipAppBar(title: Text("$memberCount members"),centerTitle: true), + body: SafeArea( + child: Column(children: [ + _MemberSearchBar(controller: searchController), + Expanded(child: _MemberList( + filteredMembers: filteredMembers, + scrollController: membersScrollController)), + ]))); + } +} + +class _MemberSearchBar extends StatelessWidget { + const _MemberSearchBar({required this.controller}); + + final TextEditingController controller; + + Widget _buildSearchField(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return TextField( + controller: controller, + autofocus: false, + cursorColor: designVariables.foreground, + style: TextStyle( + color: designVariables.textMessage, + fontSize: 17, + height: 22 / 17), + scrollPadding: EdgeInsets.zero, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: designVariables.bgSearchInput, + hintText: zulipLocalizations.searchMessagesHintText, //"search" + hintStyle: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 22 / 17), + prefixIcon: Icon(Icons.search,color: designVariables.icon), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: Icon(Icons.clear, color: designVariables.icon), + onPressed: controller.clear, + ) + : null, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: _buildSearchField(context), + ); + } +} + +class _MemberList extends StatelessWidget { + const _MemberList({ + required this.filteredMembers, + required this.scrollController, + }); + + final List filteredMembers; + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + if (filteredMembers.isEmpty) { + return Center(child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + textAlign: TextAlign.center, + 'No members found', + style: TextStyle( + color: designVariables.labelMenuButton, + fontSize: 16)))); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: CustomScrollView(controller: scrollController, slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: 8), + sliver: SliverSafeArea( + minimum: EdgeInsets.only(bottom: 8), + sliver: SliverList.builder( + itemCount: filteredMembers.length, + itemBuilder: (context, index) { + final userId = filteredMembers[index]; + return _MemberListItem(userId: userId); + }))), + ])); + } +} + +class _MemberListItem extends StatelessWidget { + const _MemberListItem({required this.userId}); + + final int userId; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + final user = store.getUser(userId); + + if (user == null) return const SizedBox.shrink(); + + final isSelf = userId == store.selfUserId; + final userStatus = store.getUserStatus(userId); + final presence = store.presence.presenceStatusForUser( + userId, + utcNow: DateTime.now(), + ); + + Color presenceColor; + switch (presence) { + case PresenceStatus.active: + presenceColor = designVariables.statusOnline; + break; + case PresenceStatus.idle: + presenceColor = designVariables.statusIdle; + break; + default: + presenceColor = designVariables.statusAway; + break; + } + + return Material( + clipBehavior: Clip.antiAlias, + borderRadius: BorderRadius.circular(10), + color: Colors.transparent, + child: InkWell( + highlightColor: designVariables.bgMenuButtonSelected, + splashFactory: NoSplash.splashFactory, + onTap: () { + Navigator.push( + context, ProfilePage.buildRoute(context: context, userId: userId)); + }, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(0, 6, 12, 6), + child: Row(children: [ + SizedBox(width: 8), + Stack( + clipBehavior: Clip.none, + children: [ + AvatarShape( + size: 40, + borderRadius: 4, + userIdForPresence: userId, + backgroundColor: designVariables.background, + child: AvatarImage(userId: userId, size: 40), + ), + Positioned( + right: -2,bottom: -2, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: presenceColor, + shape: BoxShape.circle, + border: Border.all(color: designVariables.background,width: 2), + ))), + ], + ), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Flexible(child: Text( + user.fullName, + style: TextStyle( + fontSize: 17, + height: 19 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)), + overflow: TextOverflow.ellipsis, + )), + if (isSelf) + Padding(padding: const EdgeInsets.only(left: 4), + child: Text( + '(you)', + style: TextStyle(fontSize: 14,color: designVariables.textMessageMuted), + )), + ], + ), + SizedBox(height: 2), + if (!user.isActive) + Text( + 'Deactivated', + style: TextStyle(fontSize: 14,fontStyle: FontStyle.italic,color: designVariables.textMessageMuted)) + else if (userStatus.text != null || userStatus.emoji != null) + Row(children: [ + Expanded(child: Text.rich( + TextSpan( + children: [ + if (userStatus.emoji != null) + UserStatusEmoji.asWidgetSpan( + userId: userId, + fontSize: 14, + textScaler: MediaQuery.textScalerOf(context), + position: StatusEmojiPosition.before, + neverAnimate: false), + TextSpan( + text: userStatus.text ?? '', + style: TextStyle(fontSize: 14,color: designVariables.textMessageMuted, + )), + ]), + overflow: TextOverflow.ellipsis, + ))], + )])), + ])))); + } +} \ No newline at end of file diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 4802a3b93f..8d7b07b065 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -19,6 +19,7 @@ import 'action_sheet.dart'; import 'actions.dart'; import 'app_bar.dart'; import 'button.dart'; +import 'channel_subscribers.dart'; import 'color.dart'; import 'compose_box.dart'; import 'content.dart'; @@ -602,6 +603,9 @@ class MessageListAppBarTitle extends StatelessWidget { width: double.infinity, child: GestureDetector( behavior: HitTestBehavior.translucent, + onTap: () { + Navigator.push(context,ChannelMembersPage.buildRoute(context: context, streamId: streamId)); + }, onLongPress: () { showChannelActionSheet(context, channelId: streamId); }, diff --git a/test/api/route/channel_subscribers_test.dart b/test/api/route/channel_subscribers_test.dart new file mode 100644 index 0000000000..212c1c6fd2 --- /dev/null +++ b/test/api/route/channel_subscribers_test.dart @@ -0,0 +1,78 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/channels.dart'; +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; + +void main() { + group('getSubscribers', () { + test('smoke test', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: { + 'subscribers': [1, 2, 3], + }); + final result = await getSubscribers(connection, streamId: 123); + check(result.subscribers).deepEquals([1, 2, 3]); + check(connection.takeRequests()).single.isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/streams/123/members'); + }); + }); + + // test('with empty subscribers list', () { + // return FakeApiConnection.with_((connection) async { + // connection.prepare(json: { + // 'subscribers': [], + // }); + // final result = await getSubscribers(connection, streamId: 456); + // check(result.subscribers).isEmpty(); + // check(connection.takeRequests()).single.isA() + // ..method.equals('GET') + // ..url.path.equals('/api/v1/streams/456/members'); + // }); + // }); + + test('handles large subscriber list', () { + final largeList = List.generate(1000, (i) => i + 1); + return FakeApiConnection.with_((connection) async { + connection.prepare(json: { + 'subscribers': largeList, + }); + final result = await getSubscribers(connection, streamId: 789); + check(result.subscribers).deepEquals(largeList); + }); + }); + }); + + group('GetSubscribersResult', () { + test('fromJson smoke test', () { + final json = { + 'subscribers': [1, 2, 3, 4, 5], + }; + final result = GetSubscribersResult.fromJson(json); + check(result.subscribers).deepEquals([1, 2, 3, 4, 5]); + }); + + // test('fromJson with empty list', () { + // final json = {'subscribers': []}; + // final result = GetSubscribersResult.fromJson(json); + // check(result.subscribers).isEmpty(); + // }); + + test('fromJson preserves order', () { + final json = { + 'subscribers': [5, 3, 1, 4, 2], + }; + final result = GetSubscribersResult.fromJson(json); + check(result.subscribers).deepEquals([5, 3, 1, 4, 2]); + }); + + test('toJson round trip', () { + final original = GetSubscribersResult(subscribers: [1, 2, 3]); + final json = original.toJson(); + final restored = GetSubscribersResult.fromJson(json); + check(restored.subscribers).deepEquals(original.subscribers); + }); + }); +} diff --git a/test/api/route/channels_test.dart b/test/api/route/channels_test.dart index 0a73dded59..6296312796 100644 --- a/test/api/route/channels_test.dart +++ b/test/api/route/channels_test.dart @@ -138,4 +138,27 @@ void main() { }); }); }); + group('GetSubscribersResult', () { + test('fromJson smoke test', () { + final json = { + 'subscribers': [1, 2, 3, 4, 5], + }; + final result = GetSubscribersResult.fromJson(json); + check(result.subscribers).deepEquals([1, 2, 3, 4, 5]); + }); + + // test('fromJson with empty list', () { + // final json = {'subscribers': []}; + // final result = GetSubscribersResult.fromJson(json); + // check(result.subscribers).isEmpty(); + // }); + + test('fromJson preserves order', () { + final json = { + 'subscribers': [5, 3, 1, 4, 2], + }; + final result = GetSubscribersResult.fromJson(json); + check(result.subscribers).deepEquals([5, 3, 1, 4, 2]); + }); + }); } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 621f759f28..4d4fffcf16 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -24,6 +24,7 @@ import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/action_sheet.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/button.dart'; +import 'package:zulip/widgets/channel_subscribers.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/emoji_reaction.dart'; @@ -289,6 +290,7 @@ void main() { group('showChannelActionSheet', () { void checkButtons() { check(actionSheetFinder).findsOne(); + checkButton('View channel members'); checkButton('Mark channel as read'); checkButton('Copy link to channel'); } @@ -449,6 +451,35 @@ void main() { }); }); }); + testWidgets('navigates to ChannelMembersPage', (WidgetTester tester) async { + final TransitionDurationObserver transitionDurationObserver = TransitionDurationObserver(); + + await prepare(); + final narrow = ChannelNarrow(someChannel.streamId); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: []).toJson()); + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], + child: MessageListPage(initNarrow: narrow), + )); + await tester.pumpAndSettle(); + + await tester.longPress(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(someChannel.name), + )); + await transitionDurationObserver.pumpPastTransition(tester); + connection.prepare(json: GetSubscribersResult(subscribers: [1, 2, 3]).toJson()); + + await tester.tap(findButtonForLabel('View channel members')); + await tester.pump(); + await transitionDurationObserver.pumpPastTransition(tester); + check(find.byType(ChannelMembersPage)).findsOne(); + }); group('MarkChannelAsReadButton', () { void checkRequest(int channelId) { diff --git a/test/widgets/channel_subscribers_test.dart b/test/widgets/channel_subscribers_test.dart new file mode 100644 index 0000000000..9dd50270b4 --- /dev/null +++ b/test/widgets/channel_subscribers_test.dart @@ -0,0 +1,297 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/channels.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/channel_subscribers.dart'; +import 'package:zulip/widgets/profile.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../stdlib_checks.dart'; +import 'test_app.dart'; + +late PerAccountStore store; +late FakeApiConnection connection; + +Future setupChannelMembersPage(WidgetTester tester, { + required int streamId, + required List subscriberIds, + List? users, + bool simulateError = false, +}) async { + addTearDown(testBinding.reset); + + final stream = eg.stream(streamId: streamId); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + streams: [stream], + subscriptions: [eg.subscription(stream)], + realmUsers: [eg.selfUser, ...?users], + )); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + if (simulateError) { + connection.prepare(httpException: Exception('Network error')); + } else { + connection.prepare(json: GetSubscribersResult( + subscribers: subscriberIds, + ).toJson()); + } + + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: ChannelMembersPage(streamId: streamId), + )); + + await tester.pump(); +} + +void main() { + TestZulipBinding.ensureInitialized(); + + group('ChannelMembersPage', () { + testWidgets('shows loading state initially', (tester) async { + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1, 2, 3]); + + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.byType(TextField)).findsNothing(); + + await tester.pumpAndSettle(); + }); + + testWidgets('displays members after loading', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Alice'); + final user2 = eg.user(userId: 2, fullName: 'Bob'); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1, 2], + users: [user1, user2]); + + await tester.pumpAndSettle(); + + check(find.text('Alice')).findsOne(); + check(find.text('Bob')).findsOne(); + check(find.byType(CircularProgressIndicator)).findsNothing(); + }); + + testWidgets('shows member count in app bar', (tester) async { + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1, 2, 3]); + + await tester.pumpAndSettle(); + + check(find.text('3 members')).findsOne(); + }); + + group('sorting', () { + testWidgets('self user appears first', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Alice'); + final user2 = eg.user(userId: 2, fullName: 'Bob'); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1, 2, eg.selfUser.userId], + users: [user1, user2]); + + await tester.pumpAndSettle(); + + final listItems = tester.widgetList( + find.byType(InkWell)).toList(); + + final firstItem = listItems.first; + check(find.descendant( + of: find.byWidget(firstItem), + matching: find.text('(you)'))).findsOne(); + }); + + testWidgets('other members sorted alphabetically', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Charlie'); + final user2 = eg.user(userId: 2, fullName: 'Alice'); + final user3 = eg.user(userId: 3, fullName: 'Bob'); + + await setupChannelMembersPage( + tester, + streamId: 1, + subscriberIds: [1, 2, 3], + users: [user1, user2, user3], + ); + + await tester.pumpAndSettle(); + + final textWidgets = tester.widgetList(find.byType(Text)); + + final names = textWidgets + .map((w) => w.data) + .where((text) => text == 'Alice' || text == 'Bob' || text == 'Charlie') + .toList(); + check(names).deepEquals(['Alice', 'Bob', 'Charlie']); + }); + }); + + group('search', () { + testWidgets('filters by name', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Alice Smith'); + final user2 = eg.user(userId: 2, fullName: 'Bob Jones'); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1, 2], + users: [user1, user2]); + + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pumpAndSettle(); + + check(find.text('Alice Smith')).findsOne(); + check(find.text('Bob Jones')).findsNothing(); + }); + + testWidgets('filters by email', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Alice', + email: 'alice@example.com'); + final user2 = eg.user(userId: 2, fullName: 'Bob', + email: 'bob@example.com'); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1, 2], + users: [user1, user2]); + + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'alice@'); + await tester.pumpAndSettle(); + + check(find.text('Alice')).findsOne(); + check(find.text('Bob')).findsNothing(); + }); + + testWidgets('search is case-insensitive', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Alice'); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1], + users: [user1]); + + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'ALICE'); + await tester.pumpAndSettle(); + + check(find.text('Alice')).findsOne(); + }); + + testWidgets('clear button clears search', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Alice'); + final user2 = eg.user(userId: 2, fullName: 'Bob'); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1, 2], + users: [user1, user2]); + + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pumpAndSettle(); + check(find.text('Bob')).findsNothing(); + + await tester.tap(find.byIcon(Icons.clear)); + await tester.pumpAndSettle(); + + check(find.text('Alice')).findsOne(); + check(find.text('Bob')).findsOne(); + }); + + testWidgets('shows "no members found" message', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Alice'); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1], + users: [user1]); + + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Nonexistent'); + await tester.pumpAndSettle(); + + check(find.text('No members found')).findsOne(); + }); + }); + + group('user display', () { + testWidgets('shows "(you)" for self', (tester) async { + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [eg.selfUser.userId]); + + await tester.pumpAndSettle(); + + check(find.text('(you)')).findsOne(); + }); + + testWidgets('shows "Deactivated" for inactive users', (tester) async { + final inactiveUser = eg.user(userId: 1, fullName: 'Alice', + isActive: false); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1], + users: [inactiveUser]); + + await tester.pumpAndSettle(); + + check(find.text('Deactivated')).findsOne(); + }); + }); + + testWidgets('tapping member opens profile page', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'Alice'); + + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [1], + users: [user1]); + + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alice')); + await tester.pumpAndSettle(); + + check(find.byType(ProfilePage)).findsOne(); + }); + + testWidgets('handles unknown users gracefully', (tester) async { + await setupChannelMembersPage(tester, + streamId: 1, + subscriberIds: [999]); + + await tester.pumpAndSettle(); + check(find.byType(InkWell)).findsNothing(); + }); + + testWidgets('makes API request on mount', (tester) async { + await setupChannelMembersPage(tester, + streamId: 123, + subscriberIds: [1, 2]); + + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/streams/123/members'); + }); + }); +} diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index acdde3fd2c..feb11e7bb5 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -27,6 +27,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/autocomplete.dart'; +import 'package:zulip/widgets/channel_subscribers.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; @@ -234,6 +235,23 @@ void main() { group('app bar', () { // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + testWidgets('channel name tappable to open members list', (tester) async { + final channel = eg.stream(); + await setupMessageListPage(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [eg.streamMessage(stream: channel)]); + + connection.prepare(json: GetSubscribersResult( + subscribers: [1, 2, 3]).toJson()); + await tester.tap(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(channel.name))); + await tester.pump(); + await tester.pumpAndSettle(); + + check(find.byType(ChannelMembersPage)).findsOne(); + }); testWidgets('handle empty topics', (tester) async { final channel = eg.stream(); await setupMessageListPage(tester,