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

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;