Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
7d92f0c
Refactor RoomStatusBar into MVVM
Half-Shot Dec 11, 2025
1fde8bf
cleanup
Half-Shot Dec 16, 2025
c595acc
updated snaps
Half-Shot Dec 16, 2025
3270e87
More cleanup
Half-Shot Dec 16, 2025
73f157d
fix loop
Half-Shot Dec 16, 2025
1d1a8a8
fixup
Half-Shot Dec 17, 2025
2f1d28b
drop comment
Half-Shot Dec 17, 2025
7f0306f
lint
Half-Shot Dec 18, 2025
6638f2f
cleanup console statements
Half-Shot Dec 18, 2025
9191346
Starting to move to a MVVM v2 component.
Half-Shot Dec 18, 2025
1571360
extra
Half-Shot Dec 18, 2025
e449a9d
Refactor as a shared-componend / MVVM v2
Half-Shot Dec 18, 2025
7ce4f49
some cleanup
Half-Shot Dec 18, 2025
c799ee2
i18n for banner
Half-Shot Dec 18, 2025
29b787a
remove removed css
Half-Shot Dec 18, 2025
6cc9e3e
Update playwright tests to have a two stage on the consent bar.
Half-Shot Dec 19, 2025
3168186
Update snaps
Half-Shot Dec 19, 2025
335498d
Update snapshots
Half-Shot Dec 19, 2025
c3bcc54
cleanup
Half-Shot Dec 19, 2025
e6aca3f
update snaps
Half-Shot Dec 19, 2025
1b744d0
refactor to use enum
Half-Shot Dec 19, 2025
6fe5109
fix slight differences in pw snaps
Half-Shot Dec 19, 2025
bec3a3b
Add unit tests
Half-Shot Dec 19, 2025
ecc4671
Merge remote-tracking branch 'origin/develop' into hs/roomstatusbar-vm
Half-Shot Dec 19, 2025
847cc91
fix snaps
Half-Shot Dec 19, 2025
768a731
snaps updated
Half-Shot Dec 19, 2025
5c5f899
more test cleanups
Half-Shot Dec 19, 2025
401383a
fix snaps
Half-Shot Dec 19, 2025
5fdf04f
Merge remote-tracking branch 'origin/develop' into hs/roomstatusbar-vm
Half-Shot Dec 19, 2025
72cb271
fixed now?
Half-Shot Dec 19, 2025
22b68bf
Disable animationsq
Half-Shot Dec 19, 2025
940f5be
lint lint lint
Half-Shot Dec 19, 2025
73d21b1
remove console
Half-Shot Dec 22, 2025
14e1f97
lint
Half-Shot Dec 22, 2025
39a9d52
fix snap
Half-Shot Dec 22, 2025
f58da18
Add safety views.
Half-Shot Dec 30, 2025
3a76a99
Add safety test.
Half-Shot Dec 30, 2025
74b78bf
Update view model
Half-Shot Dec 30, 2025
16eb46f
misc updates
Half-Shot Dec 30, 2025
224ebb8
Merge remote-tracking branch 'origin/develop' into hs/safety-error-code
Half-Shot Jan 13, 2026
4d0f9a3
Revert snap changes
Half-Shot Jan 13, 2026
acb4583
Remove unused
Half-Shot Jan 13, 2026
b5b2aad
lint
Half-Shot Jan 13, 2026
823814a
Update snaps
Half-Shot Jan 13, 2026
1d70933
resending ficx
Half-Shot Jan 13, 2026
7b76628
Fix playwright barfing on js-sdk
Half-Shot Jan 13, 2026
ef8bf8a
Update packages/shared-components/src/i18n/strings/en_EN.json
Half-Shot Jan 13, 2026
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 33 additions & 1 deletion packages/shared-components/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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"
}
}
},
"terms": {
Expand Down Expand Up @@ -56,5 +64,29 @@
"error_downloading_audio": "Error downloading audio",
"unnamed_audio": "Unnamed audio"
}
},
"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": "The message contains 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)."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,65 @@ export const WithLocalRoomRetry = Template.bind({});
WithLocalRoomRetry.args = {
state: RoomStatusBarState.LocalRoomFailed,
};

/**
* Rendered when a message was rejected by the server, and cannot be reattempted.
*/
export const WithMessageRejected = Template.bind({});
WithMessageRejected.args = {
state: RoomStatusBarState.MessageRejected,
harms: ["org.matrix.msc4387.harassment"],
};

/**
* Rendered when a message was rejected by the server, and can be reattempted later.
*/
export const WithMessageRejectedCanRetryInTime = Template.bind({});
WithMessageRejectedCanRetryInTime.args = {
state: RoomStatusBarState.MessageRejected,
onResendAllClick: undefined,
canRetryInSeconds: 5,
harms: [],
isResending: false,
};

/**
* Rendered when a message was rejected by the server, and can be reattempted.
*/
export const WithMessageRejectedCanRetry = Template.bind({});
WithMessageRejectedCanRetry.args = {
state: RoomStatusBarState.MessageRejected,
harms: [],
isResending: false,
};

/**
* Rendered when a message was rejected by the server, and is being resent.
*/
export const WithMessageRejectedSending = Template.bind({});
WithMessageRejectedSending.args = {
state: RoomStatusBarState.MessageRejected,
harms: [],
isResending: true,
};

/**
* Rendered when a message was rejected by the server, and we use the generic message.
*/
export const WithMessageRejectedWithKnownHarm = Template.bind({});
WithMessageRejectedWithKnownHarm.args = {
state: RoomStatusBarState.MessageRejected,
harms: ["org.matrix.msc4387.spam"],
isResending: false,
};

/**
* Rendered when a message was rejected by the server with a specific message.
*/
export const WithMessageRejectedWithServerMessage = Template.bind({});
WithMessageRejectedWithServerMessage.args = {
state: RoomStatusBarState.MessageRejected,
harms: ["any.old.harm"],
serverError: "OurServer rejects this content",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is OurServerreplaced with the server name via text interpolation? If not, we should reword this:

This content is not permitted perhaps?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just the error the server is returning. I think the MSC says that if the harm isn't known, we should defer to the server's message?

@t3chguy t3chguy Jan 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the MSC says that if the harm isn't known, we should defer to the server's message?

We shouldn't be showing untranslated server errors ever, this violates some customer commitments we have to be fully i18nable

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, I'll leave feedback on the MSC. It doesn't appear to be a must so we can use generic wording for this.

isResending: false,
};
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export const RoomStatusBarState = {
* There was an error creating a room. The user may retry creation.
*/
LocalRoomFailed: "LocalRoomFailed",
/**
* A message was rejected by the homeserver due to a safety concern.
*/
MessageRejected: "MessageRejected",
} as const;

export interface RoomStatusBarNotVisible {
Expand Down Expand Up @@ -89,14 +93,29 @@ export interface RoomStatusBarUnsentMessagesState {
export interface RoomStatusBarLocalRoomError {
state: "LocalRoomFailed";
}
export interface RoomStatusBarMessageRejectedRetryable {
state: "MessageRejected";
canRetryInSeconds?: number;
isResending: boolean;
harms: string[];
serverError?: string;
}
export interface RoomStatusBarMessageRejectedUnretryable {
state: "MessageRejected";
harms: string[];
serverError?: string;
}

type RoomStatusBarMessageRejected = RoomStatusBarMessageRejectedRetryable | RoomStatusBarMessageRejectedUnretryable;

export type RoomStatusBarViewSnapshot =
| RoomStatusBarNoConnection
| RoomStatusBarConsentState
| RoomStatusBarResourceLimitedState
| RoomStatusBarUnsentMessagesState
| RoomStatusBarLocalRoomError
| RoomStatusBarNotVisible;
| RoomStatusBarNotVisible
| RoomStatusBarMessageRejected;

/**
* The view model for RoomStatusBarView.
Expand All @@ -110,6 +129,161 @@ interface RoomStatusBarViewProps {
vm: RoomStatusBarViewModel;
}

function translateHarmsToText(harms: string[], serverProvidedText?: string): string {
const { translate: _t } = useI18n();
const translatedStrings = [];
for (const harmCategory of harms) {
switch (harmCategory) {
// case "m.spam" once the MSC passes.
case "org.matrix.msc4387.spam":
translatedStrings.push(_t("safety|harms|spam"));
break;
case "org.matrix.msc4387.spam.fraud":
translatedStrings.push(_t("safety|harms|spam.fraud"));
break;
case "org.matrix.msc4387.spam.impersonation":
translatedStrings.push(_t("safety|harms|spam.impersonation"));
break;
case "org.matrix.msc4387.spam.election_interference":
translatedStrings.push(_t("safety|harms|spam.election_interference"));
break;
case "org.matrix.msc4387.spam.flooding":
translatedStrings.push(_t("safety|harms|spam.flooding"));
break;
case "org.matrix.msc4387.adult":
translatedStrings.push(_t("safety|harms|spam.adult"));
break;
case "org.matrix.msc4387.harassment":
translatedStrings.push(_t("safety|harms|harassment"));
break;
case "org.matrix.msc4387.harassment.trolling":
translatedStrings.push(_t("safety|harms|harassment.trolling"));
break;
case "org.matrix.msc4387.harassment.targeted":
translatedStrings.push(_t("safety|harms|harassment.targeted"));
break;
case "org.matrix.msc4387.harassment.hate":
translatedStrings.push(_t("safety|harms|harassment.hate"));
break;
case "org.matrix.msc4387.harassment.doxxing":
translatedStrings.push(_t("safety|harms|harassment.doxxing"));
break;
case "org.matrix.msc4387.violence":
translatedStrings.push(_t("safety|harms|violence"));
break;
case "org.matrix.msc4387.child_safety":
translatedStrings.push(_t("safety|harms|child_safety"));
break;
case "org.matrix.msc4387.danger":
translatedStrings.push(_t("safety|harms|danger"));
break;
case "org.matrix.msc4387.tos":
translatedStrings.push(_t("safety|harms|tos"));
break;
case "org.matrix.msc4387.tos.hacking":
translatedStrings.push(_t("safety|harms|tos.hacking"));
break;
case "org.matrix.msc4387.tos.prohibited":
translatedStrings.push(_t("safety|harms|tos.prohibited"));
break;
case "org.matrix.msc4387.tos.ban_evasion":
translatedStrings.push(_t("safety|harms|tos.ban_evasion"));
break;
}
}
if (translatedStrings.length > 1) {
return _t("safety|harms|multiple");
} else if (translatedStrings.length === 0) {
return serverProvidedText ?? _t("safety|harms|generic");
}
return translatedStrings[0];
}

function RoomStatusBarViewMessageRejected({
snapshot,
actions: { onDeleteAllClick, onResendAllClick },
}: {
snapshot: RoomStatusBarMessageRejected;
actions: RoomStatusBarViewActions;
}): JSX.Element {
const { translate: _t } = useI18n();
const bannerTitleId = useId();
const deleteAllClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
onDeleteAllClick?.();
},
[onDeleteAllClick],
);

const resendClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(ev) => {
ev.preventDefault();
onResendAllClick?.();
},
[onResendAllClick],
);

let subtitleText: string;
const canRetry = "isResending" in snapshot;
const isResending = "isResending" in snapshot && snapshot.isResending;
if (canRetry) {
if (snapshot.canRetryInSeconds !== undefined) {
subtitleText = _t("room|status_bar|message_rejected|can_retry_in", { count: snapshot.canRetryInSeconds });
} else {
subtitleText = _t("room|status_bar|select_messages_to_retry");
}
} else {
subtitleText = _t("room|status_bar|message_rejected|cannot_retry");
}

return (
<Banner
role="status"
type="critical"
actions={
isResending ? (
<InlineSpinner />
) : (
<>
<Button
size="sm"
kind="destructive"
Icon={DeleteIcon}
disabled={isResending}
onClick={deleteAllClick}
>
{_t("room|status_bar|delete_all")}
</Button>
{canRetry && (
<Button
size="sm"
kind="secondary"
Icon={RestartIcon}
disabled={!!(snapshot.canRetryInSeconds && snapshot.canRetryInSeconds > 0)}
onClick={resendClick}
className={styles.container}
>
{_t("room|status_bar|retry_all")}
</Button>
)}
</>
)
}
aria-labelledby={bannerTitleId}
>
<div className={styles.container}>
<Text id={bannerTitleId} weight="semibold">
{_t("room|status_bar|message_rejected|title", {
harm: translateHarmsToText(snapshot.harms, snapshot.serverError),
})}
</Text>
<Text className={styles.description} size="sm">{subtitleText}</Text>
</div>
</Banner>
);
}

/**
* A component to alert to a failure in the context of a room.
*
Expand Down Expand Up @@ -303,6 +477,8 @@ export function RoomStatusBarView({ vm }: Readonly<RoomStatusBarViewProps>): JSX
</div>
</Banner>
);
case RoomStatusBarState.MessageRejected:
return <RoomStatusBarViewMessageRejected snapshot={snapshot} actions={vm} />;
default:
// We should never get into this state.
return null;
Expand Down
Loading
Loading