diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e43c199962..666c7ba01c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2141,7 +2141,15 @@ "select_messages_to_retry": "You can select all or individual messages to retry or delete", "server_connectivity_lost_description": "Sent messages will be stored until your connection has returned.", "server_connectivity_lost_title": "Connectivity to the server has been lost.", - "some_messages_not_sent": "Some of your messages have not been sent" + "some_messages_not_sent": "Some of your messages have not been sent", + "message_rejected": { + "title": "Message rejected: %(harm)s", + "can_retry_in": { + "one": "You may attempt to send new messages in 1 second.", + "other": "You may attempt to send new messages in %(count)s seconds." + }, + "cannot_retry": "You are not allowed to retry this message" + } }, "unknown_status_code_for_timeline_jump": "unknown status code", "unread_notifications_predecessor": { @@ -4264,5 +4272,29 @@ "userInputs": "There should not be any personal or page related data.", "wordByItself": "A word by itself is easy to guess" } + }, + "safety": { + "harms": { + "generic": "The message was blocked due to harmful content", + "multiple": "This message was blocked due to multiple harms", + "spam": "The message is considered spam.", + "spam.fraud": "The message contains fraudulent content", + "spam.impersonation": "The message is attempting to impersonate someone", + "spam.election_interference": "The message contains election interference content", + "spam.flooding": "You are sending too many messages", + "adult": "The message contains adult content", + "harassment": "Message detected as containing harassment.", + "harassment.trolling": "The message is contains trolling content.", + "harassment.targeted": "The message contains targeted harassment.", + "harassment.hate": "The message contains hateful content.", + "harassment.doxxing": "The message contains doxxing content.", + "violence": "The message contains violent content.", + "child_safety": "The message has contains child safety risks.", + "danger": "The message contains potentially dangerous content.", + "tos": "The message is against the terms of service.", + "tos.hacking": "The message is against the terms of service (hacking).", + "tos.prohibited": "The message is against the terms of service (prohibited content).", + "tos.ban_evasion": "The message is against the terms of service (ban evasion detected)." + } } } diff --git a/src/viewmodels/room/RoomStatusBar.ts b/src/viewmodels/room/RoomStatusBar.ts index e93fa8c7e5..939a69c109 100644 --- a/src/viewmodels/room/RoomStatusBar.ts +++ b/src/viewmodels/room/RoomStatusBar.ts @@ -19,6 +19,7 @@ import { type MatrixError, RoomEvent, EventStatus, + MatrixSafetyError, } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../MatrixClientPeg"; @@ -50,6 +51,7 @@ export class RoomStatusBarViewModel private static readonly determineStateForUnreadMessages = ( room: Room, hasClickedTermsAndConditions: boolean, + isResending: boolean, ): RoomStatusBarViewSnapshot => { const unsentMessages = room.getPendingEvents().filter((ev) => ev.status === EventStatus.NOT_SENT); if (unsentMessages.length === 0) { @@ -61,10 +63,11 @@ export class RoomStatusBarViewModel // The user has just clicked (and we assume accepted) the terms and contitions, so show them the retry buttons return { state: RoomStatusBarState.UnsentMessages, - isResending: false, + isResending, }; } let resourceLimitError: MatrixError | null = null; + let safetyError: MatrixSafetyError | null = null; for (const m of unsentMessages) { if (m.error) { if (m.error.errcode === "M_CONSENT_NOT_GIVEN") { @@ -77,6 +80,9 @@ export class RoomStatusBarViewModel if (m.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") { resourceLimitError = m.error; } + if (m.error instanceof MatrixSafetyError) { + safetyError = m.error; + } } } if (resourceLimitError) { @@ -86,6 +92,15 @@ export class RoomStatusBarViewModel adminContactHref: resourceLimitError.data.admin_contact, }; } + if (safetyError) { + return { + state: RoomStatusBarState.MessageRejected, + harms: [...safetyError.harms], + serverError: safetyError.error, + isResending, + canRetryInSeconds: safetyError.expiry && Math.ceil((safetyError.expiry.getTime() - Date.now()) / 1000), + }; + } return { state: RoomStatusBarState.UnsentMessages, isResending: false, @@ -107,12 +122,6 @@ export class RoomStatusBarViewModel } // If we're in the process of resending, don't flicker. - if (isResending) { - return { - state: RoomStatusBarState.UnsentMessages, - isResending, - }; - } const syncState = client.getSyncState(); // Highest priority. @@ -134,10 +143,11 @@ export class RoomStatusBarViewModel } // Then check messages. - return this.determineStateForUnreadMessages(room, hasClickedTermsAndConditions); + return this.determineStateForUnreadMessages(room, hasClickedTermsAndConditions, isResending); }; private readonly client: MatrixClient; + private timeout?: ReturnType; public constructor(props: Props) { const client = MatrixClientPeg.safeGet(); @@ -159,6 +169,10 @@ export class RoomStatusBarViewModel private hasClickedTermsAndConditions = false; private setSnapshot(): void { + if (this.timeout) { + // If we had a timer going, clear it. + clearTimeout(this.timeout); + } this.snapshot.set( RoomStatusBarViewModel.computeSnapshot( this.props.room, @@ -171,6 +185,12 @@ export class RoomStatusBarViewModel if (this.hasClickedTermsAndConditions && !this.snapshot.current.state) { this.hasClickedTermsAndConditions = false; } + if ( + this.snapshot.current.state === RoomStatusBarState.MessageRejected && + this.snapshot.current.canRetryInSeconds + ) { + this.timeout = setTimeout(() => this.setSnapshot, this.snapshot.current.canRetryInSeconds * 1000); + } } public dispose(): void {