Add live account appeal updates and restricted status settings
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
128
components/useAppealRealtime.ts
Normal file
128
components/useAppealRealtime.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user