CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting: the shared Time component + copies of its today/yesterday branch (forwarded header, thread summary, read receipts, device tile, moderation alerts, edit history), locale-default toLocale*String calls that ignored the user's 12/24 h and date-format settings (scheduled tray, reminders, schedule preview, notification snooze, bookmarks, threads list, search cache line, room insights, media gallery), a hard-coded en-US date in the activity log, and three relative-age variants. utils/formatTimestamp.ts now holds the rules — today → time; yesterday / tomorrow → day word + time; last 6 days → weekday + time; older → date + time in dateFormatString — plus autoDate / time / date / dateTime styles, formatDayDivider (full weekday), formatShortAge (room list) and formatRelativeAge (list rows). useTimestampFormatter binds them to the settings. 11 unit tests with an injected 'now'. Visible changes are limited to consistency: 12 h times keep the existing zero-padded hh:mm A; the a11y label and Created-by line use the user's date format instead of a fixed long month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds';
|
|
import React, { useCallback, useRef } from 'react';
|
|
import { useRoom } from '../../hooks/useRoom';
|
|
import { CutoutCard } from '../cutout-card';
|
|
import { SettingTile } from '../setting-tile';
|
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { BreakWord } from '../../styles/Text.css';
|
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
|
|
|
type UserKickAlertProps = {
|
|
reason?: string;
|
|
kickedBy?: string;
|
|
ts?: number;
|
|
};
|
|
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
|
const { format } = useTimestampFormatter();
|
|
const when = ts ? format(ts) : undefined;
|
|
|
|
return (
|
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
|
<SettingTile>
|
|
<Box direction="Column" gap="200">
|
|
<Box gap="200" justifyContent="SpaceBetween">
|
|
<Text size="L400">Kicked User</Text>
|
|
{when && <Text size="T200">{when}</Text>}
|
|
</Box>
|
|
<Box direction="Column">
|
|
{kickedBy && (
|
|
<Text size="T200">
|
|
Kicked by: <b>{kickedBy}</b>
|
|
</Text>
|
|
)}
|
|
<Text size="T200">
|
|
{reason ? (
|
|
<>
|
|
Reason: <b>{reason}</b>
|
|
</>
|
|
) : (
|
|
<i>No Reason Provided.</i>
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
</SettingTile>
|
|
</CutoutCard>
|
|
);
|
|
}
|
|
|
|
type UserBanAlertProps = {
|
|
userId: string;
|
|
reason?: string;
|
|
canUnban?: boolean;
|
|
bannedBy?: string;
|
|
ts?: number;
|
|
};
|
|
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
|
|
const mx = useMatrixClient();
|
|
const room = useRoom();
|
|
const { format } = useTimestampFormatter();
|
|
const when = ts ? format(ts) : undefined;
|
|
|
|
const [unbanState, unban] = useAsyncCallback<undefined, Error, []>(
|
|
useCallback(async () => {
|
|
await mx.unban(room.roomId, userId);
|
|
}, [mx, room, userId]),
|
|
);
|
|
const banning = unbanState.status === AsyncStatus.Loading;
|
|
const error = unbanState.status === AsyncStatus.Error;
|
|
|
|
return (
|
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
|
<SettingTile>
|
|
<Box direction="Column" gap="200">
|
|
<Box gap="200" justifyContent="SpaceBetween">
|
|
<Text size="L400">Banned User</Text>
|
|
{when && <Text size="T200">{when}</Text>}
|
|
</Box>
|
|
<Box direction="Column">
|
|
{bannedBy && (
|
|
<Text size="T200">
|
|
Banned by: <b>{bannedBy}</b>
|
|
</Text>
|
|
)}
|
|
<Text size="T200">
|
|
{reason ? (
|
|
<>
|
|
Reason: <b>{reason}</b>
|
|
</>
|
|
) : (
|
|
<i>No Reason Provided.</i>
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
{error && (
|
|
<Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
|
|
<b>{unbanState.error.message}</b>
|
|
</Text>
|
|
)}
|
|
{canUnban && (
|
|
<Button
|
|
size="300"
|
|
variant="Critical"
|
|
radii="300"
|
|
onClick={unban}
|
|
before={banning && <Spinner size="100" variant="Critical" fill="Solid" />}
|
|
disabled={banning}
|
|
>
|
|
<Text size="B300">Unban</Text>
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</SettingTile>
|
|
</CutoutCard>
|
|
);
|
|
}
|
|
|
|
type UserInviteAlertProps = {
|
|
userId: string;
|
|
reason?: string;
|
|
canKick?: boolean;
|
|
invitedBy?: string;
|
|
ts?: number;
|
|
};
|
|
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
|
|
const mx = useMatrixClient();
|
|
const room = useRoom();
|
|
const { format } = useTimestampFormatter();
|
|
const when = ts ? format(ts) : undefined;
|
|
|
|
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
|
useCallback(async () => {
|
|
await mx.kick(room.roomId, userId);
|
|
}, [mx, room, userId]),
|
|
);
|
|
const kicking = kickState.status === AsyncStatus.Loading;
|
|
const error = kickState.status === AsyncStatus.Error;
|
|
|
|
return (
|
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Success">
|
|
<SettingTile>
|
|
<Box direction="Column" gap="200">
|
|
<Box gap="200" justifyContent="SpaceBetween">
|
|
<Text size="L400">Invited User</Text>
|
|
{when && <Text size="T200">{when}</Text>}
|
|
</Box>
|
|
<Box direction="Column">
|
|
{invitedBy && (
|
|
<Text size="T200">
|
|
Invited by: <b>{invitedBy}</b>
|
|
</Text>
|
|
)}
|
|
<Text size="T200">
|
|
{reason ? (
|
|
<>
|
|
Reason: <b>{reason}</b>
|
|
</>
|
|
) : (
|
|
<i>No Reason Provided.</i>
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
{error && (
|
|
<Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
|
|
<b>{kickState.error.message}</b>
|
|
</Text>
|
|
)}
|
|
{canKick && (
|
|
<Button
|
|
size="300"
|
|
variant="Success"
|
|
fill="Soft"
|
|
outlined
|
|
radii="300"
|
|
onClick={kick}
|
|
before={kicking && <Spinner size="100" variant="Success" fill="Soft" />}
|
|
disabled={kicking}
|
|
>
|
|
<Text size="B300">Cancel Invite</Text>
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</SettingTile>
|
|
</CutoutCard>
|
|
);
|
|
}
|
|
|
|
type UserModerationProps = {
|
|
userId: string;
|
|
canKick: boolean;
|
|
canBan: boolean;
|
|
canInvite: boolean;
|
|
};
|
|
export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) {
|
|
const mx = useMatrixClient();
|
|
const room = useRoom();
|
|
const reasonInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const getReason = useCallback((): string | undefined => {
|
|
const reason = reasonInputRef.current?.value.trim() || undefined;
|
|
if (reasonInputRef.current) {
|
|
reasonInputRef.current.value = '';
|
|
}
|
|
return reason;
|
|
}, []);
|
|
|
|
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
|
useCallback(async () => {
|
|
await mx.kick(room.roomId, userId, getReason());
|
|
}, [mx, room, userId, getReason]),
|
|
);
|
|
|
|
const [banState, ban] = useAsyncCallback<undefined, Error, []>(
|
|
useCallback(async () => {
|
|
await mx.ban(room.roomId, userId, getReason());
|
|
}, [mx, room, userId, getReason]),
|
|
);
|
|
|
|
const [inviteState, invite] = useAsyncCallback<undefined, Error, []>(
|
|
useCallback(async () => {
|
|
await mx.invite(room.roomId, userId, getReason());
|
|
}, [mx, room, userId, getReason]),
|
|
);
|
|
|
|
const disabled =
|
|
kickState.status === AsyncStatus.Loading ||
|
|
banState.status === AsyncStatus.Loading ||
|
|
inviteState.status === AsyncStatus.Loading;
|
|
|
|
if (!canBan && !canKick && !canInvite) return null;
|
|
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<Box direction="Column" gap="200">
|
|
<Box grow="Yes" direction="Column" gap="100">
|
|
<Text size="L400">Moderation</Text>
|
|
<Input
|
|
ref={reasonInputRef}
|
|
placeholder="Reason"
|
|
aria-label="Moderation reason"
|
|
size="300"
|
|
variant="Background"
|
|
radii="300"
|
|
disabled={disabled}
|
|
/>
|
|
{kickState.status === AsyncStatus.Error && (
|
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
|
<b>{kickState.error.message}</b>
|
|
</Text>
|
|
)}
|
|
{banState.status === AsyncStatus.Error && (
|
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
|
<b>{banState.error.message}</b>
|
|
</Text>
|
|
)}
|
|
{inviteState.status === AsyncStatus.Error && (
|
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
|
<b>{inviteState.error.message}</b>
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Box shrink="No" gap="200">
|
|
{canInvite && (
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
before={
|
|
inviteState.status === AsyncStatus.Loading ? (
|
|
<Spinner size="50" variant="Secondary" fill="Soft" />
|
|
) : (
|
|
<Icon size="50" src={Icons.ArrowRight} />
|
|
)
|
|
}
|
|
onClick={invite}
|
|
disabled={disabled}
|
|
>
|
|
<Text size="B300">Invite</Text>
|
|
</Button>
|
|
)}
|
|
{canKick && (
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
size="300"
|
|
variant="Critical"
|
|
fill="Soft"
|
|
radii="300"
|
|
before={
|
|
kickState.status === AsyncStatus.Loading ? (
|
|
<Spinner size="50" variant="Critical" fill="Soft" />
|
|
) : (
|
|
<Icon size="50" src={Icons.ArrowLeft} />
|
|
)
|
|
}
|
|
onClick={kick}
|
|
disabled={disabled}
|
|
>
|
|
<Text size="B300">Kick</Text>
|
|
</Button>
|
|
)}
|
|
{canBan && (
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
size="300"
|
|
variant="Critical"
|
|
fill="Solid"
|
|
radii="300"
|
|
before={
|
|
banState.status === AsyncStatus.Loading ? (
|
|
<Spinner size="50" variant="Critical" fill="Solid" />
|
|
) : (
|
|
<Icon size="50" src={Icons.Prohibited} />
|
|
)
|
|
}
|
|
onClick={ban}
|
|
disabled={disabled}
|
|
>
|
|
<Text size="B300">Ban</Text>
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|