diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..8db96d913c 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -375,6 +375,10 @@ "@unknownChannelName": { "description": "Replacement name for channel when it cannot be found in the store." }, + "channelArchivedLabel": "(archived)", + "@channelArchivedLabel": { + "description": "Label shown next to an archived channel's name in headers." + }, "composeBoxTopicHintText": "Topic", "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 5c94626584..669e4b0b9d 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -328,6 +328,8 @@ class ZulipStream { final int streamId; String name; + @JsonKey(defaultValue: false) // TODO(server-10): remove default + bool isArchived; String description; String renderedDescription; @@ -350,6 +352,7 @@ class ZulipStream { ZulipStream({ required this.streamId, required this.name, + required this.isArchived, required this.description, required this.renderedDescription, required this.dateCreated, @@ -460,6 +463,7 @@ class Subscription extends ZulipStream { Subscription({ required super.streamId, required super.name, + required super.isArchived, required super.description, required super.renderedDescription, required super.dateCreated, diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 32c8eeb0e7..f964e0f148 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -165,6 +165,7 @@ Map _$ProfileFieldUserDataToJson( ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, + isArchived: json['is_archived'] as bool? ?? false, description: json['description'] as String, renderedDescription: json['rendered_description'] as String, dateCreated: (json['date_created'] as num).toInt(), @@ -184,6 +185,7 @@ Map _$ZulipStreamToJson(ZulipStream instance) => { 'stream_id': instance.streamId, 'name': instance.name, + 'is_archived': instance.isArchived, 'description': instance.description, 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, @@ -207,6 +209,7 @@ const _$ChannelPostPolicyEnumMap = { Subscription _$SubscriptionFromJson(Map json) => Subscription( streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, + isArchived: json['is_archived'] as bool? ?? false, description: json['description'] as String, renderedDescription: json['rendered_description'] as String, dateCreated: (json['date_created'] as num).toInt(), @@ -234,6 +237,7 @@ Map _$SubscriptionToJson(Subscription instance) => { 'stream_id': instance.streamId, 'name': instance.name, + 'is_archived': instance.isArchived, 'description': instance.description, 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart index bbd7be5a0a..7101276d6a 100644 --- a/lib/api/route/events.dart +++ b/lib/api/route/events.dart @@ -18,6 +18,7 @@ Future registerQueue(ApiConnection connection) { 'user_avatar_url_field_optional': false, // TODO(#254): turn on 'stream_typing_notifications': true, 'user_settings_object': true, + 'archived_channels': true, }, }); } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index d09393e774..0d73f4ee3d 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -603,6 +603,12 @@ abstract class ZulipLocalizations { /// **'(unknown channel)'** String get unknownChannelName; + /// Label shown next to an archived channel's name in headers. + /// + /// In en, this message translates to: + /// **'(archived)'** + String get channelArchivedLabel; + /// Hint text for topic input widget in compose box. /// /// 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 c2478f4613..f77b509aa0 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get unknownChannelName => '(unknown channel)'; + @override + String get channelArchivedLabel => '(archived)'; + @override String get composeBoxTopicHintText => 'Topic'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 289ba33af2..c176b35733 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get unknownChannelName => '(unknown channel)'; + @override + String get channelArchivedLabel => '(archived)'; + @override String get composeBoxTopicHintText => 'Topic'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 00537f73a2..a944a1893e 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get unknownChannelName => '(unknown channel)'; + @override + String get channelArchivedLabel => '(archived)'; + @override String get composeBoxTopicHintText => 'Topic'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3c063e91da..a881e72bec 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get unknownChannelName => '(unknown channel)'; + @override + String get channelArchivedLabel => '(archived)'; + @override String get composeBoxTopicHintText => 'Topic'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 64705fbe02..54c2fee017 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get unknownChannelName => '(nieznany kanał)'; + @override + String get channelArchivedLabel => '(archived)'; + @override String get composeBoxTopicHintText => 'Wątek'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 911fc281b2..259b2e4834 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get unknownChannelName => '(unknown channel)'; + @override + String get channelArchivedLabel => '(archived)'; + @override String get composeBoxTopicHintText => 'Тема'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 0cb42c3a37..dd73647a72 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get unknownChannelName => '(unknown channel)'; + @override + String get channelArchivedLabel => '(archived)'; + @override String get composeBoxTopicHintText => 'Topic'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 0594bd27d2..b41faf0ff9 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -230,10 +230,12 @@ void showTopicActionSheet(BuildContext context, { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); + final channel = store.streams[channelId]; final subscription = store.subscriptions[channelId]; final optionButtons = []; + final isChannelArchived = channel?.isArchived == true; // TODO(server-7): simplify this condition away final supportsUnmutingTopics = store.zulipFeatureLevel >= 170; // TODO(server-8): simplify this condition away @@ -241,8 +243,9 @@ void showTopicActionSheet(BuildContext context, { final visibilityOptions = []; final visibilityPolicy = store.topicVisibilityPolicy(channelId, topic); - if (subscription == null) { - // Not subscribed to the channel; there is no user topic change to be made. + if (subscription == null || isChannelArchived) { + // Not subscribed to the channel or the channel is archived; + // there is no user topic change to be made. } else if (!subscription.isMuted) { // Channel is subscribed and not muted. switch (visibilityPolicy) { @@ -306,7 +309,8 @@ void showTopicActionSheet(BuildContext context, { // limit for editing topics). if (someMessageIdInTopic != null // ignore: unnecessary_null_comparison // null topic names soon to be enabled - && topic.displayName != null) { + && topic.displayName != null + && !isChannelArchived) { optionButtons.add(ResolveUnresolveButton(pageContext: pageContext, topic: topic, someMessageIdInTopic: someMessageIdInTopic)); @@ -564,6 +568,11 @@ void showMessageActionSheet({required BuildContext context, required Message mes final messageListPage = MessageListPage.ancestorOf(pageContext); final isComposeBoxOffered = messageListPage.composeBoxController != null; + bool isInArchivedChannel = false; + if (message is StreamMessage) { + final channel = store.streams[message.streamId]; + isInArchivedChannel = channel?.isArchived == true; + } final isMessageRead = message.flags.contains(MessageFlag.read); final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; @@ -571,7 +580,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes final optionButtons = [ ReactionButtons(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), - if (isComposeBoxOffered) + if (isComposeBoxOffered && !isInArchivedChannel) QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) MarkAsUnreadButton(message: message, pageContext: pageContext), diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index aabf592dae..1127b4bff5 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1545,8 +1545,10 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case ChannelNarrow(:final streamId): case TopicNarrow(:final streamId): final channel = store.streams[streamId]; - if (channel == null || !store.hasPostingPermission(inChannel: channel, - user: store.selfUser, byDate: DateTime.now())) { + if (channel == null + || !store.hasPostingPermission(inChannel: channel, + user: store.selfUser, byDate: DateTime.now()) + || channel.isArchived) { return _ErrorBanner(getLabel: (zulipLocalizations) => zulipLocalizations.errorBannerCannotPostInChannelLabel); } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index a8c0c12b59..3e03da03a9 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -240,6 +240,7 @@ abstract class _HeaderItem extends StatelessWidget { String title(ZulipLocalizations zulipLocalizations); IconData get icon; + InlineSpan? buildTrailing(BuildContext context) => null; Color collapsedIconColor(BuildContext context); Color uncollapsedIconColor(BuildContext context); Color uncollapsedBackgroundColor(BuildContext context); @@ -285,18 +286,24 @@ abstract class _HeaderItem extends StatelessWidget { : uncollapsedIconColor(context), icon), const SizedBox(width: 5), - Expanded(child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - style: TextStyle( - fontSize: 17, - height: (20 / 17), - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, - ).merge(weightVariableTextStyle(context, wght: 600)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - title(zulipLocalizations)))), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + TextSpan( + text: title(zulipLocalizations), + style: TextStyle( + fontSize: 17, + height: 20 / 17, + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton) + .merge(weightVariableTextStyle(context, wght: 600))), + buildTrailing(context) ?? const TextSpan(), + ])))), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), Padding(padding: const EdgeInsetsDirectional.only(end: 16), @@ -436,9 +443,11 @@ mixin _LongPressable on _HeaderItem { class _StreamHeaderItem extends _HeaderItem with _LongPressable { final Subscription subscription; + final bool isArchived; const _StreamHeaderItem({ required this.subscription, + required this.isArchived, required super.collapsed, required super.pageState, required super.count, @@ -449,6 +458,23 @@ class _StreamHeaderItem extends _HeaderItem with _LongPressable { @override String title(ZulipLocalizations zulipLocalizations) => subscription.name; @override IconData get icon => iconDataForStream(subscription); + @override InlineSpan? buildTrailing(BuildContext context) { + if (!isArchived) return null; + + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return WidgetSpan( + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 4), + child: Text( + zulipLocalizations.channelArchivedLabel, + style: TextStyle( + fontSize: 17, + height: 20 / 17, + color: designVariables.labelMessageHeaderArchived, + fontStyle: FontStyle.italic)))); + } @override Color collapsedIconColor(context) => colorSwatchFor(context, subscription).iconOnPlainBackground; @override Color uncollapsedIconColor(context) => @@ -495,6 +521,7 @@ class _StreamSection extends StatelessWidget { collapsed: collapsed, pageState: pageState, sectionContext: context, + isArchived: subscription.isArchived, ); return StickyHeaderItem( header: header, diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 1e95a1be49..b6645b643d 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -313,6 +313,7 @@ class MessageListAppBarTitle extends StatelessWidget { ZulipStream? stream, }) { final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); // A null [Icon.icon] makes a blank space. final icon = stream != null ? iconDataForStream(stream) : null; return Row( @@ -326,6 +327,17 @@ class MessageListAppBarTitle extends StatelessWidget { const SizedBox(width: 4), Flexible(child: Text( stream?.name ?? zulipLocalizations.unknownChannelName)), + if (stream?.isArchived ?? false) + // TODO(#1285): Avoid concatenating translated strings + Padding( + padding: EdgeInsetsDirectional.fromSTEB(4, 4, 0, 4), + child: Text( + zulipLocalizations.channelArchivedLabel, + style: TextStyle( + fontSize: 18, + // TODO(design): check if this is the right variable + color: designVariables.labelMessageHeaderArchived, + fontStyle: FontStyle.italic))), ]); } @@ -1102,6 +1114,18 @@ class StreamMessageRecipientHeader extends StatelessWidget { style: recipientHeaderTextStyle(context), overflow: TextOverflow.ellipsis), ), + if (stream?.isArchived ?? false) + // TODO(#1285): Avoid concatenating translated strings + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(4, 4, 0, 4), + child: Text( + zulipLocalizations.channelArchivedLabel, + style: recipientHeaderTextStyle(context, + // TODO(design): check if this is the right variable + color: designVariables.labelMessageHeaderArchived, + fontStyle: FontStyle.italic), + overflow: TextOverflow.ellipsis, + maxLines: 1)), Padding( // Figma has 5px horizontal padding around an 8px wide icon. // Icon is 16px wide here so horizontal padding is 1px. @@ -1220,9 +1244,13 @@ class DmRecipientHeader extends StatelessWidget { } } -TextStyle recipientHeaderTextStyle(BuildContext context, {FontStyle? fontStyle}) { +TextStyle recipientHeaderTextStyle( + BuildContext context, { + Color? color, + FontStyle? fontStyle, +}) { return TextStyle( - color: DesignVariables.of(context).title, + color: color ?? DesignVariables.of(context).title, fontSize: 16, letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: 16), height: (18 / 16), diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 062bb9743e..2241f4a011 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -85,6 +85,7 @@ class _SubscriptionListPageBodyState extends State wit final List pinned = []; final List unpinned = []; for (final subscription in store.subscriptions.values) { + if (subscription.isArchived) continue; if (subscription.pinToTop) { pinned.add(subscription); } else { @@ -188,6 +189,8 @@ class _SubscriptionList extends StatelessWidget { @override Widget build(BuildContext context) { + assert(subscriptions.every((subscription) => !subscription.isArchived)); + return SliverList.builder( itemCount: subscriptions.length, itemBuilder: (BuildContext context, int index) { diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index eea9677045..ae1e80d109 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -172,6 +172,7 @@ class DesignVariables extends ThemeExtension { groupDmConversationIcon: Colors.black.withValues(alpha: 0.5), groupDmConversationIconBg: const Color(0x33808080), inboxItemIconMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), + labelMessageHeaderArchived: const HSLColor.fromAHSL(0.5, 0, 0, 0.5).toColor(), loginOrDivider: const Color(0xffdedede), loginOrDividerText: const Color(0xff575757), modalBarrierColor: const Color(0xff000000).withValues(alpha: 0.3), @@ -232,6 +233,7 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) groupDmConversationIconBg: const Color(0x33cccccc), inboxItemIconMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), + labelMessageHeaderArchived: const HSLColor.fromAHSL(0.5, 0, 0, 0.5).toColor(), loginOrDivider: const Color(0xff424242), loginOrDividerText: const Color(0xffa8a8a8), modalBarrierColor: const Color(0xff000000).withValues(alpha: 0.5), @@ -294,6 +296,7 @@ class DesignVariables extends ThemeExtension { required this.groupDmConversationIcon, required this.groupDmConversationIconBg, required this.inboxItemIconMarker, + required this.labelMessageHeaderArchived, required this.loginOrDivider, required this.loginOrDividerText, required this.modalBarrierColor, @@ -364,6 +367,7 @@ class DesignVariables extends ThemeExtension { final Color groupDmConversationIcon; final Color groupDmConversationIconBg; final Color inboxItemIconMarker; + final Color labelMessageHeaderArchived; final Color loginOrDivider; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color loginOrDividerText; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color modalBarrierColor; @@ -421,6 +425,7 @@ class DesignVariables extends ThemeExtension { Color? groupDmConversationIcon, Color? groupDmConversationIconBg, Color? inboxItemIconMarker, + Color? labelMessageHeaderArchived, Color? loginOrDivider, Color? loginOrDividerText, Color? modalBarrierColor, @@ -477,6 +482,7 @@ class DesignVariables extends ThemeExtension { groupDmConversationIcon: groupDmConversationIcon ?? this.groupDmConversationIcon, groupDmConversationIconBg: groupDmConversationIconBg ?? this.groupDmConversationIconBg, inboxItemIconMarker: inboxItemIconMarker ?? this.inboxItemIconMarker, + labelMessageHeaderArchived: labelMessageHeaderArchived ?? this.labelMessageHeaderArchived, loginOrDivider: loginOrDivider ?? this.loginOrDivider, loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, modalBarrierColor: modalBarrierColor ?? this.modalBarrierColor, @@ -540,6 +546,7 @@ class DesignVariables extends ThemeExtension { groupDmConversationIcon: Color.lerp(groupDmConversationIcon, other.groupDmConversationIcon, t)!, groupDmConversationIconBg: Color.lerp(groupDmConversationIconBg, other.groupDmConversationIconBg, t)!, inboxItemIconMarker: Color.lerp(inboxItemIconMarker, other.inboxItemIconMarker, t)!, + labelMessageHeaderArchived: Color.lerp(labelMessageHeaderArchived, other.labelMessageHeaderArchived, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, modalBarrierColor: Color.lerp(modalBarrierColor, other.modalBarrierColor, t)!, diff --git a/test/example_data.dart b/test/example_data.dart index afa378aa7e..0d3c645b20 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -253,6 +253,7 @@ int _lastStreamId = 200; ZulipStream stream({ int? streamId, String? name, + bool? isArchived, String? description, String? renderedDescription, int? dateCreated, @@ -272,6 +273,7 @@ ZulipStream stream({ return ZulipStream( streamId: effectiveStreamId, name: effectiveName, + isArchived: isArchived ?? false, description: effectiveDescription, renderedDescription: renderedDescription ?? '

$effectiveDescription

', dateCreated: dateCreated ?? 1686774898, @@ -310,6 +312,7 @@ Subscription subscription( return Subscription( streamId: stream.streamId, name: stream.name, + isArchived: stream.isArchived, description: stream.description, renderedDescription: stream.renderedDescription, dateCreated: stream.dateCreated, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 8aeeec4eed..866e8da027 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -52,6 +52,7 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + bool? isArchived, }) async { addTearDown(testBinding.reset); assert(narrow.containsMessage(message)); @@ -65,7 +66,10 @@ Future setupToMessageActionSheet(WidgetTester tester, { ...narrow.otherRecipientIds.map((id) => eg.user(userId: id)), ]); if (message is StreamMessage) { - final stream = eg.stream(streamId: message.streamId); + final stream = eg.stream( + streamId: message.streamId, + isArchived: isArchived ?? false, + ); await store.addStream(stream); await store.addSubscription(eg.subscription(stream)); } @@ -134,7 +138,8 @@ void main() { await tester.pump(); check(find.byType(InboxPageBody)).findsOne(); - await tester.longPress(find.text(someChannel.name).hitTestable()); + await tester.longPress( + find.textContaining(findRichText: true, someChannel.name).first); await tester.pump(const Duration(milliseconds: 250)); } @@ -183,9 +188,8 @@ void main() { child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); await tester.pumpAndSettle(); - await tester.longPress(find.descendant( - of: find.byType(RecipientHeader), - matching: find.text(message.displayRecipient ?? ''))); + await tester.longPress( + find.textContaining(findRichText: true, message.displayRecipient ?? '').first); await tester.pump(const Duration(milliseconds: 250)); } @@ -479,15 +483,17 @@ void main() { /// If `isChannelMuted` is `null`, the user is not subscribed to the /// channel. Future setupToTopicActionSheet(WidgetTester tester, { + ZulipStream? channel, required bool? isChannelMuted, required UserTopicVisibilityPolicy visibilityPolicy, int? zulipFeatureLevel, }) async { addTearDown(testBinding.reset); + channel ??= someChannel; topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; await prepare( - channel: someChannel, + channel: channel, topic: topic, isChannelSubscribed: isChannelMuted != null, // shorthand; see dartdoc isChannelMuted: isChannelMuted, @@ -496,9 +502,13 @@ void main() { ); final message = eg.streamMessage( - stream: someChannel, topic: topic, sender: eg.otherUser); + stream: channel, topic: topic, sender: eg.otherUser); + await store.handleEvent(MessageEvent( + id: 1, + message: message, + localMessageId: null)); await showFromAppBar(tester, - channel: someChannel, topic: TopicName(topic), messages: [message]); + channel: channel, topic: TopicName(topic), messages: [message]); } void checkButtons(List expectedButtonFinders) { @@ -604,6 +614,18 @@ void main() { } }); + group('archived channels', () { + testWidgets('limited topic actions for archived channels', (tester) async { + final archivedChannel = eg.stream(isArchived: true); + + await setupToTopicActionSheet(tester, + channel: archivedChannel, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none); + checkButtons([]); + }); + }); + group('legacy: follow is unsupported when FL < 219', () { final testCases = [ (false, UserTopicVisibilityPolicy.muted, [unmute]), @@ -762,6 +784,30 @@ void main() { checkErrorDialog(tester, expectedTitle: 'Failed to mark topic as unresolved'); }); + + testWidgets('not offered in archived channel', (tester) async { + final archivedChannel = eg.stream(isArchived: true); + final message = eg.streamMessage(stream: archivedChannel, topic: 'zulip'); + + await prepare(channel: archivedChannel, topic: 'zulip'); + await showFromAppBar(tester, + channel: archivedChannel, + topic: TopicName('zulip'), + messages: [message]); + check(findButtonForLabel('Mark as resolved')).findsNothing(); + }); + + testWidgets('not offered in archived channel with resolved topic', (tester) async { + final archivedChannel = eg.stream(isArchived: true); + final message = eg.streamMessage(stream: archivedChannel, topic: '✔ zulip'); + + await prepare(channel: archivedChannel, topic: '✔ zulip'); + await showFromAppBar(tester, + channel: archivedChannel, + topic: TopicName('✔ zulip'), + messages: [message]); + check(findButtonForLabel('Mark as unresolved')).findsNothing(); + }); }); group('MarkTopicAsReadButton', () { @@ -1156,6 +1202,16 @@ void main() { await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); check(findQuoteAndReplyButton(tester)).isNull(); }); + + testWidgets('not offered in archived channels', (tester) async { + final message = eg.streamMessage(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + isArchived: true); + check(findQuoteAndReplyButton(tester)).isNull(); + }); }); group('MarkAsUnread', () { diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index e02fd97a15..4a16db83fb 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1080,6 +1080,32 @@ void main() { }); }); + group('in channel/topic narrow with archived channels', () { + void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown, + bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel); + + final narrowTestCases = [ + ('channel', const ChannelNarrow(1)), + ('topic', eg.topicNarrow(1, 'topic')), + ]; + + for (final (String narrowType, Narrow narrow) in narrowTestCases) { + testWidgets('error banner is shown in $narrowType narrow', (tester) async { + await prepareComposeBox(tester, + narrow: narrow, + streams: [eg.stream(streamId: 1, isArchived: true)]); + checkComposeBox(isShown: false); + }); + + testWidgets('compose box is shown in $narrowType narrow', (tester) async { + await prepareComposeBox(tester, + narrow: narrow, + streams: [eg.stream(streamId: 1, isArchived: false)]); + checkComposeBox(isShown: true); + }); + } + }); + group('in channel/topic narrow according to channel post policy', () { void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown, bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel); diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 9fa5de1bf3..583af43881 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -118,17 +118,27 @@ void main() { /// Find a row with the given label. Widget? findRowByLabel(WidgetTester tester, String label) { - final rowLabel = tester.widgetList( - find.text(label), - ).firstOrNull; - if (rowLabel == null) { - return null; + final textFinder = find.text(label); + if (textFinder.evaluate().isNotEmpty) { + return tester.widget( + find.ancestor( + of: textFinder.first, + matching: find.byType(Row)) + ); } - return tester.widget( - find.ancestor( - of: find.byWidget(rowLabel), - matching: find.byType(Row))); + final richTextFinder = find.byWidgetPredicate((widget) => + widget is RichText && + widget.text.toPlainText().contains(label)); + if (richTextFinder.evaluate().isNotEmpty) { + return tester.widget( + find.ancestor( + of: richTextFinder.first, + matching: find.byType(Row)) + ); + } + + return null; } /// Find the all-DMs header element. @@ -604,6 +614,22 @@ void main() { check(rectAfterTap).equals(rectBeforeTap); }); + testWidgets('shows archived label for archived streams', (tester) async { + final stream = eg.stream(isArchived: true); + final subscription = eg.subscription(stream); + await setupPage(tester, + streams: [stream], + subscriptions: [subscription], + unreadMessages: [eg.streamMessage(stream: stream)]); + await tester.pumpAndSettle(); + final headerRow = findStreamHeaderRow(tester, stream.streamId); + check(headerRow).isNotNull(); + final archivedLabelFinder = find.descendant( + of: find.byWidget(headerRow!), + matching: find.text('(archived)')); + check(archivedLabelFinder).findsOne(); + }); + // TODO check it remains collapsed even if you scroll far away and back // TODO check that it's always uncollapsed when it appears after being diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 53ad1334ef..9e9d630d72 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -241,6 +241,22 @@ void main() { of: find.byType(MessageListAppBarTitle), matching: find.byIcon(ZulipIcons.mute))).findsOne(); }); + + testWidgets('shows archived label in app bar for archived channels', (tester) async { + final archivedChannel = eg.stream(isArchived: true); + final message = eg.streamMessage(stream: archivedChannel, topic: 'topic'); + + await setupMessageListPage(tester, + narrow: ChannelNarrow(archivedChannel.streamId), + messages: [message], + streams: [archivedChannel], + subscriptions: [eg.subscription(archivedChannel)]); + final appBarFinder = find.byType(MessageListAppBarTitle); + check(appBarFinder).findsOne(); + check(find.descendant( + of: appBarFinder, + matching: find.text("(archived)"))).findsOne(); + }); }); group('presents message content appropriately', () { @@ -1133,6 +1149,19 @@ void main() { await tester.pump(); check(pushedRoutes).isEmpty(); }); + + testWidgets('shows archived label for archived streams', (tester) async { + final stream = eg.stream(isArchived: true); + final message = eg.streamMessage(stream: stream, topic: 'topic'); + + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), + messages: [message], + streams: [stream], + subscriptions: [eg.subscription(stream)]); + await tester.pump(); + check(findInMessageList("(archived)")).length.equals(1); + }); }); group('DmRecipientHeader', () { diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index a3fc13dac9..0728795c4f 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -95,6 +95,25 @@ void main() { check(isUnpinnedHeaderInTree()).isTrue(); }); + testWidgets('archived subscriptions are filtered out', (tester) async { + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(eg.stream(streamId: 1, isArchived: true), pinToTop: true), + eg.subscription(eg.stream(streamId: 2, isArchived: true), pinToTop: false), + eg.subscription(eg.stream(streamId: 3), pinToTop: true), + eg.subscription(eg.stream(streamId: 4), pinToTop: false), + ]); + + check(getItemCount()).equals(2); + check(isPinnedHeaderInTree()).isTrue(); + check(isUnpinnedHeaderInTree()).isTrue(); + + check(find.text('stream 1')).findsNothing(); + check(find.text('stream 2')).findsNothing(); + + check(find.text('stream 3')).findsOne(); + check(find.text('stream 4')).findsOne(); + }); + group('subscription sorting', () { Iterable listedStreamIds(WidgetTester tester) => tester .widgetList(find.byType(SubscriptionItem))