Add live account appeal updates and restricted status settings

This commit is contained in:
2026-10-13 14:22:12 +05:00
parent cfe5820802
commit 8c3de302e7
21 changed files with 508 additions and 52 deletions

View File

@@ -8,6 +8,7 @@ import type {
AccountAppealRecord,
} from "@/shared/account-controls";
import styles from "@/app/ban/appeal/page.module.css";
import { useAppealRealtime } from "./useAppealRealtime";
export function AccountAppeal() {
const { locale } = useI18n();
@@ -22,13 +23,28 @@ export function AccountAppeal() {
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const lock = useRef(false);
const statusReads = useRef(0);
const realtime = useAppealRealtime({
enabled: Boolean(data?.account.id),
ownerId: data?.account.id,
refresh: async () => {
const version = ++statusReads.current;
const result = await api<{
account: ManagedAccount;
appeals: AccountAppealRecord[];
}>("ban/appeal");
if (version === statusReads.current) setData(result);
},
onError: (error) => setError((error as Error).message),
});
useEffect(() => {
let active = true;
const version = ++statusReads.current;
api<{ account: ManagedAccount; appeals: AccountAppealRecord[] }>(
"ban/appeal",
)
.then((result) => {
if (active) setData(result);
if (active && version === statusReads.current) setData(result);
})
.catch((e) => {
if (active) setError((e as Error).message);
@@ -55,8 +71,15 @@ export function AccountAppeal() {
}
}
async function reload() {
setData(await api("ban/appeal"));
setLoading(false);
const version = ++statusReads.current;
const result = await api<{
account: ManagedAccount;
appeals: AccountAppealRecord[];
}>("ban/appeal");
if (version === statusReads.current) {
setData(result);
setLoading(false);
}
}
const current = data?.appeals.find(
(a) => a.status_version === data.account.status_version,
@@ -107,6 +130,21 @@ export function AccountAppeal() {
{l("Read the guide", "Открыть руководство")}
</Link>
</div>
{data && (
<p className="muted" style={{ marginTop: 20 }}>
{realtime === "live"
? l(
"Live updates connected",
"Обновления в реальном времени подключены",
)
: realtime === "offline"
? l(
"Reconnecting… Refresh remains available.",
"Переподключение… Можно обновить вручную.",
)
: l("Connecting live updates…", "Подключение обновлений…")}
</p>
)}
{data && (
<>
<div className={styles.record} style={{ marginTop: 32 }}>
@@ -138,8 +176,8 @@ export function AccountAppeal() {
) : (
<p>
{l(
"Normal account access, uploads and API keys are blocked. You can still read the docs, view this page and sign out.",
"Обычный доступ к аккаунту, загрузки и ключи API заблокированы. Вы можете читать документацию, открывать эту страницу и выходить из аккаунта.",
"Normal account access, uploads and API keys are blocked. You can still read the docs, view your status in Settings, use this page and sign out.",
"Обычный доступ к аккаунту, загрузки и ключи API заблокированы. Вы можете читать документацию, видеть статус в настройках, открывать эту страницу и выходить из аккаунта.",
)}
</p>
)}

View File

@@ -8,6 +8,7 @@ import type {
AccountAppealRecord,
} from "@/shared/account-controls";
import styles from "@/app/ban/appeal/page.module.css";
import { useAppealRealtime } from "./useAppealRealtime";
export function AccountControls({
administrator,
@@ -28,6 +29,7 @@ export function AccountControls({
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const lock = useRef(false);
const appealReads = useRef(0);
const [selected, setSelected] = useState<{
account: ManagedAccount;
action: "deactivate" | "ban" | "restore" | "delete";
@@ -37,19 +39,38 @@ export function AccountControls({
const [reviewReasons, setReviewReasons] = useState<Record<string, string>>(
{},
);
const realtime = useAppealRealtime({
enabled: showAppeals,
refresh: async () => {
const version = ++appealReads.current;
const result = await api<{ appeals: AccountAppealRecord[] }>(
"moderation/account-appeals",
);
if (version === appealReads.current) {
setAppeals(result.appeals);
setLoading(false);
}
},
onError: (error) => {
setError((error as Error).message);
setLoading(false);
},
});
useEffect(() => {
if (!showAppeals) return;
let active = true;
const version = ++appealReads.current;
api<{ appeals: AccountAppealRecord[] }>("moderation/account-appeals")
.then((data) => {
if (active) setAppeals(data.appeals);
if (active && version === appealReads.current) setAppeals(data.appeals);
})
.catch((e) => {
if (active) setError((e as Error).message);
if (active && version === appealReads.current)
setError((e as Error).message);
})
.finally(() => {
if (active) setLoading(false);
if (active && version === appealReads.current) setLoading(false);
});
return () => {
active = false;
@@ -81,16 +102,14 @@ export function AccountControls({
async function refreshAppeals() {
if (!showAppeals) return;
setLoading(true);
const version = ++appealReads.current;
try {
setAppeals(
(
await api<{ appeals: AccountAppealRecord[] }>(
"moderation/account-appeals",
)
).appeals,
const result = await api<{ appeals: AccountAppealRecord[] }>(
"moderation/account-appeals",
);
if (version === appealReads.current) setAppeals(result.appeals);
} finally {
setLoading(false);
if (version === appealReads.current) setLoading(false);
}
}
async function save(event: React.FormEvent) {
@@ -320,14 +339,29 @@ export function AccountControls({
"Модераторы и администраторы могут рассматривать апелляции. Одобрение восстанавливает доступ после этой блокировки. Деактивация и удаление доступны только администраторам.",
)}
</p>
<button
className="button"
disabled={busy || loading}
onClick={() => void run(refreshAppeals)}
>
<RefreshCw size={16} aria-hidden="true" />
{l("Refresh appeals", "Обновить апелляции")}
</button>
<div className={styles.refreshActions}>
<button
className="button"
disabled={busy || loading}
onClick={() => void run(refreshAppeals)}
>
<RefreshCw size={16} aria-hidden="true" />
{l("Refresh appeals", "Обновить апелляции")}
</button>
<span className="muted">
{realtime === "live"
? l(
"Live updates connected",
"Обновления в реальном времени подключены",
)
: realtime === "offline"
? l(
"Reconnecting… Refresh remains available.",
"Переподключение… Можно обновить вручную.",
)
: l("Connecting live updates…", "Подключение обновлений…")}
</span>
</div>
{loading && (
<p role="status">{l("Loading appeals…", "Загрузка апелляций…")}</p>
)}

View File

@@ -0,0 +1,128 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createBrowserClient } from "@supabase/ssr";
import { realtimeRefresh } from "@/lib/realtime-refresh";
export function useAppealRealtime({
enabled,
ownerId,
refresh,
onError,
}: {
enabled: boolean;
ownerId?: string;
refresh: () => Promise<void>;
onError: (error: unknown) => void;
}) {
const [status, setStatus] = useState<"connecting" | "live" | "offline">(
"connecting",
);
const handlers = useRef({ refresh, onError });
useEffect(() => {
handlers.current = { refresh, onError };
}, [refresh, onError]);
useEffect(() => {
if (!enabled) return;
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!url || !key) {
setStatus("offline");
return;
}
let active = true;
let connected = false;
let debounce: ReturnType<typeof setTimeout> | undefined;
const updates = realtimeRefresh(
() => handlers.current.refresh(),
(error) => handlers.current.onError(error),
);
const invalidate = () => {
if (!active || debounce !== undefined) return;
debounce = setTimeout(() => {
debounce = undefined;
void updates.request();
}, 200);
};
const client = createBrowserClient(url, key);
const options = {
schema: "public",
table: "account_appeals",
...(ownerId ? { filter: `owner_id=eq.${ownerId}` } : {}),
};
const channel = client
.channel(`account-appeals:${ownerId ?? "staff"}:${crypto.randomUUID()}`, {
config: { postgres_changes_options: { wait: true } },
})
.on("system", {}, (event) => {
if (
active &&
event.extension === "postgres_changes" &&
event.status === "error"
) {
connected = false;
setStatus("offline");
console.warn(
"Appeal live updates unavailable:",
String(event.message ?? "subscription error")
.replace(/eyJ[A-Za-z0-9_.-]+/g, "[redacted]")
.slice(0, 240),
);
}
})
// DELETE events cannot be checked against a surviving row's RLS policy.
.on("postgres_changes", { ...options, event: "INSERT" }, invalidate)
.on("postgres_changes", { ...options, event: "UPDATE" }, invalidate);
if (ownerId)
channel.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "profiles",
filter: `id=eq.${ownerId}`,
},
invalidate,
);
channel.subscribe((state, error) => {
if (!active) return;
connected = state === "SUBSCRIBED";
if (error)
console.warn(
"Appeal live updates unavailable:",
error.message
.replace(/eyJ[A-Za-z0-9_.-]+/g, "[redacted]")
.slice(0, 240),
);
setStatus(
connected
? "live"
: state === "CLOSED" ||
state === "CHANNEL_ERROR" ||
state === "TIMED_OUT"
? "offline"
: "connecting",
);
if (connected) invalidate(); // Close the initial-load/reconnection gap.
});
const visible = () => {
if (document.visibilityState === "visible") invalidate();
};
document.addEventListener("visibilitychange", visible);
window.addEventListener("focus", invalidate);
window.addEventListener("online", invalidate);
const fallback = setInterval(() => {
if (!connected && document.visibilityState === "visible") invalidate();
}, 30000);
return () => {
active = false;
updates.close();
if (debounce !== undefined) clearTimeout(debounce);
clearInterval(fallback);
document.removeEventListener("visibilitychange", visible);
window.removeEventListener("focus", invalidate);
window.removeEventListener("online", invalidate);
void client.removeChannel(channel);
};
}, [enabled, ownerId]);
return status;
}