added ability to broadcast
This commit is contained in:
parent
7d41cbeb1e
commit
2491088e81
31 changed files with 1607 additions and 18 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue