Skip to content

Better management of archived channel names #1344

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

chimnayajith
Copy link
Contributor

Pull Request

Description

This PR improves archived stream support in the mobile app to match the web app’s behavior. Key updates include:

  • Appending " (archived)" to stream names when viewing messages.
  • Updating UI elements (e.g., stream headers) to clearly indicate archived streams.
  • Filtering out archived channels from relevant pages.
  • Displaying the archived tag in a distinct font and color, consistent with the web app.
  • Replacing the compose box with a "no permission" error banner instead of offering message composition.

Related Issues

Screenshots

@chimnayajith chimnayajith force-pushed the 800-archived-channels branch 2 times, most recently from 24ed3b6 to 432b103 Compare February 11, 2025 19:37
@PIG208 PIG208 self-assigned this Feb 12, 2025
@PIG208 PIG208 added the maintainer review PR ready for review by Zulip maintainers label Feb 12, 2025
@PIG208 PIG208 self-requested a review February 12, 2025 20:46
Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! One more thing that I think needs to be handled is the topic actions sheet. None of the supported actions (follow/mute/unmute/resolve/unresolve) works on a topic in an archived channel. Not sure what's the best way to deal with this, though. It would be a good question to ask on #mobile-design.

Comment on lines 1413 to 1414
if (channel == null || !store.hasPostingPermission(inChannel: channel,
user: selfUser, byDate: DateTime.now())) {
user: selfUser, byDate: DateTime.now()) || channel.isArchived) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's reformat this to show that these expressions are parallel:

Suggested change
if (channel == null || !store.hasPostingPermission(inChannel: channel,
user: selfUser, byDate: DateTime.now())) {
user: selfUser, byDate: DateTime.now()) || channel.isArchived) {
if (channel == null
|| !store.hasPostingPermission(inChannel: channel,
user: selfUser, byDate: DateTime.now())
|| channel.isArchived) {

testWidgets('error banner is shown in $narrowType narrow', (tester) async {
await prepareComposeBox(tester,
narrow: narrow,
selfUser: eg.user(role: UserRole.administrator),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the user role relevant here? If not, this can be removed.

pinned.add(subscription);
} else {
unpinned.add(subscription);
if(!subscription.isArchived) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we can use continue here, so that the ifs don't need to be nested

@@ -187,10 +189,13 @@ class _SubscriptionList extends StatelessWidget {

@override
Widget build(BuildContext context) {
// Filtering out the archived subscriptions.
final activeSubscriptions = subscriptions.where((sub) => !sub.isArchived).toList();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this line of code is pretty straightforward, and the comment doesn't add much here, so let's just leave it out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, is it possible for subscriptions to contain archived subscriptions, or is it the job of SubscriptionListPageBody to ensure that no archived subscriptions are passed here?

If the latter, it might be better performance-wise to have an assertion that none of these subscriptions is archived, so that we don't need to re-filter the subscriptions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah now that I check, its already being handled. removed this part.

eg.subscription(eg.stream(streamId: 1, isArchived: true), pinToTop: true),
eg.subscription(eg.stream(streamId: 2), pinToTop: true),
eg.subscription(eg.stream(streamId: 3, isArchived: true), pinToTop: false),
eg.subscription(eg.stream(streamId: 4,), pinToTop: false),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: extra comma

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also reorder the list to make it easier to read:

    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),
    ]);

@@ -1062,7 +1085,7 @@ void main() {
.initNarrow.equals(DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId));
await tester.pumpAndSettle();
});

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks unintentional.

fontStyle: FontStyle.italic,
),
),
),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a specific design that we are following here (and other UI changes), it would be helpful to have a link in the commit message.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't a particular design that i could reference. I'm trying to keep it consistent with web.

@@ -595,6 +616,36 @@ void main() {
check(rectAfterTap).equals(rectBeforeTap);
});

testWidgets('shows archived label for archived streams', (tester) async {
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For tests, it is fine to not use ZulipLocalizations and have plain strings unless we are specifically testing something related to l10n. This way we have a better idea of what exactly we are expecting in tests.

]);

// Only non-archived streams
check(getItemCount()).equals(2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit ambiguous because it doesn't check if the remaining streams are the ones that are not archived.

@@ -347,6 +347,8 @@ class ZulipStream {
// TODO(server-8): added in FL 199, was previously only on [Subscription] objects
int? streamWeeklyTraffic;

final bool isArchived;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Make sure to match the order of these fields in the API documentation. See the dartdoc of ZulipStream to find the relevant piece.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems to be the order in the API documentation as I checked. Please let me know if its not

https://zulip.com/api/register-queue and go to subscriptions return value

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. The location the dartdoc points to has a different order. Let's match that:

/// For docs, search for "if stream"
/// in <https://zulip.com/api/register-queue>.
@JsonSerializable(fieldRename: FieldRename.snake)
class ZulipStream {

@chimnayajith chimnayajith force-pushed the 800-archived-channels branch 4 times, most recently from 1a8ae0f to 1bc6ec1 Compare February 28, 2025 04:56
@chimnayajith
Copy link
Contributor Author

chimnayajith commented Mar 3, 2025

The above comments were addressed and pushed. Waiting for clarification on comment above on CZO

@chimnayajith chimnayajith force-pushed the 800-archived-channels branch from 1bc6ec1 to f45792c Compare March 3, 2025 19:17
@PIG208 PIG208 self-requested a review March 6, 2025 18:53
Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update! Left some more comments.

@@ -84,6 +84,7 @@ class _SubscriptionListPageBodyState extends State<SubscriptionListPageBody> wit
final List<Subscription> pinned = [];
final List<Subscription> unpinned = [];
for (final subscription in store.subscriptions.values) {
if (subscription.isArchived) continue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make sure that _SubscriptionList checks for this with an assertion in its build method.

Padding(
padding: const EdgeInsetsDirectional.fromSTEB(4, 4, 0, 4),
child: Text(
' ${zulipLocalizations.channelArchivedLabel}',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the other place, #1285 is applicable here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also mention that the design is inspired by web in the commit message.

Comment on lines 1122 to 1123
style: recipientHeaderTextStyle(context).copyWith(
color: messageListTheme.streamRecipientHeaderChevronRight,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of calling copyWith immediately after getting the TextStyle from the helper, let's add color to that helper so we can do it in a single call.

Padding(
padding: EdgeInsetsDirectional.fromSTEB(4, 4, 0, 4),
child: Text(
' ${zulipLocalizations.channelArchivedLabel}',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar issue here.

widget.text.toPlainText().contains(stream.name)
),
);
expect(richTextFinder, findsOneWidget);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use check in our codebase instead. This one can be written as check(richTextFinder).findsOne().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment also applies to other similar places in the PR, so please look for them and update.

Comment on lines 622 to 629
final richTextFinder = find.descendant(
of: find.byWidget(headerRowFinder!),
matching: find.byWidgetPredicate((widget) =>
widget is RichText &&
widget.text.toPlainText().contains(stream.name)
),
);
expect(richTextFinder, findsOneWidget);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the implementation of findStreamHeaderRow, this stanza seems to be repetitive. Let's just remove this check. Another benefit of removing this is that we can focus more on checking the presence of the "(archived)" label.

Comment on lines 637 to 639
// Check that the Text widget has italic style
final archivedLabelText = tester.widget<Text>(archivedLabelFinder);
expect(archivedLabelText.style?.fontStyle, FontStyle.italic);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually leave out checks for specific styling unless there is some extra logic to it (changing style dynamically to different inputs, etc.)

@@ -1101,6 +1101,28 @@ void main() {
await tester.pump();
check(pushedRoutes).isEmpty();
});

testWidgets('shows archived label for archived streams', (tester) async {
final stream = eg.stream(streamId: 1, name: 'stream name', isArchived: true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, we can remove streamId here.

testWidgets('error banner is shown in $narrowType narrow', (tester) async {
await prepareComposeBox(tester,
narrow: narrow,
selfUser: eg.user(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove selfUser if the tests do not rely on it.

Comment on lines 78 to 95
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();
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Order this after "only unpinned subscriptions" because "basic subscriptions", "only pinned subscriptions" are more similar to that test and they should be next to each other.

@PIG208
Copy link
Member

PIG208 commented Mar 6, 2025

While addressing the review comments, a useful thing to do (and often necessary) is to try to find other places where the comment applies to during your self-reviews. This way we can save some back-and-forth revisions when there are some remaining known issues.

@chimnayajith chimnayajith force-pushed the 800-archived-channels branch from f45792c to 63925aa Compare March 15, 2025 02:35
@chimnayajith
Copy link
Contributor Author

@PIG208 Pushed a revision. PTAL. Removed unsupported options from the channel and message action sheets for archived streams.

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update! I just went through it again. Left some comments.

Comment on lines 236 to 237
final isChannelArchived = channel?.isArchived == true;
if (!isChannelArchived) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try to avoid making these checks more nested. We should keep them flat for readability.

I think the existing if/else structure makes it convenient to also check if the channel is archived; if it is, we just skip adding to visibilityOptions.

This also applies to ResolveUnresolveButton separately.

@@ -236,6 +237,7 @@ abstract class _HeaderItem extends StatelessWidget {
required this.count,
required this.hasMention,
required this.sectionContext,
this.isArchived = false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, it doesn't feel quite right for isArchived to be on _HeaderItem, since _AllDmsHeaderItem subclasses this and cannot be archived (which is why the default value is needed).

_HeaderItem can gain an abstract InlineSpan? buildTrailing(BuildContext context) method. If buildTrailing returns null, then the marker is not built at all. _StreamHeaderItem will override it to build "(archieved)" following the title.

Meanwhile, we can move isArchived to _StreamHeaderItem and make it a required parameter without a default value.

You can find an example of this setup from _MenuButton.buildLeading.

await tester.longPress(find.text(someChannel.name).hitTestable());
final row = findRowByLabel(tester, someChannel.name);
check(row).isNotNull();
final materialFinders = find.ancestor(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: materialFinders -> materialFinder (there is just a singular Finder)

await setupToMessageActionSheet(tester,
message: message,
narrow: TopicNarrow.ofMessage(message));
check(findQuoteAndReplyButton(tester)).isNotNull();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check seems contradictory to the name of the test, which claims that quote-and-reply is "not offered in archived channels". Perhaps either the implementation or the test is not working as intended?

Comment on lines 160 to 165
final row = findRowByLabel(tester, someChannel.name);
check(row).isNotNull();
final materialFinders = find.ancestor(
of: find.byWidget(row!),
matching: find.byType(Material)).hitTestable();
await tester.longPress(materialFinders.first);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or perhaps it is better to simplify this to:

Suggested change
final row = findRowByLabel(tester, someChannel.name);
check(row).isNotNull();
final materialFinders = find.ancestor(
of: find.byWidget(row!),
matching: find.byType(Material)).hitTestable();
await tester.longPress(materialFinders.first);
await tester.longPress(
find.textContaining(findRichText: true, someChannel.name).first);

and then we can remove the findRowByLabel helper.

Comment on lines 622 to 628
group('archived channels', () {
testWidgets('limited topic actions for archived channels', (tester) async {
final archivedChannel = eg.stream(isArchived: true);
final someTopic = 'my topic';

final message = eg.streamMessage(
stream: archivedChannel,
topic: someTopic,
flags: []);

await prepare(
channel: archivedChannel,
topic: someTopic,
isChannelSubscribed: true,
isChannelMuted: false,
visibilityPolicy: UserTopicVisibilityPolicy.none,
unreadMsgs: eg.unreadMsgs(channels: [
eg.unreadChannelMsgs(
streamId: archivedChannel.streamId,
topic: someTopic,
unreadMessageIds: [message.id]),
]));

await showFromAppBar(tester,
channel: archivedChannel,
topic: someTopic,
messages: [message]);
check(mute).findsNothing();
check(unmute).findsNothing();
check(follow).findsNothing();
check(unfollow).findsNothing();

check(findButtonForLabel('Mark as resolved')).findsNothing();
check(findButtonForLabel('Mark as unresolved')).findsNothing();

check(findButtonForLabel('Mark topic as read')).findsOne();
});
});

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can simplify this test by extending and making use of the existing helper:

diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index a44b20f71..9624c93a3 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -494,15 +494,17 @@ void main() {
       /// If `isChannelMuted` is `null`, the user is not subscribed to the
       /// channel.
       Future<void> 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,
@@ -511,9 +513,10 @@ 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));
         await showFromAppBar(tester,
-          channel: someChannel, topic: topic, messages: [message]);
+          channel: channel, topic: topic, messages: [message]);
       }
 
       void checkButtons(List<Finder> expectedButtonFinders) {
@@ -619,43 +622,14 @@ void main() {
         }
       });
 
-      group('archived channels', () {
-        testWidgets('limited topic actions for archived channels', (tester) async {
-          final archivedChannel = eg.stream(isArchived: true);
-          final someTopic = 'my topic';
+      testWidgets('limited topic actions for archived channels', (tester) async {
+        final archivedChannel = eg.stream(isArchived: true);
 
-          final message = eg.streamMessage(
-            stream: archivedChannel,
-            topic: someTopic,
-            flags: []);
-
-          await prepare(
-            channel: archivedChannel,
-            topic: someTopic,
-            isChannelSubscribed: true,
-            isChannelMuted: false,
-            visibilityPolicy: UserTopicVisibilityPolicy.none,
-            unreadMsgs: eg.unreadMsgs(channels: [
-              eg.unreadChannelMsgs(
-                streamId: archivedChannel.streamId,
-                topic: someTopic,
-                unreadMessageIds: [message.id]),
-            ]));
-
-          await showFromAppBar(tester,
-            channel: archivedChannel,
-            topic: someTopic,
-            messages: [message]);
-          check(mute).findsNothing();
-          check(unmute).findsNothing();
-          check(follow).findsNothing();
-          check(unfollow).findsNothing();
-
-          check(findButtonForLabel('Mark as resolved')).findsNothing();
-          check(findButtonForLabel('Mark as unresolved')).findsNothing();
-
-          check(findButtonForLabel('Mark topic as read')).findsOne();
-        });
+        await setupToTopicActionSheet(tester,
+          channel: archivedChannel,
+          isChannelMuted: false,
+          visibilityPolicy: UserTopicVisibilityPolicy.none);
+        checkButtons([]);
       });

While writing tests like this, it is usually helpful to see how the adjacent tests are written, and try to reuse the test helpers. Not only does that simplify the test, it also makes them look more coherent with the surrounding tests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposed test only checks the buttons relevant to the test group ("UserTopicUpdateButton") this is in, as I feel that checks for the other buttons like "Mark as resolved/unresolved" belong to the other corresponding test groups like "ResolveUnresolveButton".

Comment on lines 1223 to 1231
testWidgets('offered in non-archived channels', (tester) async {
final channel = eg.stream(isArchived: false);
final message = eg.streamMessage(stream: channel);

await setupToMessageActionSheet(tester,
message: message,
narrow: TopicNarrow.ofMessage(message));
check(findQuoteAndReplyButton(tester)).isNotNull();
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the other tests above do not have a test for the opposite case because it is covered in the earlier tests that assume the channel is not archived (which is the boring/default case). We can drop this test and then flatten the group.

await tester.pumpAndSettle();
final headerRow = findStreamHeaderRow(tester, stream.streamId);
check(headerRow).isNotNull();

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove this empty line as the lines before/after it are relevant.

@@ -1131,6 +1131,19 @@ void main() {
await tester.pump();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also needs a test for the app bar.

@chimnayajith chimnayajith force-pushed the 800-archived-channels branch from 63925aa to a045e5c Compare April 11, 2025 09:42
Design inspired by the web version.

Fixes zulip#800
@chimnayajith chimnayajith force-pushed the 800-archived-channels branch from a045e5c to 5390571 Compare April 11, 2025 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
maintainer review PR ready for review by Zulip maintainers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support better management of names of archived channels
2 participants