Skip to content

Commit f0d16e2

Browse files
authored
Merge pull request #3479 from ecency/bugfix/chat-ban-messaging
Chat: tell a banned user what happened and when it lifts
2 parents 457e15e + 41847bd commit f0d16e2

6 files changed

Lines changed: 349 additions & 2 deletions

File tree

src/config/locales/en-US.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,16 @@
685685
"select_emoji": "Select Emoji",
686686
"more_emojis": "More",
687687
"search_emojis": "Search emojis...",
688-
"no_emoji_found": "No emoji found"
688+
"no_emoji_found": "No emoji found",
689+
"ban-reason-spray": "You're paused from posting because the same message went to several channels at once.",
690+
"ban-reason-mass-dm": "You're paused from posting because a message was sent to many people at once.",
691+
"ban-reason-generic": "You're paused from posting in chat.",
692+
"ban-can-still-read": "You can still read chat.",
693+
"ban-unlocks": "Posting unlocks {when}.",
694+
"ban-remaining-soon": "in under a minute",
695+
"ban-remaining-minutes": "in about {count} minutes",
696+
"ban-remaining-hours": "in about {count} hours",
697+
"ban-remaining-days": "in about {count} days"
689698
},
690699
"free_estm": {
691700
"timer_text": "Next free spin in",

src/providers/chat/mattermost.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,12 @@ export const sendMattermostMessage = async (
422422
err.response?.data?.error || err.response?.data?.message || 'User is banned from chat',
423423
);
424424
banError.isBanError = true;
425+
// Carry the structured payload, not just the message. The message is operator-facing
426+
// (it names the account and quotes an ISO timestamp); these two are what let the UI say
427+
// why it happened and when it lifts. Dropping them strands the notice and forces the UI
428+
// back to displaying the very string it exists to replace.
429+
banError.bannedUntil = err.response?.data?.bannedUntil;
430+
banError.reason = err.response?.data?.reason;
425431
throw banError;
426432
}
427433
throw err;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import { Text, View } from 'react-native';
3+
import { useIntl } from 'react-intl';
4+
import { chatThreadStyles as styles } from '../styles/chatThread.styles';
5+
import { BAN_NOTICE_TICK_MS, ChatBanInfo, formatChatBanNotice } from '../utils/chatBanNotice';
6+
7+
interface ChatBanBannerProps {
8+
info: ChatBanInfo;
9+
/** Called once the ban lapses, so the caller can clear the banner without a reload. */
10+
onExpire?: () => void;
11+
}
12+
13+
/**
14+
* Standing notice shown while the user is banned from posting.
15+
*
16+
* Replaces a one-shot toast. A ban is a state, not an event: a toast explains it once and then
17+
* every later send just fails silently, which is how the original version left people with no
18+
* idea why nothing sent.
19+
*/
20+
export const ChatBanBanner: React.FC<ChatBanBannerProps> = ({ info, onExpire }) => {
21+
const intl = useIntl();
22+
const [now, setNow] = useState(() => Date.now());
23+
24+
// Held in a ref so an inline arrow from the caller doesn't restart the interval each render.
25+
// Assigned in an effect rather than during render: a render React discards could otherwise
26+
// mutate the ref the already-committed interval reads from.
27+
const onExpireRef = useRef(onExpire);
28+
useEffect(() => {
29+
onExpireRef.current = onExpire;
30+
}, [onExpire]);
31+
32+
useEffect(() => {
33+
const id = setInterval(() => {
34+
const t = Date.now();
35+
setNow(t);
36+
if (t >= info.bannedUntil) {
37+
clearInterval(id);
38+
onExpireRef.current?.();
39+
}
40+
}, BAN_NOTICE_TICK_MS);
41+
return () => clearInterval(id);
42+
}, [info.bannedUntil]);
43+
44+
return (
45+
<View style={styles.dmWarningContainer}>
46+
{/* eslint-disable-next-line jsx-a11y/accessible-emoji */}
47+
<Text style={styles.dmWarningIcon}></Text>
48+
<View style={styles.dmWarningContent}>
49+
<Text style={styles.dmWarningBody}>
50+
{formatChatBanNotice(info, now, intl.formatMessage)}
51+
</Text>
52+
</View>
53+
</View>
54+
);
55+
};

src/screens/chats/container/chatThreadContainer.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ import { PinnedMessagesModal } from '../children/PinnedMessagesModal';
8787
import { OnlineUsersModal } from '../children/OnlineUsersModal';
8888
import { TypingIndicator } from '../children/TypingIndicator';
8989
import { DmWarningBanner } from '../children/DmWarningBanner';
90+
import { ChatBanBanner } from '../children/ChatBanBanner';
91+
import { ChatBanInfo, getChatBanInfo } from '../utils/chatBanNotice';
9092

9193
interface ChatReaction {
9294
emoji_name: string;
@@ -149,6 +151,7 @@ export const ChatThreadContainer: React.FC<ChatThreadContainerProps> = ({
149151
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
150152
const [mentionStartIndex, setMentionStartIndex] = useState<number | null>(null);
151153
const [error, setError] = useState<string | null>(null);
154+
const [banInfo, setBanInfo] = useState<ChatBanInfo | null>(null);
152155
const [hasBootstrapped, setHasBootstrapped] = useState<boolean>(!!initialBootstrap);
153156
const [canModerate, setCanModerate] = useState<boolean>(false);
154157
const [lastViewedAt, setLastViewedAt] = useState<number | null>(initialLastViewedAt ?? null);
@@ -300,6 +303,14 @@ export const ChatThreadContainer: React.FC<ChatThreadContainerProps> = ({
300303
Math.abs((post.create_at || 0) - lastSentAtRef.current) < 30000;
301304

302305
if (pendingMatch || fallbackMatch) {
306+
// A websocket echo of our own just-sent message is independent proof the create
307+
// landed, and it can arrive when the HTTP response never does (timeout, dropped
308+
// connection). Without this the banner would sit there until its original expiry
309+
// even though the ban has clearly been lifted. Both match arms are create-only:
310+
// pending_post_id is set only when creating, and the fallback keys on
311+
// lastSentMessageRef, which the create branch is what populates.
312+
setBanInfo(null);
313+
303314
const confirmedId = lastSentPendingIdRef.current;
304315
if (confirmedId) {
305316
confirmedPendingPostIdsRef.current.add(confirmedId);
@@ -1296,6 +1307,13 @@ export const ChatThreadContainer: React.FC<ChatThreadContainerProps> = ({
12961307
props,
12971308
pendingPostId,
12981309
);
1310+
1311+
// Cleared HERE, in the create branch only. The ban gates creating posts and nothing
1312+
// else — editing an existing message is not checked server-side — so a successful edit
1313+
// proves nothing about the restriction and must not dismiss the notice. Only a create
1314+
// that lands shows the ban is actually gone (an early moderator unban).
1315+
setBanInfo(null);
1316+
12991317
const newPost = normalizePost(response);
13001318
if (newPost) {
13011319
if (channelId) {
@@ -1343,7 +1361,14 @@ export const ChatThreadContainer: React.FC<ChatThreadContainerProps> = ({
13431361
confirmedPendingPostIdsRef.current.delete(pendingId);
13441362
} else {
13451363
// Check if this is a ban error
1346-
if (err?.isBanError) {
1364+
const ban = getChatBanInfo(err);
1365+
if (ban) {
1366+
// Standing state, not a toast: a ban persists, so the explanation has to persist with
1367+
// it. `error` is no good here either — it only renders in the empty-thread view.
1368+
setBanInfo(ban);
1369+
} else if (err?.isBanError) {
1370+
// Ban detected but no usable expiry (an older server, or a malformed payload). Fall
1371+
// back to the previous one-shot message rather than showing a countdown we don't have.
13471372
dispatch(
13481373
toastNotification(
13491374
intl.formatMessage({
@@ -2197,6 +2222,8 @@ export const ChatThreadContainer: React.FC<ChatThreadContainerProps> = ({
21972222
getHiveUsername={getHiveUsernameFromMattermostUser as any}
21982223
/>
21992224

2225+
{banInfo && <ChatBanBanner info={banInfo} onExpire={() => setBanInfo(null)} />}
2226+
22002227
<ThreadComposer
22012228
message={message}
22022229
onMessageChange={_handleMessageChange}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {
2+
BAN_NOTICE_TICK_MS,
3+
formatBanRemaining,
4+
formatChatBanNotice,
5+
getChatBanInfo,
6+
} from './chatBanNotice';
7+
8+
// Stands in for react-intl's formatMessage: returns defaultMessage with {placeholder} filled,
9+
// which is what the real one does for these descriptors.
10+
const fmt = (
11+
descriptor: { id: string; defaultMessage: string },
12+
values?: Record<string, string | number>,
13+
) => {
14+
let out = descriptor.defaultMessage;
15+
Object.entries(values || {}).forEach(([k, v]) => {
16+
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
17+
});
18+
return out;
19+
};
20+
21+
const NOW = 1_800_000_000_000;
22+
const mins = (n: number) => NOW + n * 60_000;
23+
const hours = (n: number) => NOW + n * 3_600_000;
24+
const days = (n: number) => NOW + n * 86_400_000;
25+
26+
describe('getChatBanInfo', () => {
27+
it('extracts a live ban from the thrown error', () => {
28+
expect(getChatBanInfo({ bannedUntil: hours(48), reason: 'spray' }, NOW)).toEqual({
29+
bannedUntil: hours(48),
30+
reason: 'spray',
31+
});
32+
});
33+
34+
it('ignores an expired ban', () => {
35+
expect(getChatBanInfo({ bannedUntil: NOW - 1 }, NOW)).toBeNull();
36+
});
37+
38+
it('returns null for ordinary failures', () => {
39+
expect(getChatBanInfo(new Error('network'), NOW)).toBeNull();
40+
expect(getChatBanInfo(null, NOW)).toBeNull();
41+
});
42+
43+
it('tolerates a ban with no reason, so older servers still work', () => {
44+
expect(getChatBanInfo({ bannedUntil: hours(1) }, NOW)?.reason).toBeUndefined();
45+
});
46+
47+
it('rejects a non-finite expiry', () => {
48+
// Infinity survives an isNaN check and is also > now, so both original guards passed it.
49+
// The banner would then show an endless duration and never fire onExpire.
50+
expect(getChatBanInfo({ bannedUntil: Infinity }, NOW)).toBeNull();
51+
expect(getChatBanInfo({ bannedUntil: 'Infinity' }, NOW)).toBeNull();
52+
expect(getChatBanInfo({ bannedUntil: -Infinity }, NOW)).toBeNull();
53+
expect(getChatBanInfo({ bannedUntil: 'not-a-number' }, NOW)).toBeNull();
54+
});
55+
56+
it('drops a non-string reason rather than rendering it', () => {
57+
expect(
58+
getChatBanInfo({ bannedUntil: hours(1), reason: { a: 1 } }, NOW)?.reason,
59+
).toBeUndefined();
60+
});
61+
});
62+
63+
describe('formatBanRemaining', () => {
64+
it('scales the unit to the magnitude', () => {
65+
expect(formatBanRemaining(mins(30), NOW, fmt)).toContain('30 minutes');
66+
expect(formatBanRemaining(hours(5), NOW, fmt)).toContain('5 hours');
67+
expect(formatBanRemaining(days(2), NOW, fmt)).toContain('2 days');
68+
});
69+
70+
it('never promises an unlock that has not happened', () => {
71+
expect(formatBanRemaining(NOW + 30_000, NOW, fmt)).toBe('in under a minute');
72+
expect(formatBanRemaining(NOW - 10_000, NOW, fmt)).toBe('in under a minute');
73+
});
74+
75+
it('never phrases a count as 1', () => {
76+
[mins(59), mins(89), hours(1), hours(2), days(1), days(400)].forEach((until) => {
77+
const match = formatBanRemaining(until, NOW, fmt).match(/about (\d+)/);
78+
if (match) {
79+
expect(Number(match[1])).toBeGreaterThanOrEqual(2);
80+
}
81+
});
82+
});
83+
84+
it('renders a multi-year ban as days rather than degrading', () => {
85+
expect(formatBanRemaining(NOW + 3 * 365 * 86_400_000, NOW, fmt)).toContain('1095 days');
86+
});
87+
});
88+
89+
describe('formatChatBanNotice', () => {
90+
it('explains a spray timeout', () => {
91+
const text = formatChatBanNotice({ bannedUntil: hours(48), reason: 'spray' }, NOW, fmt);
92+
expect(text).toContain('same message went to several channels');
93+
expect(text).toContain('You can still read chat.');
94+
expect(text).toContain('2 days');
95+
});
96+
97+
it('explains a mass-DM ban differently', () => {
98+
const text = formatChatBanNotice({ bannedUntil: days(365), reason: 'mass-dm' }, NOW, fmt);
99+
expect(text).toContain('many people at once');
100+
});
101+
102+
it('falls back to generic copy for manual and unrecognised reasons', () => {
103+
['manual', 'reason-from-a-newer-service', undefined].forEach((reason) => {
104+
const text = formatChatBanNotice({ bannedUntil: hours(3), reason }, NOW, fmt);
105+
expect(text).toContain('paused from posting');
106+
expect(text).toContain('3 hours');
107+
});
108+
});
109+
110+
it('never shows operator-facing detail to the banned user', () => {
111+
const text = formatChatBanNotice({ bannedUntil: hours(48), reason: 'spray' }, NOW, fmt);
112+
expect(text).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
113+
expect(text).not.toContain('ecency_chat');
114+
expect(text).not.toContain('@');
115+
});
116+
});
117+
118+
describe('BAN_NOTICE_TICK_MS', () => {
119+
it('stays inside the 32-bit setTimeout limit', () => {
120+
// A delay derived from bannedUntil overflows for long bans and fires almost immediately,
121+
// which would clear the notice for exactly the users who are most banned.
122+
expect(BAN_NOTICE_TICK_MS).toBeGreaterThan(0);
123+
expect(BAN_NOTICE_TICK_MS).toBeLessThan(2_147_483_647);
124+
});
125+
});

0 commit comments

Comments
 (0)