Skip to content

Commit 5f44dd1

Browse files
author
Amin Mahboubi
authored
Fix/unread count mutes (#498)
* add userMuteStatus * refactor generateMsg to test utils * skip muted users in unreadCounts * message.new updates unreadCount correctly * reversing the unread count condition
1 parent 3527d20 commit 5f44dd1

File tree

6 files changed

+307
-68
lines changed

6 files changed

+307
-68
lines changed

src/channel.ts

Lines changed: 33 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,20 @@ export class Channel<
11271127
}
11281128
}
11291129

1130+
_countMessageAsUnread(message: {
1131+
shadowed?: boolean;
1132+
silent?: boolean;
1133+
user?: { id?: string } | null;
1134+
}) {
1135+
if (message.shadowed) return false;
1136+
if (message.silent) return false;
1137+
if (message.user?.id === this.getClient().userID) return false;
1138+
if (message.user?.id && this.getClient().userMuteStatus(message.user.id))
1139+
return false;
1140+
1141+
return true;
1142+
}
1143+
11301144
/**
11311145
* countUnread - Count of unread messages
11321146
*
@@ -1135,27 +1149,12 @@ export class Channel<
11351149
* @return {number} Unread count
11361150
*/
11371151
countUnread(lastRead?: Date | Immutable.ImmutableDate | null) {
1138-
if (!lastRead) {
1139-
return this.state.unreadCount;
1140-
}
1152+
if (!lastRead) return this.state.unreadCount;
11411153

11421154
let count = 0;
1143-
for (const m of this.state.messages.asMutable()) {
1144-
const message = m.asMutable({ deep: true });
1145-
if (this.getClient().userID === message.user?.id) {
1146-
continue;
1147-
}
1148-
if (m.shadowed) {
1149-
continue;
1150-
}
1151-
if (m.silent) {
1152-
continue;
1153-
}
1154-
if (lastRead == null) {
1155-
count++;
1156-
continue;
1157-
}
1158-
if (m.created_at > lastRead) {
1155+
for (let i = 0; i < this.state.messages.length; i += 1) {
1156+
const message = this.state.messages[i];
1157+
if (message.created_at > lastRead && this._countMessageAsUnread(message)) {
11591158
count++;
11601159
}
11611160
}
@@ -1169,27 +1168,17 @@ export class Channel<
11691168
*/
11701169
countUnreadMentions() {
11711170
const lastRead = this.lastRead();
1171+
const userID = this.getClient().userID;
1172+
11721173
let count = 0;
1173-
for (const m of this.state.messages.asMutable()) {
1174-
const message = m.asMutable({ deep: true });
1175-
if (this.getClient().userID === message.user?.id) {
1176-
continue;
1177-
}
1178-
if (m.shadowed) {
1179-
continue;
1180-
}
1181-
if (m.silent) {
1182-
continue;
1183-
}
1184-
if (lastRead == null) {
1174+
for (let i = 0; i < this.state.messages.length; i += 1) {
1175+
const message = this.state.messages[i];
1176+
if (
1177+
this._countMessageAsUnread(message) &&
1178+
(!lastRead || message.created_at > lastRead) &&
1179+
message.mentioned_users?.find((u) => u.id === userID)
1180+
) {
11851181
count++;
1186-
continue;
1187-
}
1188-
if (m.created_at > lastRead) {
1189-
const userID = this.getClient().userID;
1190-
if (m.mentioned_users?.findIndex((u) => u.id === userID) !== -1) {
1191-
count++;
1192-
}
11931182
}
11941183
}
11951184
return count;
@@ -1555,13 +1544,14 @@ export class Channel<
15551544
}
15561545
break;
15571546
case 'message.new':
1558-
if (event.user?.id === this.getClient().user?.id) {
1559-
s.unreadCount = 0;
1560-
} else {
1561-
if (!event.message?.shadowed) s.unreadCount = s.unreadCount + 1;
1562-
}
15631547
if (event.message) {
15641548
s.addMessageSorted(event.message);
1549+
1550+
if (event.user?.id === this.getClient().user?.id) {
1551+
s.unreadCount = 0;
1552+
} else if (this._countMessageAsUnread(event.message)) {
1553+
s.unreadCount = s.unreadCount + 1;
1554+
}
15651555
}
15661556
break;
15671557
case 'message.updated':

src/client.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
Message,
5656
MessageFilters,
5757
MessageResponse,
58+
Mute,
5859
MuteUserOptions,
5960
MuteUserResponse,
6061
PartialUserUpdate,
@@ -135,6 +136,7 @@ export class StreamChat<
135136
};
136137
logger: Logger;
137138
mutedChannels: ChannelMute<ChannelType, CommandType, UserType>[];
139+
mutedUsers: Mute<UserType>[];
138140
node: boolean;
139141
options: StreamChatOptions;
140142
secret?: string;
@@ -179,6 +181,7 @@ export class StreamChat<
179181
this.state = new ClientState<UserType>();
180182
// a list of channels to hide ws events from
181183
this.mutedChannels = [];
184+
this.mutedUsers = [];
182185

183186
// set the secret
184187
if (secretOrOptions && isString(secretOrOptions)) {
@@ -926,6 +929,7 @@ export class StreamChat<
926929
client.user = event.me;
927930
client.state.updateUser(event.me);
928931
client.mutedChannels = event.me.channel_mutes;
932+
client.mutedUsers = event.me.mutes;
929933
}
930934

931935
if (event.channel && event.type === 'notification.message_new') {
@@ -935,6 +939,10 @@ export class StreamChat<
935939
if (event.type === 'notification.channel_mutes_updated' && event.me?.channel_mutes) {
936940
this.mutedChannels = event.me.channel_mutes;
937941
}
942+
943+
if (event.type === 'notification.mutes_updated' && event.me?.mutes) {
944+
this.mutedUsers = event.me.mutes;
945+
}
938946
}
939947

940948
_muteStatus(cid: string) {
@@ -1701,6 +1709,21 @@ export class StreamChat<
17011709
});
17021710
}
17031711

1712+
/** userMuteStatus - check if a user is muted or not, can be used after setUser() is called
1713+
*
1714+
* @param {string} targetID
1715+
* @returns {boolean}
1716+
*/
1717+
userMuteStatus(targetID: string) {
1718+
if (!this.user || !this.wsPromise)
1719+
throw new Error('Make sure to await setUser() first.');
1720+
1721+
for (let i = 0; i < this.mutedUsers.length; i += 1) {
1722+
if (this.mutedUsers[i].target.id === targetID) return true;
1723+
}
1724+
return false;
1725+
}
1726+
17041727
/**
17051728
* flagMessage - flag a message
17061729
* @param {string} targetMessageID

test/unit/channel.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import chai from 'chai';
2+
import { Channel } from '../../src/channel';
3+
import { generateMsg } from './utils';
4+
5+
const expect = chai.expect;
6+
7+
describe('Channel count unread', function () {
8+
const user = { id: 'user' };
9+
const lastRead = new Date('2020-01-01T00:00:00');
10+
11+
const channel = new Channel({}, 'messaging', 'id');
12+
channel.lastRead = () => lastRead;
13+
channel.getClient = () => ({
14+
user,
15+
userID: 'user',
16+
userMuteStatus: (targetId) => targetId.startsWith('mute'),
17+
});
18+
19+
const ignoredMessages = [
20+
generateMsg({ date: '2018-01-01T00:00:00', mentioned_users: [user] }),
21+
generateMsg({ date: '2019-01-01T00:00:00' }),
22+
generateMsg({ date: '2020-01-01T00:00:00' }),
23+
generateMsg({
24+
date: '2023-01-01T00:00:00',
25+
shadowed: true,
26+
mentioned_users: [user],
27+
}),
28+
generateMsg({
29+
date: '2024-01-01T00:00:00',
30+
silent: true,
31+
mentioned_users: [user],
32+
}),
33+
generateMsg({
34+
date: '2025-01-01T00:00:00',
35+
user: { id: 'mute1' },
36+
mentioned_users: [user],
37+
}),
38+
];
39+
channel.state.addMessagesSorted(ignoredMessages);
40+
41+
it('_countMessageAsUnread should return false shadowed or silent messages', function () {
42+
expect(channel._countMessageAsUnread({ shadowed: true })).not.to.be.ok;
43+
expect(channel._countMessageAsUnread({ silent: true })).not.to.be.ok;
44+
});
45+
46+
it('_countMessageAsUnread should return false for current user messages', function () {
47+
expect(channel._countMessageAsUnread({ user })).not.to.be.ok;
48+
});
49+
50+
it('_countMessageAsUnread should return false for muted user', function () {
51+
expect(channel._countMessageAsUnread({ user: { id: 'mute1' } })).not.to.be.ok;
52+
});
53+
54+
it('_countMessageAsUnread should return true for unmuted user', function () {
55+
expect(channel._countMessageAsUnread({ user: { id: 'unmute' } })).to.be.ok;
56+
});
57+
58+
it('_countMessageAsUnread should return true for other messages', function () {
59+
expect(
60+
channel._countMessageAsUnread({
61+
shadowed: false,
62+
silent: false,
63+
user: { id: 'random' },
64+
}),
65+
).to.be.ok;
66+
});
67+
68+
it('countUnread should return state.unreadCount without lastRead', function () {
69+
expect(channel.countUnread()).to.be.equal(channel.state.unreadCount);
70+
channel.state.unreadCount = 10;
71+
expect(channel.countUnread()).to.be.equal(10);
72+
channel.state.unreadCount = 0;
73+
});
74+
75+
it('countUnread should return correct count', function () {
76+
expect(channel.countUnread(lastRead)).to.be.equal(0);
77+
channel.state.addMessagesSorted([
78+
generateMsg({ date: '2021-01-01T00:00:00' }),
79+
generateMsg({ date: '2022-01-01T00:00:00' }),
80+
]);
81+
expect(channel.countUnread(lastRead)).to.be.equal(2);
82+
});
83+
84+
it('countUnreadMentions should return correct count', function () {
85+
expect(channel.countUnreadMentions()).to.be.equal(0);
86+
channel.state.addMessageSorted(
87+
generateMsg({
88+
date: '2021-01-01T00:00:00',
89+
mentioned_users: [user, { id: 'random' }],
90+
}),
91+
generateMsg({
92+
date: '2022-01-01T00:00:00',
93+
mentioned_users: [{ id: 'random' }],
94+
}),
95+
);
96+
expect(channel.countUnreadMentions()).to.be.equal(1);
97+
});
98+
});
99+
100+
describe('Channel _handleChannelEvent', function () {
101+
const user = { id: 'user' };
102+
const channel = new Channel(
103+
{
104+
logger: () => null,
105+
user,
106+
userID: user.id,
107+
userMuteStatus: (targetId) => targetId.startsWith('mute'),
108+
},
109+
'messaging',
110+
'id',
111+
);
112+
113+
it('message.new reset the unreadCount for current user messages', function () {
114+
channel.state.unreadCount = 100;
115+
channel._handleChannelEvent({
116+
type: 'message.new',
117+
user,
118+
message: generateMsg(),
119+
});
120+
121+
expect(channel.state.unreadCount).to.be.equal(0);
122+
});
123+
124+
it('message.new increment unreadCount properly', function () {
125+
channel.state.unreadCount = 20;
126+
channel._handleChannelEvent({
127+
type: 'message.new',
128+
user: { id: 'id' },
129+
message: generateMsg({ user: { id: 'id' } }),
130+
});
131+
expect(channel.state.unreadCount).to.be.equal(21);
132+
channel._handleChannelEvent({
133+
type: 'message.new',
134+
user: { id: 'id2' },
135+
message: generateMsg({ user: { id: 'id2' } }),
136+
});
137+
expect(channel.state.unreadCount).to.be.equal(22);
138+
});
139+
140+
it('message.new skip increment for silent/shadowed/muted messages', function () {
141+
channel.state.unreadCount = 30;
142+
channel._handleChannelEvent({
143+
type: 'message.new',
144+
user: { id: 'id' },
145+
message: generateMsg({ silent: true }),
146+
});
147+
expect(channel.state.unreadCount).to.be.equal(30);
148+
channel._handleChannelEvent({
149+
type: 'message.new',
150+
user: { id: 'id2' },
151+
message: generateMsg({ shadowed: true }),
152+
});
153+
expect(channel.state.unreadCount).to.be.equal(30);
154+
channel._handleChannelEvent({
155+
type: 'message.new',
156+
user: { id: 'mute1' },
157+
message: generateMsg({ user: { id: 'mute1' } }),
158+
});
159+
expect(channel.state.unreadCount).to.be.equal(30);
160+
});
161+
});

test/unit/channel_state.js

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,10 @@
11
import Immutable from 'seamless-immutable';
22
import chai from 'chai';
3-
import { v4 as uuidv4 } from 'uuid';
43
import { ChannelState } from '../../src/channel_state';
4+
import { generateMsg } from './utils';
55

66
const expect = chai.expect;
77

8-
const generateMsg = (msg = {}) => {
9-
const date = msg.date || new Date().toISOString();
10-
return {
11-
id: uuidv4(),
12-
text: 'x',
13-
html: '<p>x</p>\n',
14-
type: 'regular',
15-
user: { id: 'id' },
16-
attachments: [],
17-
latest_reactions: [],
18-
own_reactions: [],
19-
reaction_counts: null,
20-
reaction_scores: {},
21-
reply_count: 0,
22-
created_at: date,
23-
updated_at: date,
24-
mentioned_users: [],
25-
silent: false,
26-
status: 'received',
27-
__html: '<p>x</p>\n',
28-
...msg,
29-
};
30-
};
31-
328
describe('ChannelState addMessagesSorted', function () {
339
it('empty state add single messages', async function () {
3410
const state = new ChannelState();

0 commit comments

Comments
 (0)