mirror of
https://github.com/iamxvbaba/gramsrv.git
synced 2026-11-07 15:06:06 +03:00
admin(ui): media safety (photos only, canReadMessages, withBase), messenger paging/search/stale-guard/detail links, settings group isolation, nav i18n
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+32
-31
@@ -1,31 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>telesrv admin</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("telesrv.admin.theme");
|
||||
var theme =
|
||||
stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-ehB0yzht.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dn9qwVny.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="en" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>telesrv admin</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("telesrv.admin.theme");
|
||||
var theme =
|
||||
stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-bzsdGgXJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CGGYC_ze.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -242,14 +242,21 @@ export const api = {
|
||||
emoji: (params: URLSearchParams) => request<EmojiListResponse>(`/api/emoji?${params.toString()}`),
|
||||
emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`),
|
||||
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
|
||||
liveChats: (userID: number) => request<LiveChatListResponse>(`/api/live/chats?user_id=${encodeURIComponent(String(userID))}`),
|
||||
liveChats: (userID: number, beforeDate?: number, beforePeerID?: number) => {
|
||||
const params = new URLSearchParams({ user_id: String(userID) });
|
||||
if (beforeDate && beforePeerID) {
|
||||
params.set("before_date", String(beforeDate));
|
||||
params.set("before_peer_id", String(beforePeerID));
|
||||
}
|
||||
return request<LiveChatListResponse>(`/api/live/chats?${params.toString()}`);
|
||||
},
|
||||
livePeers: (users: number[], channels: number[]) => {
|
||||
const params = new URLSearchParams();
|
||||
if (users.length) params.set("users", users.join(","));
|
||||
if (channels.length) params.set("channels", channels.join(","));
|
||||
return request<LivePeerNames>(`/api/live/peers?${params.toString()}`);
|
||||
},
|
||||
mediaURL: (key: string) => `/api/live/media?key=${encodeURIComponent(key)}`,
|
||||
mediaURL: (key: string) => withBase(`/api/live/media?key=${encodeURIComponent(key)}`),
|
||||
message: (ownerUserID: number, msgID: number) => {
|
||||
const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) });
|
||||
return request<MessageDetail>(`/api/messages/detail?${params.toString()}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatUnix } from "../lib/format";
|
||||
|
||||
// MessagePreview renders a conversation as chat bubbles -- the "live preview"
|
||||
@@ -12,6 +13,7 @@ export type PreviewMessage = {
|
||||
deleted?: boolean;
|
||||
media?: boolean;
|
||||
mediaURL?: string;
|
||||
mediaKind?: "photo" | "document";
|
||||
post?: boolean;
|
||||
};
|
||||
|
||||
@@ -21,7 +23,9 @@ export function MessagePreview({
|
||||
emptyLabel,
|
||||
nameOf,
|
||||
avatar,
|
||||
focusID
|
||||
focusID,
|
||||
detailOf,
|
||||
onOpenDetail
|
||||
}: {
|
||||
messages: PreviewMessage[];
|
||||
title: string;
|
||||
@@ -29,7 +33,10 @@ export function MessagePreview({
|
||||
nameOf?: (from: number | string) => string;
|
||||
avatar?: ReactNode;
|
||||
focusID?: string | number;
|
||||
detailOf?: (message: PreviewMessage) => string | undefined;
|
||||
onOpenDetail?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [zoom, setZoom] = useState<string | null>(null);
|
||||
return (
|
||||
<section className="msg-preview">
|
||||
@@ -51,15 +58,29 @@ export function MessagePreview({
|
||||
>
|
||||
<div className={`msg-preview-bubble${message.deleted ? " deleted" : ""}`}>
|
||||
{!message.outgoing && !message.post && <div className="msg-preview-from">{nameOf ? nameOf(message.from) : message.from}</div>}
|
||||
{message.mediaURL ? (
|
||||
{message.mediaURL && message.mediaKind !== "document" ? (
|
||||
<img className="msg-preview-photo" src={message.mediaURL} alt="" loading="lazy" onClick={() => setZoom(message.mediaURL ?? null)} />
|
||||
) : message.mediaURL ? (
|
||||
<a className="msg-preview-media" href={message.mediaURL} target="_blank" rel="noreferrer">{t("common.download")}</a>
|
||||
) : message.media ? (
|
||||
<div className="msg-preview-media">media</div>
|
||||
<div className="msg-preview-media">{t("messages.media")}</div>
|
||||
) : null}
|
||||
{message.body && <div className="msg-preview-text">{message.body}</div>}
|
||||
<div className="msg-preview-meta">
|
||||
{detailOf && message.id !== undefined && onOpenDetail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="msg-preview-link"
|
||||
onClick={() => {
|
||||
const path = detailOf(message);
|
||||
if (path) onOpenDetail(path);
|
||||
}}
|
||||
>
|
||||
{`#${message.id} · ${t("common.detail")}`}
|
||||
</button>
|
||||
) : null}
|
||||
{formatUnix(message.date)}
|
||||
{message.deleted ? " · deleted" : ""}
|
||||
{message.deleted ? ` · ${t("common.deleted")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +102,23 @@ const translations: Record<Language, Record<string, string>> = {
|
||||
"route.serverSettings": "Server Settings",
|
||||
"route.serverSettingsSubtitle": "Console / Server Settings",
|
||||
"layout.navigation": "Navigation",
|
||||
"messages.fromLabel": "from",
|
||||
"messages.searchChats": "Search chats",
|
||||
"messages.noChats": "No chats",
|
||||
"messages.pickUserForChats": "Pick a user to list their chats.",
|
||||
"messages.loadMoreChats": "Load more chats",
|
||||
"messages.loadOlder": "Load older messages",
|
||||
"messages.noMessages": "No messages",
|
||||
"messages.selectChat": "Select a chat to read it.",
|
||||
"messages.moderationActions": "Moderation actions",
|
||||
"messages.media": "Media",
|
||||
"common.download": "Download attachment",
|
||||
"moderation.privateMessage": "Private message",
|
||||
"moderation.channelMessage": "Channel message",
|
||||
"moderation.rawReportData": "Raw report data",
|
||||
"serverSettings.searchSettings": "Search settings",
|
||||
"serverSettings.noMatch": "Nothing in the configuration matches",
|
||||
"serverSettings.statusLabel": "Status",
|
||||
"nav.liveReview": "Live review",
|
||||
"nav.usersChats": "Users and chats",
|
||||
"nav.moderation": "Moderation",
|
||||
@@ -1758,6 +1775,23 @@ const translations: Record<Language, Record<string, string>> = {
|
||||
"route.serverSettings": "服务器设置",
|
||||
"route.serverSettingsSubtitle": "控制台 / 服务器设置",
|
||||
"layout.navigation": "导航",
|
||||
"messages.fromLabel": "来自",
|
||||
"messages.searchChats": "搜索聊天",
|
||||
"messages.noChats": "没有聊天",
|
||||
"messages.pickUserForChats": "选择用户以列出其聊天。",
|
||||
"messages.loadMoreChats": "加载更多聊天",
|
||||
"messages.loadOlder": "加载更早的消息",
|
||||
"messages.noMessages": "没有消息",
|
||||
"messages.selectChat": "选择一个聊天以阅读。",
|
||||
"messages.moderationActions": "管理操作",
|
||||
"messages.media": "媒体",
|
||||
"common.download": "下载附件",
|
||||
"moderation.privateMessage": "私聊消息",
|
||||
"moderation.channelMessage": "频道消息",
|
||||
"moderation.rawReportData": "原始举报数据",
|
||||
"serverSettings.searchSettings": "搜索设置",
|
||||
"serverSettings.noMatch": "配置中没有匹配项",
|
||||
"serverSettings.statusLabel": "状态",
|
||||
"nav.liveReview": "实时查看",
|
||||
"nav.usersChats": "用户和聊天",
|
||||
"nav.moderation": "审核",
|
||||
@@ -3414,6 +3448,23 @@ const translations: Record<Language, Record<string, string>> = {
|
||||
"route.serverSettings": "Настройки сервера",
|
||||
"route.serverSettingsSubtitle": "Консоль / Настройки сервера",
|
||||
"layout.navigation": "Навигация",
|
||||
"messages.fromLabel": "от",
|
||||
"messages.searchChats": "Поиск чатов",
|
||||
"messages.noChats": "Нет чатов",
|
||||
"messages.pickUserForChats": "Выберите пользователя, чтобы показать его чаты.",
|
||||
"messages.loadMoreChats": "Показать больше чатов",
|
||||
"messages.loadOlder": "Показать более старые",
|
||||
"messages.noMessages": "Нет сообщений",
|
||||
"messages.selectChat": "Выберите чат для чтения.",
|
||||
"messages.moderationActions": "Действия модерации",
|
||||
"messages.media": "Медиа",
|
||||
"common.download": "Скачать вложение",
|
||||
"moderation.privateMessage": "Личное сообщение",
|
||||
"moderation.channelMessage": "Сообщение канала",
|
||||
"moderation.rawReportData": "Исходные данные жалобы",
|
||||
"serverSettings.searchSettings": "Поиск настроек",
|
||||
"serverSettings.noMatch": "В конфигурации нет совпадений",
|
||||
"serverSettings.statusLabel": "Статус",
|
||||
"nav.liveReview": "Живой просмотр",
|
||||
"nav.usersChats": "Пользователи и чаты",
|
||||
"nav.moderation": "Модерация",
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
// mediaLocationKey derives a blob location key ("photo:<id>" / "doc:<id>") from
|
||||
// a message's stored media JSON, so the review screens can load the bytes from
|
||||
// /api/live/media.
|
||||
// Parsing of a message's stored media JSON for the review screens.
|
||||
//
|
||||
// The id is extracted as a STRING on purpose: photo/document ids are 64-bit and
|
||||
// exceed Number.MAX_SAFE_INTEGER, so JSON.parse (and Number(id)) would round
|
||||
// them and the lookup would miss. A regex keeps the exact digits.
|
||||
export function mediaLocationKey(mediaJSON?: string): string | null {
|
||||
// The id is read as a STRING on purpose: photo/document ids are 64-bit and
|
||||
// exceed Number.MAX_SAFE_INTEGER, so JSON.parse (or Number(id)) would round
|
||||
// them and the /api/live/media lookup would miss. A targeted regex also avoids
|
||||
// picking the wrong "id" (a webpage or a live photo's embedded video) that a
|
||||
// naive first-id scan would find in the key-sorted pretty JSON.
|
||||
import { api } from "../api";
|
||||
|
||||
export type MediaRef = { key: string; kind: "photo" | "document" };
|
||||
|
||||
export function parseMedia(mediaJSON?: string): MediaRef | null {
|
||||
if (!mediaJSON || mediaJSON === "{}") return null;
|
||||
const idMatch = /"id"\s*:\s*(\d+)/.exec(mediaJSON);
|
||||
if (!idMatch) return null;
|
||||
const id = idMatch[1];
|
||||
const kindMatch = /"kind"\s*:\s*"([a-z_]+)"/.exec(mediaJSON);
|
||||
const kind = kindMatch?.[1];
|
||||
if (kind === "document" || /"document"\s*:/.test(mediaJSON)) {
|
||||
return `doc:${id}`;
|
||||
}
|
||||
if (kind === "photo" || /"photo"\s*:/.test(mediaJSON)) {
|
||||
return `photo:${id}`;
|
||||
}
|
||||
return null;
|
||||
const wanted = kind === "document" ? "document" : "photo";
|
||||
const objectMatch = new RegExp(`"${wanted}"\\s*:\\s*\\{`).exec(mediaJSON);
|
||||
if (!objectMatch) return null;
|
||||
const idMatch = /"id"\s*:\s*(\d+)/.exec(mediaJSON.slice(objectMatch.index + objectMatch[0].length));
|
||||
if (!idMatch) return null;
|
||||
return { key: `${wanted === "document" ? "doc" : "photo"}:${idMatch[1]}`, kind: wanted };
|
||||
}
|
||||
|
||||
export function mediaLocationKey(mediaJSON?: string): string | null {
|
||||
return parseMedia(mediaJSON)?.key ?? null;
|
||||
}
|
||||
|
||||
// mediaFields returns the preview fields for a message's media JSON.
|
||||
export function mediaFields(mediaJSON?: string): { mediaURL?: string; mediaKind?: "photo" | "document" } {
|
||||
const ref = parseMedia(mediaJSON);
|
||||
return ref ? { mediaURL: api.mediaURL(ref.key), mediaKind: ref.kind } : {};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MessagePreview, type PreviewMessage } from "../components/MessagePrevie
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatUnix } from "../lib/format";
|
||||
import { mediaLocationKey } from "../lib/media";
|
||||
import { mediaFields } from "../lib/media";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { GroupMessageDetail } from "../types";
|
||||
|
||||
@@ -45,7 +45,7 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
|
||||
body: row.Body,
|
||||
deleted: row.Deleted,
|
||||
media: Boolean(row.Media && row.Media !== "{}"),
|
||||
mediaURL: (() => { const key = mediaLocationKey(row.Media); return key ? api.mediaURL(key) : undefined; })(),
|
||||
...mediaFields(row.Media),
|
||||
post: row.Post
|
||||
}))
|
||||
);
|
||||
@@ -90,6 +90,27 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
|
||||
<Summary label={t("common.views")} value={String(msg.ViewsCount)} />
|
||||
</div>
|
||||
<MessagePreview title={t("messages.groupTitle")} emptyLabel={"No messages"} messages={preview} focusID={msg.ID} />
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.channelUpdateEvents")} text={t("messages.channelEventsSource")} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.messageId")}</th><th>{t("common.sender")}</th><th>{t("common.time")}</th></tr></thead>
|
||||
<tbody>
|
||||
{detail.UpdateEvents.map((row) => (
|
||||
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
|
||||
<td>{row.PTS}</td>
|
||||
<td>{row.PTSCount}</td>
|
||||
<td>{row.Type}</td>
|
||||
<td>{row.MessageID}</td>
|
||||
<td>{row.SenderUserID}</td>
|
||||
<td>{formatUnix(row.Date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<details className="section-block">
|
||||
<summary>{"Raw records"}</summary>
|
||||
<section className="section-block">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MessagePreview } from "../components/MessagePreview";
|
||||
import { Alert, Badge, EmptyRow, Metric, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { channelKind, formatUnix } from "../lib/format";
|
||||
import { mediaLocationKey } from "../lib/media";
|
||||
import { mediaFields } from "../lib/media";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ChannelRow, GroupMessageListResponse } from "../types";
|
||||
|
||||
@@ -86,7 +86,7 @@ export function GroupMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
body: row.Body,
|
||||
deleted: row.Deleted,
|
||||
media: Boolean(row.Media && row.Media !== "{}"),
|
||||
mediaURL: (() => { const key = mediaLocationKey(row.Media); return key ? api.mediaURL(key) : undefined; })(),
|
||||
...mediaFields(row.Media),
|
||||
post: row.Post
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MessagePreview, type PreviewMessage } from "../components/MessagePrevie
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatDate, formatUnix } from "../lib/format";
|
||||
import { mediaLocationKey } from "../lib/media";
|
||||
import { mediaFields } from "../lib/media";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { MessageDetail } from "../types";
|
||||
|
||||
@@ -51,7 +51,7 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
|
||||
outgoing: row.Outgoing,
|
||||
deleted: row.Deleted,
|
||||
media: Boolean(row.Media && row.Media !== "{}"),
|
||||
mediaURL: (() => { const key = mediaLocationKey(row.Media); return key ? api.mediaURL(key) : undefined; })()
|
||||
...mediaFields(row.Media)
|
||||
}))
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { History, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
@@ -8,16 +8,15 @@ import { MessagePreview, type PreviewMessage } from "../components/MessagePrevie
|
||||
import { Alert, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { parseIDs, toInt } from "../lib/format";
|
||||
import { mediaLocationKey } from "../lib/media";
|
||||
import { mediaFields } from "../lib/media";
|
||||
import { loadPeerNames, peerLabel, userName, type PeerNames } from "../lib/peerNames";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRow, LiveChatRow } from "../types";
|
||||
import { GroupMessagesTab } from "./GroupMessagesPage";
|
||||
|
||||
function mediaKeyURL(media?: string): string | undefined {
|
||||
const key = mediaLocationKey(media);
|
||||
return key ? api.mediaURL(key) : undefined;
|
||||
}
|
||||
const PAGE = 50;
|
||||
|
||||
type Cursor = { date: number; id: number };
|
||||
|
||||
function privatePreview(rows: {
|
||||
OwnerUserID: number;
|
||||
@@ -37,82 +36,175 @@ function privatePreview(rows: {
|
||||
outgoing: row.Outgoing,
|
||||
deleted: row.Deleted,
|
||||
media: Boolean(row.Media && row.Media !== "{}"),
|
||||
mediaURL: mediaKeyURL(row.Media)
|
||||
...mediaFields(row.Media)
|
||||
}));
|
||||
}
|
||||
|
||||
function groupPreview(rows: {
|
||||
ID: number;
|
||||
Date: number;
|
||||
SenderUserID: number;
|
||||
Body: string;
|
||||
Deleted: boolean;
|
||||
Media?: string;
|
||||
Post?: boolean;
|
||||
}[]): PreviewMessage[] {
|
||||
return rows.map((row) => ({
|
||||
id: row.ID,
|
||||
date: row.Date,
|
||||
from: row.SenderUserID,
|
||||
body: row.Body,
|
||||
deleted: row.Deleted,
|
||||
media: Boolean(row.Media && row.Media !== "{}"),
|
||||
...mediaFields(row.Media),
|
||||
post: row.Post
|
||||
}));
|
||||
}
|
||||
|
||||
function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
void navigate;
|
||||
const { t } = useI18n();
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [chats, setChats] = useState<LiveChatRow[]>([]);
|
||||
const [hasMoreChats, setHasMoreChats] = useState(false);
|
||||
const [loadingChats, setLoadingChats] = useState(false);
|
||||
const [active, setActive] = useState<LiveChatRow | null>(null);
|
||||
const [messages, setMessages] = useState<PreviewMessage[]>([]);
|
||||
const [oldest, setOldest] = useState<Cursor | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [names, setNames] = useState<PeerNames | null>(null);
|
||||
const [ids, setIDs] = useState("");
|
||||
const [revoke, setRevoke] = useState(true);
|
||||
const [names, setNames] = useState<PeerNames | null>(null);
|
||||
const [maxID, setMaxID] = useState("");
|
||||
const [maxBatches, setMaxBatches] = useState("1");
|
||||
const [justClear, setJustClear] = useState(false);
|
||||
// Guards against a slow response for chat A rendering under chat B.
|
||||
const requestRef = useRef(0);
|
||||
|
||||
function resolveNames(peers: LiveChatRow[], ownerID: number, senderIDs: number[]) {
|
||||
void loadPeerNames(
|
||||
[ownerID, ...peers.filter((c) => c.PeerType === "user").map((c) => c.PeerID), ...senderIDs],
|
||||
peers.filter((c) => c.PeerType !== "user").map((c) => c.PeerID)
|
||||
)
|
||||
.then((next) => setNames((prev) => ({ users: { ...(prev?.users ?? {}), ...next.users }, channels: { ...(prev?.channels ?? {}), ...next.channels } })))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async function chooseOwner(next: AccountRow | null) {
|
||||
requestRef.current++;
|
||||
setOwner(next);
|
||||
setChats([]);
|
||||
setHasMoreChats(false);
|
||||
setActive(null);
|
||||
setMessages([]);
|
||||
setOldest(null);
|
||||
setNames(null);
|
||||
setError("");
|
||||
if (!next) return;
|
||||
setBusy(true);
|
||||
setLoadingChats(true);
|
||||
try {
|
||||
const list = (await api.liveChats(next.ID)).chats;
|
||||
setChats(list);
|
||||
void loadPeerNames(
|
||||
[next.ID, ...list.filter((c) => c.PeerType === "user").map((c) => c.PeerID)],
|
||||
list.filter((c) => c.PeerType !== "user").map((c) => c.PeerID)
|
||||
).then(setNames).catch(() => undefined);
|
||||
const res = await api.liveChats(next.ID);
|
||||
setChats(res.chats);
|
||||
setHasMoreChats(res.has_more);
|
||||
resolveNames(res.chats, next.ID, []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setLoadingChats(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreChats() {
|
||||
if (!owner || chats.length === 0) return;
|
||||
const last = chats[chats.length - 1];
|
||||
setLoadingChats(true);
|
||||
try {
|
||||
const res = await api.liveChats(owner.ID, last.TopMessageDate, last.PeerID);
|
||||
setChats((prev) => [...prev, ...res.chats]);
|
||||
setHasMoreChats(res.has_more);
|
||||
resolveNames(res.chats, owner.ID, []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoadingChats(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConversation(chat: LiveChatRow, before: Cursor | null) {
|
||||
const ownerID = owner?.ID ?? 0;
|
||||
if (chat.PeerType === "channel" || chat.PeerType === "chat") {
|
||||
const params = new URLSearchParams({ channel_id: String(chat.PeerID), limit: String(PAGE) });
|
||||
if (before) {
|
||||
params.set("before_date", String(before.date));
|
||||
params.set("before_id", String(before.id));
|
||||
}
|
||||
const data = await api.groupMessages(params);
|
||||
return { rows: groupPreview(data.rows), senders: data.rows.map((r) => r.SenderUserID), cursor: data.rows.length ? { date: data.rows[data.rows.length - 1].Date, id: data.rows[data.rows.length - 1].ID } : null };
|
||||
}
|
||||
const params = new URLSearchParams({ owner_user_id: String(ownerID), peer_id: String(chat.PeerID), limit: String(PAGE) });
|
||||
if (before) {
|
||||
params.set("before_date", String(before.date));
|
||||
params.set("before_id", String(before.id));
|
||||
}
|
||||
const data = await api.messages(params);
|
||||
return { rows: privatePreview(data.rows), senders: data.rows.map((r) => r.FromUserID), cursor: data.rows.length ? { date: data.rows[data.rows.length - 1].Date, id: data.rows[data.rows.length - 1].BoxID } : null };
|
||||
}
|
||||
|
||||
async function openChat(chat: LiveChatRow) {
|
||||
const ticket = ++requestRef.current;
|
||||
setActive(chat);
|
||||
setMessages([]);
|
||||
setOldest(null);
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (chat.PeerType === "channel" || chat.PeerType === "chat") {
|
||||
const data = await api.groupMessages(new URLSearchParams({ channel_id: String(chat.PeerID), limit: "50" }));
|
||||
setMessages(data.rows.map((row) => ({
|
||||
id: row.ID,
|
||||
date: row.Date,
|
||||
from: row.SenderUserID,
|
||||
body: row.Body,
|
||||
deleted: row.Deleted,
|
||||
media: Boolean(row.Media && row.Media !== "{}"),
|
||||
mediaURL: mediaKeyURL(row.Media),
|
||||
post: row.Post
|
||||
})));
|
||||
void loadPeerNames(data.rows.map((row) => row.SenderUserID), []).then((next) => setNames((prev) => ({ users: { ...(prev?.users ?? {}), ...next.users }, channels: prev?.channels ?? {} }))).catch(() => undefined);
|
||||
} else {
|
||||
const data = await api.messages(new URLSearchParams({
|
||||
owner_user_id: String(owner?.ID ?? 0),
|
||||
peer_id: String(chat.PeerID),
|
||||
limit: "50"
|
||||
}));
|
||||
setMessages(privatePreview(data.rows));
|
||||
void loadPeerNames(data.rows.map((row) => row.FromUserID), []).then((next) => setNames((prev) => ({ users: { ...(prev?.users ?? {}), ...next.users }, channels: prev?.channels ?? {} }))).catch(() => undefined);
|
||||
}
|
||||
const result = await fetchConversation(chat, null);
|
||||
if (ticket !== requestRef.current) return;
|
||||
setMessages(result.rows);
|
||||
setOldest(result.rows.length >= PAGE ? result.cursor : null);
|
||||
resolveNames([chat], owner?.ID ?? 0, result.senders);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
if (ticket === requestRef.current) setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (ticket === requestRef.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = chats.filter((chat) => !filter.trim() || `${chat.PeerType} ${chat.PeerID}`.toLowerCase().includes(filter.trim().toLowerCase()));
|
||||
async function loadOlder() {
|
||||
if (!active || !oldest) return;
|
||||
const ticket = ++requestRef.current;
|
||||
const chat = active;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await fetchConversation(chat, oldest);
|
||||
if (ticket !== requestRef.current) return;
|
||||
if (result.rows.length === 0) {
|
||||
setOldest(null);
|
||||
return;
|
||||
}
|
||||
setMessages((prev) => [...result.rows, ...prev]);
|
||||
setOldest(result.rows.length >= PAGE ? result.cursor : null);
|
||||
resolveNames([chat], owner?.ID ?? 0, result.senders);
|
||||
} catch (err) {
|
||||
if (ticket === requestRef.current) setError(errorMessage(err));
|
||||
} finally {
|
||||
if (ticket === requestRef.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = chats.filter(
|
||||
(chat) => !filter.trim() || peerLabel(names, chat.PeerType, chat.PeerID).toLowerCase().includes(filter.trim().toLowerCase()) || String(chat.PeerID).includes(filter.trim())
|
||||
);
|
||||
|
||||
const detailOf = (message: PreviewMessage): string | undefined => {
|
||||
if (!active || !owner) return undefined;
|
||||
if (active.PeerType === "channel" || active.PeerType === "chat") {
|
||||
return `/messages/groups/detail?channel_id=${active.PeerID}&msg_id=${message.id}`;
|
||||
}
|
||||
const box = String(message.id).split("-")[1];
|
||||
return box ? `/messages/private/detail?owner_user_id=${owner.ID}&msg_id=${box}` : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,14 +218,10 @@ function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
<aside className="messenger-list">
|
||||
<label className="searchbox messenger-search">
|
||||
<Search size={14} />
|
||||
<input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder={"Search chats"} />
|
||||
<input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder={t("messages.searchChats")} />
|
||||
</label>
|
||||
<div className="messenger-items">
|
||||
{filtered.length === 0 && (
|
||||
<div className="messenger-placeholder">
|
||||
{owner ? "No chats" : "Pick a user to list their chats."}
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 && <div className="messenger-placeholder">{owner ? t("messages.noChats") : t("messages.pickUserForChats")}</div>}
|
||||
{filtered.map((chat) => (
|
||||
<button
|
||||
key={`${chat.PeerType}-${chat.PeerID}`}
|
||||
@@ -141,12 +229,7 @@ function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
className={`messenger-item${active?.PeerID === chat.PeerID && active?.PeerType === chat.PeerType ? " active" : ""}`}
|
||||
onClick={() => void openChat(chat)}
|
||||
>
|
||||
<Avatar
|
||||
id={Number(chat.PeerID)}
|
||||
kind={chat.PeerType === "user" ? "user" : "channel"}
|
||||
label={peerLabel(names, chat.PeerType, chat.PeerID)}
|
||||
size={38}
|
||||
/>
|
||||
<Avatar id={Number(chat.PeerID)} kind={chat.PeerType === "user" ? "user" : "channel"} label={peerLabel(names, chat.PeerType, chat.PeerID)} size={38} />
|
||||
<span className="messenger-item-body">
|
||||
<span className="messenger-item-top">
|
||||
<strong>{peerLabel(names, chat.PeerType, chat.PeerID)}</strong>
|
||||
@@ -156,32 +239,39 @@ function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{hasMoreChats && (
|
||||
<button className="btn icon-text messenger-more" type="button" disabled={loadingChats} onClick={() => void loadMoreChats()}>
|
||||
{t("messages.loadMoreChats")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<section className="messenger-chat">
|
||||
{active ? (
|
||||
<MessagePreview
|
||||
title={peerLabel(names, active.PeerType, active.PeerID)}
|
||||
emptyLabel={busy ? "Loading…" : "No messages"}
|
||||
messages={messages}
|
||||
nameOf={(from) => userName(names, Number(from))}
|
||||
avatar={
|
||||
<Avatar
|
||||
id={Number(active.PeerID)}
|
||||
kind={active.PeerType === "user" ? "user" : "channel"}
|
||||
label={peerLabel(names, active.PeerType, active.PeerID)}
|
||||
size={30}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
{oldest && (
|
||||
<button className="btn icon-text messenger-more" type="button" disabled={busy} onClick={() => void loadOlder()}>
|
||||
{t("messages.loadOlder")}
|
||||
</button>
|
||||
)}
|
||||
<MessagePreview
|
||||
title={peerLabel(names, active.PeerType, active.PeerID)}
|
||||
emptyLabel={busy ? t("common.loading") : t("messages.noMessages")}
|
||||
messages={messages}
|
||||
nameOf={(from) => userName(names, Number(from))}
|
||||
avatar={<Avatar id={Number(active.PeerID)} kind={active.PeerType === "user" ? "user" : "channel"} label={peerLabel(names, active.PeerType, active.PeerID)} size={30} />}
|
||||
detailOf={detailOf}
|
||||
onOpenDetail={(path) => navigate(path)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="messenger-placeholder">{"Select a chat to read it."}</div>
|
||||
<div className="messenger-placeholder">{t("messages.selectChat")}</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{owner && active && (
|
||||
<details className="section-block">
|
||||
<summary>{"Moderation actions"}</summary>
|
||||
<summary>{t("messages.moderationActions")}</summary>
|
||||
<div className="operation-row">
|
||||
<div className="operation-box">
|
||||
<div className="operation-title"><Trash2 size={15} /> {t("messages.deleteSelected")}</div>
|
||||
@@ -196,12 +286,16 @@ function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
</div>
|
||||
<div className="operation-box">
|
||||
<div className="operation-title"><History size={15} /> {t("messages.clearHistory")}</div>
|
||||
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={t("messages.maxIDPlaceholder")} />
|
||||
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder={t("messages.maxBatchesPlaceholder")} />
|
||||
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> {t("messages.justClear")}</label>
|
||||
<ActionButton path="/api/actions/delete-history" label={t("messages.previewClearHistory")} payload={() => ({
|
||||
owner_user_id: owner.ID,
|
||||
peer_id: active.PeerID,
|
||||
max_id: toInt(""),
|
||||
max_batches: 1,
|
||||
just_clear: false,
|
||||
max_id: toInt(maxID),
|
||||
max_batches: toInt(maxBatches),
|
||||
just_clear: justClear,
|
||||
revoke
|
||||
})} />
|
||||
</div>
|
||||
@@ -220,10 +314,9 @@ export function MessagesPage({ navigate, tab, path }: {
|
||||
tab: "private" | "groups";
|
||||
path: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
void path;
|
||||
return (
|
||||
<PageFrame title={tab === "groups" ? t("messages.groupTitle") : t("messages.privateTitle")}>
|
||||
<PageFrame title={tab === "groups" ? "Groups" : "Messages"}>
|
||||
{tab === "private" ? <PrivateMessagesTab navigate={navigate} /> : <GroupMessagesTab navigate={navigate} />}
|
||||
</PageFrame>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,9 @@ import { ArrowLeft, CheckCircle2, ExternalLink, MessagesSquare, RefreshCw, Shiel
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { MessagePreview, type PreviewMessage } from "../components/MessagePreview";
|
||||
import { useI18n, type TFunction } from "../i18n";
|
||||
import { permissionMessagesRead, useCan } from "../permissions";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { mediaLocationKey } from "../lib/media";
|
||||
import { loadPeerNames, peerLabel, userName, type PeerNames } from "../lib/peerNames";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ModerationCaseDetail, ModerationReport, ModerationReportItem } from "../types";
|
||||
@@ -31,65 +29,8 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [names, setNames] = useState<PeerNames | null>(null);
|
||||
// Live conversation preview for a reported message, loaded from the same
|
||||
// /api/messages(+/groups) routes the Messages console uses.
|
||||
const [preview, setPreview] = useState<{ title: string; rows: PreviewMessage[] } | null>(null);
|
||||
const [previewBusy, setPreviewBusy] = useState(false);
|
||||
const [previewError, setPreviewError] = useState("");
|
||||
|
||||
async function openPreview(activeReport: ModerationReport, evidence: ModerationReportItem) {
|
||||
if (evidence.Kind !== "message" || !evidence.Peer) return;
|
||||
setPreviewBusy(true);
|
||||
setPreviewError("");
|
||||
try {
|
||||
if (evidence.Peer.Type === "channel") {
|
||||
const data = await api.groupMessages(new URLSearchParams({ channel_id: String(evidence.Peer.ID), limit: "50" }));
|
||||
setPreview({
|
||||
title: `Channel ${evidence.Peer.ID}`,
|
||||
rows: data.rows.map((row) => ({
|
||||
id: row.ID,
|
||||
date: row.Date,
|
||||
from: row.SenderUserID,
|
||||
body: row.Body,
|
||||
deleted: row.Deleted,
|
||||
media: Boolean(row.Media && row.Media !== "{}"),
|
||||
mediaURL: (() => { const key = mediaLocationKey(row.Media); return key ? api.mediaURL(key) : undefined; })(),
|
||||
post: row.Post
|
||||
}))
|
||||
});
|
||||
} else {
|
||||
const data = await api.messages(new URLSearchParams({
|
||||
owner_user_id: String(activeReport.ReporterUserID),
|
||||
peer_id: String(evidence.Peer.ID),
|
||||
limit: "50"
|
||||
}));
|
||||
setPreview({
|
||||
title: `${activeReport.ReporterUserID} ↔ ${evidence.Peer.ID}`,
|
||||
rows: data.rows.map((row) => ({
|
||||
id: `${row.OwnerUserID}-${row.BoxID}`,
|
||||
date: row.Date,
|
||||
from: row.FromUserID,
|
||||
body: row.Body,
|
||||
outgoing: row.Outgoing,
|
||||
deleted: row.Deleted,
|
||||
media: Boolean(row.Media && row.Media !== "{}"),
|
||||
mediaURL: (() => { const key = mediaLocationKey(row.Media); return key ? api.mediaURL(key) : undefined; })()
|
||||
}))
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setPreviewError(errorMessage(err));
|
||||
} finally {
|
||||
setPreviewBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectReport(next: ModerationReport | null) {
|
||||
// The reported content is shown as chat bubbles in the evidence list; do
|
||||
// not also auto-load the whole conversation (that duplicates it). The
|
||||
// conversation opens only on demand via "Preview messages".
|
||||
setPreview(null);
|
||||
setPreviewError("");
|
||||
setReport(next);
|
||||
if (!next) return;
|
||||
const ids = next.Items
|
||||
@@ -281,7 +222,7 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||
<Summary label={t("common.time")} value={formatDate(report.CreatedAt)} />
|
||||
</div>
|
||||
{report.Comment && <p className="about-text">{report.Comment}</p>}
|
||||
<ReportEvidence t={t} report={report} navigate={navigate} names={names} onPreview={(evidence) => { if (report) void openPreview(report, evidence); }} previewBusy={previewBusy} />
|
||||
<ReportEvidence t={t} report={report} navigate={navigate} names={names} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -406,13 +347,11 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||
// message itself is one click away in the Messages console when the operator
|
||||
// has messages.read. Media held as evidence gets its own small table so an
|
||||
// operator can act on it.
|
||||
function ReportEvidence({ t, report, navigate, names, onPreview, previewBusy }: {
|
||||
function ReportEvidence({ t, report, navigate, names }: {
|
||||
t: TFunction;
|
||||
report: ModerationReport;
|
||||
navigate: Navigate;
|
||||
names: PeerNames | null;
|
||||
onPreview: (evidence: ModerationReportItem) => void;
|
||||
previewBusy: boolean;
|
||||
}) {
|
||||
const canReadMessages = useCan(permissionMessagesRead);
|
||||
// A message can show up more than once across report items; collapse identical
|
||||
@@ -431,14 +370,7 @@ function ReportEvidence({ t, report, navigate, names, onPreview, previewBusy }:
|
||||
other.Peer?.ID === item.Peer?.ID
|
||||
) === index
|
||||
);
|
||||
const kindLabel: Record<string, string> = {
|
||||
story: "Story",
|
||||
profile_photo: "Profile photo",
|
||||
reaction: "Reaction on a message",
|
||||
peer: "Whole profile",
|
||||
encrypted_chat: "Secret chat",
|
||||
sponsored: "Sponsored message"
|
||||
};
|
||||
const kindLabel = (kind: string) => moderationEnumLabel(t, "itemKind", kind);
|
||||
return (
|
||||
<div className="detail-stack">
|
||||
{evidenceItems.map((evidence, index) => {
|
||||
@@ -451,9 +383,9 @@ function ReportEvidence({ t, report, navigate, names, onPreview, previewBusy }:
|
||||
const label =
|
||||
evidence.Kind === "message"
|
||||
? evidence.Peer?.Type === "channel"
|
||||
? "Channel message"
|
||||
: "Private message"
|
||||
: kindLabel[evidence.Kind] ?? evidence.Kind;
|
||||
? t("moderation.channelMessage")
|
||||
: t("moderation.privateMessage")
|
||||
: kindLabel(evidence.Kind);
|
||||
return (
|
||||
<div className="evidence-item" key={`${evidence.Kind}-${index}`}>
|
||||
<div className="evidence-head">
|
||||
@@ -463,14 +395,16 @@ function ReportEvidence({ t, report, navigate, names, onPreview, previewBusy }:
|
||||
</div>
|
||||
<div className="chat-row in">
|
||||
<div className={`chat-bubble${media && !body ? " media-only" : ""}`}>
|
||||
{mediaHold ? (
|
||||
{mediaHold && canReadMessages && mediaHold.Kind === "photo" ? (
|
||||
<img className="evidence-media-img" src={api.mediaURL(mediaHold.StorageKey)} alt="" loading="lazy" />
|
||||
) : mediaHold && canReadMessages ? (
|
||||
<a className="msg-preview-media" href={api.mediaURL(mediaHold.StorageKey)} target="_blank" rel="noreferrer">{t("common.download")}</a>
|
||||
) : media ? (
|
||||
<div className="evidence-media-placeholder">{media}</div>
|
||||
) : null}
|
||||
{body && <div className="chat-text">{body}</div>}
|
||||
<div className="chat-meta">
|
||||
{evidence.AuthorUserID ? <span>{`from ${userName(names, evidence.AuthorUserID)}`}</span> : null}
|
||||
{evidence.AuthorUserID ? <span>{`${t("messages.fromLabel")} ${userName(names, evidence.AuthorUserID)}`}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,7 +420,7 @@ function ReportEvidence({ t, report, navigate, names, onPreview, previewBusy }:
|
||||
})}
|
||||
{evidenceItems.length === 0 && <div className="empty-panel">{t("common.noResults")}</div>}
|
||||
<details className="section-block">
|
||||
<summary>{"Raw report data"}</summary>
|
||||
<summary>{t("moderation.rawReportData")}</summary>
|
||||
<JsonBlock value={JSON.stringify({ items: report.Items, media_holds: report.MediaHolds }, null, 2)} />
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,12 @@ export function ServerSettingsPage({ search }: { search?: URLSearchParams }) {
|
||||
if (focus === "identity") setSection("identity");
|
||||
else if (focus === "login") setSection("messages");
|
||||
}, [focus]);
|
||||
// ?focus=env lands on the first .env group once they are loaded.
|
||||
useEffect(() => {
|
||||
if (focus === "env" && section === "status" && groups.length > 0) {
|
||||
setSection(`env:${groups[0].title}` as SettingsSection);
|
||||
}
|
||||
}, [focus, section, groups]);
|
||||
|
||||
const q = needle.trim().toLowerCase();
|
||||
// A search looks through every .env field (key, description, group title) and
|
||||
@@ -73,11 +79,11 @@ export function ServerSettingsPage({ search }: { search?: URLSearchParams }) {
|
||||
<nav className="settings-nav" aria-label={t("serverSettings.tabsLabel")}>
|
||||
<label className="searchbox settings-search">
|
||||
<Search size={15} />
|
||||
<input value={needle} onChange={(event) => setNeedle(event.target.value)} placeholder={"Search settings"} aria-label={"Search settings"} />
|
||||
<input value={needle} onChange={(event) => setNeedle(event.target.value)} placeholder={t("serverSettings.searchSettings")} aria-label={t("serverSettings.searchSettings")} />
|
||||
</label>
|
||||
{!q && (
|
||||
<>
|
||||
<SettingsNavItem label={"Status"} active={section === "status"} onClick={() => setSection("status")} />
|
||||
<SettingsNavItem label={t("serverSettings.statusLabel")} active={section === "status"} onClick={() => setSection("status")} />
|
||||
<SettingsNavItem label={t("serverSettings.identityTitle")} active={section === "identity"} onClick={() => setSection("identity")} />
|
||||
<SettingsNavItem label={t("nav.loginMessages")} active={section === "messages"} onClick={() => setSection("messages")} />
|
||||
{groups.length > 0 && <div className="settings-nav-label">{t("nav.configuration")}</div>}
|
||||
@@ -97,10 +103,10 @@ export function ServerSettingsPage({ search }: { search?: URLSearchParams }) {
|
||||
{envError && <Alert>{envError}</Alert>}
|
||||
{q ? (
|
||||
matches.length === 0 ? (
|
||||
<p className="about-text">{`Nothing in the configuration matches "${needle.trim()}".`}</p>
|
||||
<p className="about-text">{`${t("serverSettings.noMatch")} "${needle.trim()}".`}</p>
|
||||
) : (
|
||||
matches.map((group) => (
|
||||
<EnvGroupPanel key={group.title} group={group} values={values} setValues={setValues} onSaved={loadEnv} />
|
||||
<EnvGroupPanel key={group.title} group={group} values={values} setValues={setValues} onSaved={() => undefined} />
|
||||
))
|
||||
)
|
||||
) : section === "status" ? (
|
||||
@@ -110,7 +116,7 @@ export function ServerSettingsPage({ search }: { search?: URLSearchParams }) {
|
||||
) : section === "messages" ? (
|
||||
<LoginNotificationsSection />
|
||||
) : activeGroup ? (
|
||||
<EnvGroupPanel group={activeGroup} values={values} setValues={setValues} onSaved={loadEnv} />
|
||||
<EnvGroupPanel group={activeGroup} values={values} setValues={setValues} onSaved={() => undefined} />
|
||||
) : (
|
||||
<LoadingSurface label={"Loading settings..."} />
|
||||
)}
|
||||
|
||||
@@ -2537,3 +2537,22 @@ td.evidence-message-cell {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
||||
.messenger-more {
|
||||
margin: 6px;
|
||||
}
|
||||
.msg-preview-link {
|
||||
margin-right: 6px;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
.msg-preview-link:hover {
|
||||
color: var(--brand);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
@@ -1360,6 +1360,7 @@ export type LiveChatRow = {
|
||||
export type LiveChatListResponse = {
|
||||
user_id: number;
|
||||
chats: LiveChatRow[];
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user