Skip to content

Commit 7e7f0a8

Browse files
committed
mute: Recognize unmuted topics in muted streams
Fixes: #5362
1 parent 18831f9 commit 7e7f0a8

File tree

10 files changed

+211
-37
lines changed

10 files changed

+211
-37
lines changed

src/action-sheets/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ export const constructTopicActionButtons = (args: {|
653653
buttons.push(unmuteTopic);
654654
break;
655655
case UserTopicVisibilityPolicy.None:
656+
case UserTopicVisibilityPolicy.Unmuted:
656657
buttons.push(muteTopic);
657658
break;
658659
}

src/api/modelTypes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,11 @@ export type Topic = $ReadOnly<{|
643643
export enum UserTopicVisibilityPolicy {
644644
None = 0,
645645
Muted = 1,
646+
// Not in the API docs yet, but the API has been agreed on:
647+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/muted.20topics/near/1501574
648+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/muted.20topics/near/1528885
649+
// TODO(server): delete this comment once documented
650+
Unmuted = 2,
646651
}
647652

648653
/**

src/chat/__tests__/narrowsSelectors-test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { NULL_SUBSCRIPTION } from '../../nullObjects';
2424
import * as eg from '../../__tests__/lib/exampleData';
2525
import { makeMuteState } from '../../mute/__tests__/mute-testlib';
26+
import { UserTopicVisibilityPolicy } from '../../api/modelTypes';
2627

2728
describe('getMessagesForNarrow', () => {
2829
const message = eg.streamMessage({ id: 123 });
@@ -96,6 +97,7 @@ describe('getShownMessagesForNarrow', () => {
9697
const subscription = eg.subscription;
9798
const mutedSubscription = { ...subscription, in_home_view: false };
9899
const muteTopic = makeMuteState([[stream, message.subject]]);
100+
const unmuteTopic = makeMuteState([[stream, message.subject, UserTopicVisibilityPolicy.Unmuted]]);
99101

100102
const makeStateGeneral = (message, narrow, extra) =>
101103
eg.reduxStatePlus({
@@ -126,6 +128,12 @@ describe('getShownMessagesForNarrow', () => {
126128
expect(shown(makeState({ subscriptions: [mutedSubscription] }))).toEqual(false);
127129
});
128130

131+
test('stream message shown if topic unmuted, even though stream muted', () => {
132+
expect(shown(makeState({ subscriptions: [mutedSubscription], mute: unmuteTopic }))).toEqual(
133+
true,
134+
);
135+
});
136+
129137
test('stream message hidden if topic muted', () => {
130138
expect(shown(makeState({ mute: muteTopic }))).toEqual(false);
131139
});

src/chat/narrowsSelectors.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
caseNarrow,
2424
streamIdOfNarrow,
2525
} from '../utils/narrow';
26-
import { getMute, isTopicVisibleInStream } from '../mute/muteModel';
26+
import { getMute, isTopicVisibleInStream, isTopicVisible } from '../mute/muteModel';
2727
import { NULL_ARRAY, NULL_SUBSCRIPTION } from '../nullObjects';
2828
import * as logging from '../utils/logging';
2929
import { getStreamsById, getSubscriptionsById } from '../subscriptions/subscriptionSelectors';
@@ -115,14 +115,12 @@ export const getShownMessagesForNarrow: Selector<$ReadOnlyArray<Message | Outbox
115115
const sub = subscriptions.get(message.stream_id);
116116
if (!sub) {
117117
// If there's no matching subscription, then the user must have
118-
// unsubscribed from the stream since the message was received. Leave
119-
// those messages out of this view, just like for a muted stream.
118+
// unsubscribed from the stream since the message was received.
119+
// Leave those messages out of this view, just as we would if
120+
// the user had muted the stream (without unmuting topics).
120121
return false;
121122
}
122-
return (
123-
sub.in_home_view
124-
&& isTopicVisibleInStream(message.stream_id, message.subject, mute)
125-
);
123+
return isTopicVisible(message.stream_id, message.subject, sub, mute);
126124
}),
127125

128126
stream: _ =>

src/mute/__tests__/mute-testlib.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,23 @@ export const makeUserTopic = (
1616
last_updated: 12345, // arbitrary value; we ignore last_updated here
1717
});
1818

19-
export const makeMuteState = (data: $ReadOnlyArray<[Stream, string]>): MuteState =>
19+
/**
20+
* Convenience constructor for a MuteState.
21+
*
22+
* The tuples are (stream, topic, policy).
23+
* The policy defaults to UserTopicVisibilityPolicy.Muted.
24+
*/
25+
export const makeMuteState = (
26+
data: $ReadOnlyArray<[Stream, string] | [Stream, string, UserTopicVisibilityPolicy]>,
27+
): MuteState =>
2028
reducer(
2129
undefined,
2230
eg.mkActionRegisterComplete({
23-
user_topics: data.map(([stream, topic]) =>
24-
makeUserTopic(stream, topic, UserTopicVisibilityPolicy.Muted),
25-
),
31+
user_topics: data.map(args => {
32+
// $FlowIgnore[invalid-tuple-index]: we're supplying a default
33+
const [stream, topic, policy = UserTopicVisibilityPolicy.Muted] = args;
34+
return makeUserTopic(stream, topic, policy);
35+
}),
2636
}),
2737
eg.reduxState(),
2838
);

src/mute/__tests__/muteModel-test.js

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
import deepFreeze from 'deep-freeze';
33

44
import fullReducer from '../../boot/reducers';
5-
import { getMute, getTopicVisibilityPolicy, isTopicVisibleInStream, reducer } from '../muteModel';
5+
import {
6+
getMute,
7+
getTopicVisibilityPolicy,
8+
isTopicVisible,
9+
isTopicVisibleInStream,
10+
reducer,
11+
} from '../muteModel';
612
import { EVENT, EVENT_MUTED_TOPICS } from '../../actionConstants';
713
import * as eg from '../../__tests__/lib/exampleData';
814
import { makeMuteState, makeUserTopic } from './mute-testlib';
@@ -31,6 +37,13 @@ describe('getters', () => {
3137
test('with topic muted', () => {
3238
check(makeMuteState([[eg.stream, 'topic']]), UserTopicVisibilityPolicy.Muted);
3339
});
40+
41+
test('with topic unmuted', () => {
42+
check(
43+
makeMuteState([[eg.stream, 'topic', UserTopicVisibilityPolicy.Unmuted]]),
44+
UserTopicVisibilityPolicy.Unmuted,
45+
);
46+
});
3447
});
3548

3649
describe('isTopicVisibleInStream', () => {
@@ -49,29 +62,81 @@ describe('getters', () => {
4962
test('with topic muted', () => {
5063
check(makeMuteState([[eg.stream, 'topic']]), false);
5164
});
65+
66+
test('with topic unmuted', () => {
67+
check(makeMuteState([[eg.stream, 'topic', UserTopicVisibilityPolicy.Unmuted]]), true);
68+
});
69+
});
70+
71+
describe('isTopicVisible', () => {
72+
function check(streamMuted, topicPolicy, expected) {
73+
const subscription = { ...eg.subscription, in_home_view: !streamMuted };
74+
const state = makeMuteState(
75+
topicPolicy === UserTopicVisibilityPolicy.None ? [] : [[eg.stream, 'topic', topicPolicy]],
76+
);
77+
expect(isTopicVisible(eg.stream.stream_id, 'topic', subscription, state)).toEqual(expected);
78+
}
79+
80+
test('stream unmuted, topic-policy None', () => {
81+
check(false, UserTopicVisibilityPolicy.None, true);
82+
});
83+
84+
test('stream unmuted, topic-policy Muted', () => {
85+
check(false, UserTopicVisibilityPolicy.Muted, false);
86+
});
87+
88+
test('stream unmuted, topic-policy Unmuted', () => {
89+
check(false, UserTopicVisibilityPolicy.Unmuted, true);
90+
});
91+
92+
test('stream muted, topic-policy None', () => {
93+
check(true, UserTopicVisibilityPolicy.None, false);
94+
});
95+
96+
test('stream muted, topic-policy Muted', () => {
97+
check(true, UserTopicVisibilityPolicy.Muted, false);
98+
});
99+
100+
test('stream muted, topic-policy Unmuted', () => {
101+
check(true, UserTopicVisibilityPolicy.Unmuted, true);
102+
});
52103
});
53104
});
54105

55106
describe('reducer', () => {
56107
describe('REGISTER_COMPLETE', () => {
57108
test('in modern user_topics format: unit test', () => {
58109
const action = eg.mkActionRegisterComplete({
59-
user_topics: [makeUserTopic(eg.stream, 'topic', UserTopicVisibilityPolicy.Muted)],
110+
user_topics: [
111+
makeUserTopic(eg.stream, 'topic', UserTopicVisibilityPolicy.Muted),
112+
makeUserTopic(eg.stream, 'other topic', UserTopicVisibilityPolicy.Unmuted),
113+
],
60114
});
61115
expect(reducer(initialState, action, eg.plusReduxState)).toEqual(
62-
makeMuteState([[eg.stream, 'topic']]),
116+
makeMuteState([
117+
[eg.stream, 'topic', UserTopicVisibilityPolicy.Muted],
118+
[eg.stream, 'other topic', UserTopicVisibilityPolicy.Unmuted],
119+
]),
63120
);
64121
});
65122

66123
test('in modern user_topics format: end-to-end test', () => {
67124
const action = eg.mkActionRegisterComplete({
68125
streams: [eg.stream],
69126
subscriptions: [eg.subscription],
70-
user_topics: [makeUserTopic(eg.stream, 'topic', UserTopicVisibilityPolicy.Muted)],
127+
user_topics: [
128+
makeUserTopic(eg.stream, 'topic', UserTopicVisibilityPolicy.Muted),
129+
makeUserTopic(eg.stream, 'other topic', UserTopicVisibilityPolicy.Unmuted),
130+
],
71131
});
72132
const newState = tryGetActiveAccountState(fullReducer(eg.plusReduxState, action));
73133
expect(newState).toBeTruthy();
74-
expect(newState && getMute(newState)).toEqual(makeMuteState([[eg.stream, 'topic']]));
134+
expect(newState && getMute(newState)).toEqual(
135+
makeMuteState([
136+
[eg.stream, 'topic', UserTopicVisibilityPolicy.Muted],
137+
[eg.stream, 'other topic', UserTopicVisibilityPolicy.Unmuted],
138+
]),
139+
);
75140
});
76141

77142
test('in old muted_topics format: unit test', () => {
@@ -117,21 +182,37 @@ describe('reducer', () => {
117182
check(
118183
initialState,
119184
makeUserTopic(eg.stream, 'topic', UserTopicVisibilityPolicy.Muted),
120-
makeMuteState([[eg.stream, 'topic']]),
185+
makeMuteState([[eg.stream, 'topic', UserTopicVisibilityPolicy.Muted]]),
121186
);
122187
});
123188

124189
test('add in existing stream', () => {
125190
check(
126-
makeMuteState([[eg.stream, 'topic']]),
127-
makeUserTopic(eg.stream, 'other topic', UserTopicVisibilityPolicy.Muted),
191+
makeMuteState([[eg.stream, 'topic', UserTopicVisibilityPolicy.Muted]]),
192+
makeUserTopic(eg.stream, 'other topic', UserTopicVisibilityPolicy.Unmuted),
128193
makeMuteState([
129-
[eg.stream, 'topic'],
130-
[eg.stream, 'other topic'],
194+
[eg.stream, 'topic', UserTopicVisibilityPolicy.Muted],
195+
[eg.stream, 'other topic', UserTopicVisibilityPolicy.Unmuted],
131196
]),
132197
);
133198
});
134199

200+
test('change Muted -> Unmuted', () => {
201+
check(
202+
makeMuteState([[eg.stream, 'topic', UserTopicVisibilityPolicy.Muted]]),
203+
makeUserTopic(eg.stream, 'topic', UserTopicVisibilityPolicy.Unmuted),
204+
makeMuteState([[eg.stream, 'topic', UserTopicVisibilityPolicy.Unmuted]]),
205+
);
206+
});
207+
208+
test('change Unmuted -> Muted', () => {
209+
check(
210+
makeMuteState([[eg.stream, 'topic', UserTopicVisibilityPolicy.Unmuted]]),
211+
makeUserTopic(eg.stream, 'topic', UserTopicVisibilityPolicy.Muted),
212+
makeMuteState([[eg.stream, 'topic', UserTopicVisibilityPolicy.Muted]]),
213+
);
214+
});
215+
135216
test('remove, with others in stream', () => {
136217
check(
137218
makeMuteState([

src/mute/muteModel.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
/* @flow strict-local */
22
import Immutable from 'immutable';
33

4-
import type { MuteState, PerAccountApplicableAction, PerAccountState } from '../types';
4+
import type {
5+
MuteState,
6+
PerAccountApplicableAction,
7+
PerAccountState,
8+
Subscription,
9+
} from '../types';
510
import { UserTopicVisibilityPolicy } from '../api/modelTypes';
611
import { EventTypes } from '../api/eventTypes';
712
import {
@@ -40,7 +45,14 @@ export function getTopicVisibilityPolicy(
4045
* Whether this topic should appear when already focusing on its stream.
4146
*
4247
* This is false if the user's visibility policy for the topic is Muted,
43-
* and true if the policy is None.
48+
* and true if the policy is Unmuted or None.
49+
*
50+
* This function is appropriate for muting calculations in UI contexts that
51+
* are already specific to a stream: for example the stream's unread count,
52+
* or the message list in the stream's narrow.
53+
*
54+
* For UI contexts that are not specific to a particular stream, see
55+
* `isTopicVisible`.
4456
*/
4557
export function isTopicVisibleInStream(streamId: number, topic: string, mute: MuteState): boolean {
4658
const policy = getTopicVisibilityPolicy(mute, streamId, topic);
@@ -49,6 +61,35 @@ export function isTopicVisibleInStream(streamId: number, topic: string, mute: Mu
4961
return true;
5062
case UserTopicVisibilityPolicy.Muted:
5163
return false;
64+
case UserTopicVisibilityPolicy.Unmuted:
65+
return true;
66+
}
67+
}
68+
69+
/**
70+
* Whether this topic should appear when not specifically focusing on this stream.
71+
*
72+
* This takes into account the user's visibility policy for the stream
73+
* overall, as well as their policy for this topic.
74+
*
75+
* For UI contexts that are specific to a particular stream, see
76+
* `isTopicVisibleInStream`.
77+
*/
78+
export function isTopicVisible(
79+
streamId: number,
80+
topic: string,
81+
subscription: Subscription,
82+
mute: MuteState,
83+
): boolean {
84+
switch (getTopicVisibilityPolicy(mute, streamId, topic)) {
85+
case UserTopicVisibilityPolicy.None: {
86+
const streamMuted = !subscription.in_home_view;
87+
return !streamMuted;
88+
}
89+
case UserTopicVisibilityPolicy.Muted:
90+
return false;
91+
case UserTopicVisibilityPolicy.Unmuted:
92+
return true;
5293
}
5394
}
5495

src/topics/topicSelectors.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getTopics } from '../directSelectors';
66
import { getUnread, getUnreadCountForTopic } from '../unread/unreadModel';
77
import { NULL_ARRAY } from '../nullObjects';
88
import { isStreamNarrow, streamIdOfNarrow } from '../utils/narrow';
9-
import { getMute, isTopicVisibleInStream } from '../mute/muteModel';
9+
import { getMute, isTopicVisible } from '../mute/muteModel';
1010
import { getSubscriptionsById } from '../subscriptions/subscriptionSelectors';
1111

1212
export const getTopicsForNarrow: Selector<$ReadOnlyArray<string>, Narrow> = createSelector(
@@ -36,13 +36,14 @@ export const getTopicsForStream: Selector<?$ReadOnlyArray<TopicExtended>, number
3636
return undefined;
3737
}
3838

39-
// If we're looking at a stream the user isn't subscribed to, then
40-
// they won't see unreads from it even if they somehow have
41-
// individual topics set to unmuted. So effectively it's all muted.
42-
const streamMuted = subscription ? !subscription.in_home_view : true;
43-
4439
return topicList.map(({ name, max_id }): TopicExtended => {
45-
const isMuted = streamMuted || !isTopicVisibleInStream(streamId, name, mute);
40+
// prettier-ignore
41+
const isMuted = subscription
42+
? !isTopicVisible(streamId, name, subscription, mute)
43+
// If we're looking at a stream the user isn't subscribed to, then
44+
// they won't see unreads from it even if they somehow have
45+
// individual topics set to unmuted. So effectively it's all muted.
46+
: true;
4647
const unreadCount = getUnreadCountForTopic(unread, streamId, name);
4748
return { name, max_id, isMuted, unreadCount };
4849
});

0 commit comments

Comments
 (0)