Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/config/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,16 @@
"select_emoji": "Select Emoji",
"more_emojis": "More",
"search_emojis": "Search emojis...",
"no_emoji_found": "No emoji found"
"no_emoji_found": "No emoji found",
"ban-reason-spray": "You're paused from posting because the same message went to several channels at once.",
"ban-reason-mass-dm": "You're paused from posting because a message was sent to many people at once.",
"ban-reason-generic": "You're paused from posting in chat.",
"ban-can-still-read": "You can still read chat.",
"ban-unlocks": "Posting unlocks {when}.",
"ban-remaining-soon": "in under a minute",
"ban-remaining-minutes": "in about {count} minutes",
"ban-remaining-hours": "in about {count} hours",
"ban-remaining-days": "in about {count} days"
},
"free_estm": {
"timer_text": "Next free spin in",
Expand Down
6 changes: 6 additions & 0 deletions src/providers/chat/mattermost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,12 @@ export const sendMattermostMessage = async (
err.response?.data?.error || err.response?.data?.message || 'User is banned from chat',
);
banError.isBanError = true;
// Carry the structured payload, not just the message. The message is operator-facing
// (it names the account and quotes an ISO timestamp); these two are what let the UI say
// why it happened and when it lifts. Dropping them strands the notice and forces the UI
// back to displaying the very string it exists to replace.
banError.bannedUntil = err.response?.data?.bannedUntil;
banError.reason = err.response?.data?.reason;
throw banError;
}
throw err;
Expand Down
55 changes: 55 additions & 0 deletions src/screens/chats/children/ChatBanBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useEffect, useRef, useState } from 'react';
import { Text, View } from 'react-native';
import { useIntl } from 'react-intl';
import { chatThreadStyles as styles } from '../styles/chatThread.styles';
import { BAN_NOTICE_TICK_MS, ChatBanInfo, formatChatBanNotice } from '../utils/chatBanNotice';

interface ChatBanBannerProps {
info: ChatBanInfo;
/** Called once the ban lapses, so the caller can clear the banner without a reload. */
onExpire?: () => void;
}

/**
* Standing notice shown while the user is banned from posting.
*
* Replaces a one-shot toast. A ban is a state, not an event: a toast explains it once and then
* every later send just fails silently, which is how the original version left people with no
* idea why nothing sent.
*/
export const ChatBanBanner: React.FC<ChatBanBannerProps> = ({ info, onExpire }) => {
const intl = useIntl();
const [now, setNow] = useState(() => Date.now());

// Held in a ref so an inline arrow from the caller doesn't restart the interval each render.
// Assigned in an effect rather than during render: a render React discards could otherwise
// mutate the ref the already-committed interval reads from.
const onExpireRef = useRef(onExpire);
useEffect(() => {
onExpireRef.current = onExpire;
}, [onExpire]);

useEffect(() => {
const id = setInterval(() => {
const t = Date.now();
setNow(t);
if (t >= info.bannedUntil) {
clearInterval(id);
onExpireRef.current?.();
}
}, BAN_NOTICE_TICK_MS);
return () => clearInterval(id);
}, [info.bannedUntil]);

return (
<View style={styles.dmWarningContainer}>
{/* eslint-disable-next-line jsx-a11y/accessible-emoji */}
<Text style={styles.dmWarningIcon}>⏳</Text>
<View style={styles.dmWarningContent}>
<Text style={styles.dmWarningBody}>
{formatChatBanNotice(info, now, intl.formatMessage)}
</Text>
</View>
</View>
);
};
29 changes: 28 additions & 1 deletion src/screens/chats/container/chatThreadContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ import { PinnedMessagesModal } from '../children/PinnedMessagesModal';
import { OnlineUsersModal } from '../children/OnlineUsersModal';
import { TypingIndicator } from '../children/TypingIndicator';
import { DmWarningBanner } from '../children/DmWarningBanner';
import { ChatBanBanner } from '../children/ChatBanBanner';
import { ChatBanInfo, getChatBanInfo } from '../utils/chatBanNotice';

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

if (pendingMatch || fallbackMatch) {
// A websocket echo of our own just-sent message is independent proof the create
// landed, and it can arrive when the HTTP response never does (timeout, dropped
// connection). Without this the banner would sit there until its original expiry
// even though the ban has clearly been lifted. Both match arms are create-only:
// pending_post_id is set only when creating, and the fallback keys on
// lastSentMessageRef, which the create branch is what populates.
setBanInfo(null);

const confirmedId = lastSentPendingIdRef.current;
if (confirmedId) {
confirmedPendingPostIdsRef.current.add(confirmedId);
Expand Down Expand Up @@ -1296,6 +1307,13 @@ export const ChatThreadContainer: React.FC<ChatThreadContainerProps> = ({
props,
pendingPostId,
);

// Cleared HERE, in the create branch only. The ban gates creating posts and nothing
// else — editing an existing message is not checked server-side — so a successful edit
// proves nothing about the restriction and must not dismiss the notice. Only a create
// that lands shows the ban is actually gone (an early moderator unban).
setBanInfo(null);

const newPost = normalizePost(response);
if (newPost) {
if (channelId) {
Expand Down Expand Up @@ -1343,7 +1361,14 @@ export const ChatThreadContainer: React.FC<ChatThreadContainerProps> = ({
confirmedPendingPostIdsRef.current.delete(pendingId);
} else {
// Check if this is a ban error
if (err?.isBanError) {
const ban = getChatBanInfo(err);
if (ban) {
// Standing state, not a toast: a ban persists, so the explanation has to persist with
// it. `error` is no good here either — it only renders in the empty-thread view.
setBanInfo(ban);
} else if (err?.isBanError) {
// Ban detected but no usable expiry (an older server, or a malformed payload). Fall
// back to the previous one-shot message rather than showing a countdown we don't have.
dispatch(
toastNotification(
intl.formatMessage({
Expand Down Expand Up @@ -2197,6 +2222,8 @@ export const ChatThreadContainer: React.FC<ChatThreadContainerProps> = ({
getHiveUsername={getHiveUsernameFromMattermostUser as any}
/>

{banInfo && <ChatBanBanner info={banInfo} onExpire={() => setBanInfo(null)} />}

<ThreadComposer
message={message}
onMessageChange={_handleMessageChange}
Expand Down
125 changes: 125 additions & 0 deletions src/screens/chats/utils/chatBanNotice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {
BAN_NOTICE_TICK_MS,
formatBanRemaining,
formatChatBanNotice,
getChatBanInfo,
} from './chatBanNotice';

// Stands in for react-intl's formatMessage: returns defaultMessage with {placeholder} filled,
// which is what the real one does for these descriptors.
const fmt = (
descriptor: { id: string; defaultMessage: string },
values?: Record<string, string | number>,
) => {
let out = descriptor.defaultMessage;
Object.entries(values || {}).forEach(([k, v]) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
});
return out;
};

const NOW = 1_800_000_000_000;
const mins = (n: number) => NOW + n * 60_000;
const hours = (n: number) => NOW + n * 3_600_000;
const days = (n: number) => NOW + n * 86_400_000;

describe('getChatBanInfo', () => {
it('extracts a live ban from the thrown error', () => {
expect(getChatBanInfo({ bannedUntil: hours(48), reason: 'spray' }, NOW)).toEqual({
bannedUntil: hours(48),
reason: 'spray',
});
});

it('ignores an expired ban', () => {
expect(getChatBanInfo({ bannedUntil: NOW - 1 }, NOW)).toBeNull();
});

it('returns null for ordinary failures', () => {
expect(getChatBanInfo(new Error('network'), NOW)).toBeNull();
expect(getChatBanInfo(null, NOW)).toBeNull();
});

it('tolerates a ban with no reason, so older servers still work', () => {
expect(getChatBanInfo({ bannedUntil: hours(1) }, NOW)?.reason).toBeUndefined();
});

it('rejects a non-finite expiry', () => {
// Infinity survives an isNaN check and is also > now, so both original guards passed it.
// The banner would then show an endless duration and never fire onExpire.
expect(getChatBanInfo({ bannedUntil: Infinity }, NOW)).toBeNull();
expect(getChatBanInfo({ bannedUntil: 'Infinity' }, NOW)).toBeNull();
expect(getChatBanInfo({ bannedUntil: -Infinity }, NOW)).toBeNull();
expect(getChatBanInfo({ bannedUntil: 'not-a-number' }, NOW)).toBeNull();
});

it('drops a non-string reason rather than rendering it', () => {
expect(
getChatBanInfo({ bannedUntil: hours(1), reason: { a: 1 } }, NOW)?.reason,
).toBeUndefined();
});
});

describe('formatBanRemaining', () => {
it('scales the unit to the magnitude', () => {
expect(formatBanRemaining(mins(30), NOW, fmt)).toContain('30 minutes');
expect(formatBanRemaining(hours(5), NOW, fmt)).toContain('5 hours');
expect(formatBanRemaining(days(2), NOW, fmt)).toContain('2 days');
});

it('never promises an unlock that has not happened', () => {
expect(formatBanRemaining(NOW + 30_000, NOW, fmt)).toBe('in under a minute');
expect(formatBanRemaining(NOW - 10_000, NOW, fmt)).toBe('in under a minute');
});

it('never phrases a count as 1', () => {
[mins(59), mins(89), hours(1), hours(2), days(1), days(400)].forEach((until) => {
const match = formatBanRemaining(until, NOW, fmt).match(/about (\d+)/);
if (match) {
expect(Number(match[1])).toBeGreaterThanOrEqual(2);
}
});
});

it('renders a multi-year ban as days rather than degrading', () => {
expect(formatBanRemaining(NOW + 3 * 365 * 86_400_000, NOW, fmt)).toContain('1095 days');
});
});

describe('formatChatBanNotice', () => {
it('explains a spray timeout', () => {
const text = formatChatBanNotice({ bannedUntil: hours(48), reason: 'spray' }, NOW, fmt);
expect(text).toContain('same message went to several channels');
expect(text).toContain('You can still read chat.');
expect(text).toContain('2 days');
});

it('explains a mass-DM ban differently', () => {
const text = formatChatBanNotice({ bannedUntil: days(365), reason: 'mass-dm' }, NOW, fmt);
expect(text).toContain('many people at once');
});

it('falls back to generic copy for manual and unrecognised reasons', () => {
['manual', 'reason-from-a-newer-service', undefined].forEach((reason) => {
const text = formatChatBanNotice({ bannedUntil: hours(3), reason }, NOW, fmt);
expect(text).toContain('paused from posting');
expect(text).toContain('3 hours');
});
});

it('never shows operator-facing detail to the banned user', () => {
const text = formatChatBanNotice({ bannedUntil: hours(48), reason: 'spray' }, NOW, fmt);
expect(text).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
expect(text).not.toContain('ecency_chat');
expect(text).not.toContain('@');
});
});

describe('BAN_NOTICE_TICK_MS', () => {
it('stays inside the 32-bit setTimeout limit', () => {
// A delay derived from bannedUntil overflows for long bans and fires almost immediately,
// which would clear the notice for exactly the users who are most banned.
expect(BAN_NOTICE_TICK_MS).toBeGreaterThan(0);
expect(BAN_NOTICE_TICK_MS).toBeLessThan(2_147_483_647);
});
});
Loading
Loading