added ability to broadcast

This commit is contained in:
onysd 2026-08-06 15:22:36 +03:00
parent 7d41cbeb1e
commit 2491088e81
31 changed files with 1607 additions and 18 deletions

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

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-DWg-er34.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bv1l1T8K.css">
<script type="module" crossorigin src="/assets/index-CS-EAiSc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-sqNghGhC.css">
</head>
<body>
<div id="root"></div>

View file

@ -13,6 +13,7 @@ import type {
BotListResponse,
BotVerificationCountsResponse,
BotVerifierListResponse,
BroadcastListResponse,
ChannelDetail,
CustomVerificationListResponse,
CustomVerificationRequestDetail,
@ -160,6 +161,7 @@ export const api = {
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
broadcasts: (params: URLSearchParams) => request<BroadcastListResponse>(`/api/broadcasts?${params.toString()}`),
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
collectibleUsernames: (params: URLSearchParams) =>
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),

View file

@ -0,0 +1,80 @@
import { Send, X } from "lucide-react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { AccountRow } from "../types";
import { ActionButton } from "./ActionButton";
import { MultiUserPicker } from "./EntityPicker";
type TargetMode = "all" | "selected";
// CreateBroadcastModal composes the message and target list, then hands off to
// ActionButton for the usual dry-run/confirm flow. "All users" is resolved to an
// explicit id list server-side (cmd/telesrv-admin/server.go), not here -- the
// picker only ever deals with an actual, visible list of accounts.
export function CreateBroadcastModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [message, setMessage] = useState("");
const [targetMode, setTargetMode] = useState<TargetMode>("all");
const [recipients, setRecipients] = useState<AccountRow[]>([]);
const disabled = useMemo(() => {
if (!message.trim()) return true;
if (targetMode === "selected" && recipients.length === 0) return true;
return false;
}, [message, targetMode, recipients]);
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Send broadcast"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Broadcasts"}</div>
<h2>{"Send broadcast"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p>{"Sends a message from the official system account (777000) to all users or to a chosen list. Delivery happens in the background and may take a few minutes for large audiences."}</p>
<label className="form-field">
<span>{"Message"}</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
rows={5}
maxLength={4096}
placeholder={"What's new..."}
/>
</label>
<div className="bot-create-fields">
<label className="duration-field">
<span>{"Target"}</span>
<select value={targetMode} onChange={(event) => setTargetMode(event.target.value as TargetMode)}>
<option value="all">{"All users"}</option>
<option value="selected">{"Selected users"}</option>
</select>
</label>
</div>
{targetMode === "selected" && (
<MultiUserPicker label={"Recipients"} selected={recipients} onChange={setRecipients} />
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
<ActionButton
label={"Send broadcast"}
icon={<Send size={15} />}
tone="neutral"
path="/api/actions/create-broadcast"
disabled={disabled}
payload={() => ({
message: message.trim(),
target_mode: targetMode,
user_ids: targetMode === "selected" ? recipients.map((row) => row.ID) : undefined
})}
onDone={onCreated}
/>
</div>
</section>
</div>,
document.body
);
}

View file

@ -98,6 +98,119 @@ export function UserPicker({
);
}
// MultiUserPicker is UserPicker's search widget with a running selection
// instead of a single slot -- clicking a result toggles it in or out of the
// list, shown above the search box as removable chips.
export function MultiUserPicker({
label,
selected,
onChange
}: {
label: string;
selected: AccountRow[];
onChange: (rows: AccountRow[]) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<AccountRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim());
}
try {
const result = await api.accounts(params);
setRows(result.rows);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
function toggle(row: AccountRow) {
if (selected.some((entry) => entry.ID === row.ID)) {
onChange(selected.filter((entry) => entry.ID !== row.ID));
} else {
onChange([...selected, row]);
}
}
function remove(id: number) {
onChange(selected.filter((entry) => entry.ID !== id));
}
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{selected.length > 0 ? (
<button className="link-button" type="button" onClick={() => onChange([])}>
<X size={13} /> {"Clear all"}
</button>
) : null}
</div>
{selected.length > 0 ? (
<div className="picker-chip-list">
{selected.map((row) => (
<span key={row.ID} className="picker-chip">
{displayName(row)} <span className="mono">{row.ID}</span>
<button type="button" onClick={() => remove(row.ID)} aria-label={`Remove ${row.ID}`}>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder={"Search user_id / phone / username"}
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "Search"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => {
const isSelected = selected.some((entry) => entry.ID === row.ID);
return (
<button
key={row.ID}
className={`picker-row ${isSelected ? "selected" : ""}`}
type="button"
onClick={() => toggle(row)}
>
<span className="mono">{row.ID}</span>
<strong>{displayName(row)}</strong>
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
{isSelected ? <Check size={15} /> : null}
</button>
);
})}
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
</div>
</div>
);
}
// BotPicker is the same widget over /api/bots. Verifier status is granted to a bot
// account, and an operator knows the handle rather than the id, so the grant form
// resolves it here instead of asking for a raw number.

View file

@ -6,6 +6,7 @@ import {
Database,
LayoutDashboard,
LogOut,
Megaphone,
MessageSquareText,
ShieldAlert,
ShieldCheck,
@ -92,6 +93,7 @@ export function Shell({
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
{canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
)}

View file

@ -0,0 +1,152 @@
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Send } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { CreateBroadcastModal } from "../components/CreateBroadcastModal";
import { Alert, Badge, EmptyRow, Metric, PageFrame } from "../components/ui";
import { formatDate } from "../lib/format";
import type { BroadcastListResponse } from "../types";
type Cursor = { beforeID: number };
const zeroCursor: Cursor = { beforeID: 0 };
export function BroadcastsPage() {
const [data, setData] = useState<BroadcastListResponse | null>(null);
const [history, setHistory] = useState<Cursor[]>([]);
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [createModalOpen, setCreateModalOpen] = useState(false);
async function fetchPage(at: Cursor) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "50" });
if (at.beforeID) {
params.set("before_id", String(at.beforeID));
}
try {
const result = await api.broadcasts(params);
setData(result);
return result;
} catch (err) {
setError(errorMessage(err));
return null;
} finally {
setBusy(false);
}
}
async function loadFresh() {
setHistory([]);
setCursor(zeroCursor);
await fetchPage(zeroCursor);
}
async function loadNext() {
if (!data?.has_more) return;
const at = { beforeID: data.next_before_id };
const result = await fetchPage(at);
if (result) {
setHistory((prev) => [...prev, cursor]);
setCursor(at);
}
}
async function loadPrev() {
if (history.length === 0) return;
const at = history[history.length - 1];
const result = await fetchPage(at);
if (result) {
setHistory((prev) => prev.slice(0, -1));
setCursor(at);
}
}
useEffect(() => {
void loadFresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rows = data?.rows ?? [];
const inFlight = rows.filter((row) => row.SentCount + row.FailedCount < row.TotalCount).length;
const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy;
return (
<PageFrame
title={"Broadcasts"}
eyebrow={"Announcements sent from the official system account"}
actions={
<>
<button className="btn primary icon-text" type="button" onClick={() => setCreateModalOpen(true)}>
<Send size={15} /> {"Send broadcast"}
</button>
<button className="btn" type="button" onClick={() => void loadFresh()} disabled={busy}>
<RefreshCw size={15} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Campaigns on page"} value={String(rows.length)} />
<Metric label={"Still delivering"} value={String(inFlight)} tone={inFlight > 0 ? "warn" : "neutral"} />
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Message"}</th>
<th>{"Target"}</th>
<th>{"Sent"}</th>
<th>{"Failed"}</th>
<th>{"Total"}</th>
<th>{"Created by"}</th>
<th>{"Created"}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const delivered = row.SentCount + row.FailedCount;
const done = row.TotalCount > 0 && delivered >= row.TotalCount;
return (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td className="truncate">{row.Message}</td>
<td>{row.TargetMode === "all" ? <Badge tone="warn">{"All users"}</Badge> : <Badge>{"Selected"}</Badge>}</td>
<td>{row.SentCount}</td>
<td>{row.FailedCount > 0 ? <Badge tone="danger">{row.FailedCount}</Badge> : row.FailedCount}</td>
<td>{row.TotalCount}</td>
<td>{row.CreatedBy || "-"}</td>
<td>
{formatDate(row.CreatedAt)}
{!done && <Badge tone="warn">{"Sending"}</Badge>}
</td>
</tr>
);
})}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {"Previous page"}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronRight size={15} />} {"Next page"}
</button>
</div>
{createModalOpen && (
<CreateBroadcastModal
onClose={() => setCreateModalOpen(false)}
onCreated={() => void loadFresh()}
/>
)}
</PageFrame>
);
}

View file

@ -10,6 +10,7 @@ import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
import { BotsPage } from "./BotsPage";
import { BroadcastsPage } from "./BroadcastsPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
@ -121,6 +122,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/moderation") {
return <ModerationCasesPage navigate={navigate} />;
}
if (route.path === "/broadcasts") {
return <BroadcastsPage />;
}
if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />;
}

View file

@ -27,6 +27,7 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
if (pathname.startsWith("/bots")) return "Bots";
if (pathname.startsWith("/moderation")) return "Reports and Moderation";
if (pathname.startsWith("/broadcasts")) return "Broadcasts";
if (pathname.startsWith("/emoji")) return "Emoji";
if (pathname.startsWith("/messages")) return "Message Audit";
if (pathname.startsWith("/give-gifts")) return "Give Gifts";

View file

@ -317,6 +317,40 @@
text-align: center;
}
.picker-chip-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.picker-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
color: var(--brand-tint-text);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.picker-chip button {
display: inline-flex;
align-items: center;
padding: 0;
color: inherit;
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.75;
}
.picker-chip button:hover {
opacity: 1;
}
.emoji-picker-row {
grid-template-columns: 36px minmax(140px, 1fr) minmax(100px, 1fr);
}

View file

@ -872,6 +872,24 @@ export type BotListResponse = {
listing: boolean;
};
export type BroadcastRow = {
ID: number;
Message: string;
TargetMode: string;
TotalCount: number;
SentCount: number;
FailedCount: number;
CreatedBy: string;
CreatedAt: string;
};
export type BroadcastListResponse = {
limit: number;
rows: BroadcastRow[];
has_more: boolean;
next_before_id: number;
};
export type EmojiRow = {
DocumentID: string;
Alt: string;