Add safety views.

This commit is contained in:
Half-Shot 2025-12-30 11:16:33 +00:00
parent 39a9d521ed
commit f58da1802d
2 changed files with 237 additions and 1 deletions

View File

@ -103,3 +103,71 @@ 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,
onResendAllClick: undefined,
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: [],
};
/**
* Rendered when a message was rejected by the server, and can be reattempted.
*/
export const WithMessageRejectedCanRetry = Template.bind({});
WithMessageRejectedCanRetry.args = {
state: RoomStatusBarState.MessageRejected,
harms: [],
};
/**
* 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"],
};
/**
* Rendered when a message was rejected by the server, and we use the generic message.
*/
export const WithMessageRejectedWithUnknownHarm = Template.bind({});
WithMessageRejectedWithUnknownHarm.args = {
state: RoomStatusBarState.MessageRejected,
harms: ["any.old.harm"],
};
/**
* 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",
};

View File

@ -42,6 +42,7 @@ export enum RoomStatusBarState {
ResourceLimited,
UnsentMessages,
LocalRoomFailed,
MessageRejected,
}
export interface RoomStatusBarNotVisible {
@ -71,13 +72,22 @@ export interface RoomStatusBarLocalRoomError {
state: RoomStatusBarState.LocalRoomFailed;
}
export interface RoomStatusBarMessageRejected {
state: RoomStatusBarState.MessageRejected;
canRetryInSeconds?: number;
isResending: boolean;
harms: string[];
serverError?: string;
}
export type RoomStatusBarViewSnapshot =
| RoomStatusBarNoConnection
| RoomStatusBarConsentState
| RoomStatusBarResourceLimitedState
| RoomStatusBarUnsentMessagesState
| RoomStatusBarLocalRoomError
| RoomStatusBarNotVisible;
| RoomStatusBarNotVisible
| RoomStatusBarMessageRejected;
/**
* The view model for the banner.
@ -91,6 +101,162 @@ 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;
if (onResendAllClick) {
subtitleText = _t("room|status_bar|select_messages_to_retry");
} else if (!onResendAllClick && snapshot.canRetryInSeconds !== undefined) {
subtitleText = _t("room|status_bar|message_rejected|can_retry_in", { count: snapshot.canRetryInSeconds });
} else {
subtitleText = _t("room|status_bar|message_rejected|cannot_retry");
}
return (
<Banner
role="status"
type="critical"
actions={
snapshot.isResending ? (
<InlineSpinner />
) : (
<>
{onDeleteAllClick && (
<Button
size="sm"
kind="destructive"
Icon={DeleteIcon}
disabled={snapshot.isResending}
onClick={deleteAllClick}
>
{_t("room|status_bar|delete_all")}
</Button>
)}
{(onResendAllClick || snapshot.canRetryInSeconds) && (
<Button
size="sm"
kind="secondary"
Icon={RestartIcon}
disabled={
snapshot.isResending ||
!!(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}>{subtitleText}</Text>
</div>
</Banner>
);
}
/**
* A component to alert to a failure in the context of a room.
*
@ -282,6 +448,8 @@ export function RoomStatusBarView({ vm }: Readonly<RoomStatusBarViewProps>): JSX
</div>
</Banner>
);
case RoomStatusBarState.MessageRejected:
return <RoomStatusBarViewMessageRejected snapshot={snapshot} actions={vm} />;
default:
throw Error(`Unexpected unknown state for RoomStatusBar ${snapshot["state"]}`);
}