updated bots,channels and supergroups lists and edit screens
This commit is contained in:
parent
40d49d5e97
commit
0b3c861057
21 changed files with 796 additions and 198 deletions
|
|
@ -58,6 +58,7 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
||||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
||||||
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
|
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
|
||||||
|
mux.Handle("GET /api/channels/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleChannelAvatarAPI)))
|
||||||
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
|
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
|
||||||
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
|
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
|
||||||
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI)))
|
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI)))
|
||||||
|
|
@ -100,6 +101,7 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI)))
|
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI)))
|
||||||
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
|
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
|
||||||
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
|
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
|
||||||
|
mux.Handle("POST /api/actions/set-channel-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelAvatarAPI)))
|
||||||
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
|
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
|
||||||
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI)))
|
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI)))
|
||||||
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
|
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
|
||||||
|
|
@ -709,6 +711,90 @@ func (s *server) handleAccountAvatarAPI(w http.ResponseWriter, r *http.Request)
|
||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleChannelAvatarAPI streams a channel's current avatar straight through
|
||||||
|
// from the real telesrv admin API (/v1/channels/{id}/avatar), mirroring
|
||||||
|
// handleAccountAvatarAPI.
|
||||||
|
func (s *server) handleChannelAvatarAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID, err := parseInt64(r.PathValue("id"))
|
||||||
|
if err != nil || channelID <= 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiPath := fmt.Sprintf("/v1/channels/%d/avatar", channelID)
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type setChannelAvatarAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
ChannelID int64 `json:"channel_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleSetChannelAvatarAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
var body setChannelAvatarAPIRequest
|
||||||
|
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "avatar file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||||
|
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.SetChannelAvatarRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-avatar"),
|
||||||
|
ChannelID: body.ChannelID,
|
||||||
|
FileName: header.Filename,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminMultipart(r.Context(), "/v1/channels/set-avatar", req, header.Filename, data)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleBotsAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleBotsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.read == nil {
|
if s.read == nil {
|
||||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
10
cmd/telesrv-admin/web/dist/assets/index-DZsC6sWi.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-DZsC6sWi.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-CKkpOZxZ.js"></script>
|
<script type="module" crossorigin src="/assets/index-DZsC6sWi.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ export const api = {
|
||||||
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
|
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
|
||||||
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
|
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
|
||||||
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
||||||
|
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
|
||||||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||||
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
|
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
|
||||||
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),
|
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),
|
||||||
|
|
|
||||||
|
|
@ -34,18 +34,29 @@ function avatarInitials(firstName: string, lastName: string, username: string):
|
||||||
return out.toUpperCase();
|
return out.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AvatarKind = "user" | "channel";
|
||||||
|
|
||||||
|
// Avatar renders a user's or channel's current photo, reading through the
|
||||||
|
// admin console's proxy (/api/accounts/{id}/avatar or /api/channels/{id}/avatar).
|
||||||
|
// It falls back to a gradient-tinted initials tile — identical to the public
|
||||||
|
// preview cards — when there's no photo or the fetch fails.
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
userID,
|
id,
|
||||||
firstName,
|
kind = "user",
|
||||||
lastName,
|
firstName = "",
|
||||||
|
lastName = "",
|
||||||
username = "",
|
username = "",
|
||||||
|
title = "",
|
||||||
size = 34,
|
size = 34,
|
||||||
refreshKey
|
refreshKey
|
||||||
}: {
|
}: {
|
||||||
userID: number;
|
id: number;
|
||||||
firstName: string;
|
kind?: AvatarKind;
|
||||||
lastName: string;
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
// title is the channel/supergroup name, used for initials when kind="channel".
|
||||||
|
title?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
// refreshKey busts the browser's cached image (Cache-Control: max-age=300)
|
// refreshKey busts the browser's cached image (Cache-Control: max-age=300)
|
||||||
// right after an admin-driven avatar change, so the new photo shows up
|
// right after an admin-driven avatar change, so the new photo shows up
|
||||||
|
|
@ -56,24 +67,25 @@ export function Avatar({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
}, [userID, refreshKey]);
|
}, [id, kind, refreshKey]);
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
const [from, to] = avatarGradient(userID);
|
const [from, to] = avatarGradient(id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="avatar-fallback"
|
className="avatar-fallback"
|
||||||
style={{ width: size, height: size, background: `linear-gradient(135deg, ${from}, ${to})`, fontSize: Math.round(size * 0.42) }}
|
style={{ width: size, height: size, background: `linear-gradient(135deg, ${from}, ${to})`, fontSize: Math.round(size * 0.42) }}
|
||||||
>
|
>
|
||||||
{avatarInitials(firstName, lastName, username)}
|
{kind === "channel" ? avatarInitials(title, "", username) : avatarInitials(firstName, lastName, username)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const basePath = kind === "channel" ? `/api/channels/${id}/avatar` : `/api/accounts/${id}/avatar`;
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className="avatar-photo-img"
|
className="avatar-photo-img"
|
||||||
src={`/api/accounts/${userID}/avatar${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
|
src={`${basePath}${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import { createPortal } from "react-dom";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { Alert } from "./ui";
|
import { Alert } from "./ui";
|
||||||
|
|
||||||
// AccountAvatarModal uploads a new profile photo for an account. It follows
|
type AvatarModalKind = "user" | "channel";
|
||||||
// CreateStickerSetModal's shape (a single reason field + direct execute,
|
|
||||||
// rather than ActionButton's JSON dry-run/confirm flow) since the payload is
|
// AvatarModal uploads a new photo for an account or a channel/supergroup. It
|
||||||
// a multipart file upload, not a plain JSON body.
|
// follows CreateStickerSetModal's shape (a single reason field + direct
|
||||||
export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number; onClose: () => void; onDone: () => void }) {
|
// execute, rather than ActionButton's JSON dry-run/confirm flow) since the
|
||||||
|
// payload is a multipart file upload, not a plain JSON body.
|
||||||
|
export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [previewURL, setPreviewURL] = useState("");
|
const [previewURL, setPreviewURL] = useState("");
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
|
|
@ -37,10 +39,11 @@ export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
const idField = kind === "channel" ? "channel_id" : "user_id";
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, user_id: userID }));
|
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, [idField]: id }));
|
||||||
form.set("file", file, file.name);
|
form.set("file", file, file.name);
|
||||||
const result = await api.setAccountAvatar(form);
|
const result = kind === "channel" ? await api.setChannelAvatar(form) : await api.setAccountAvatar(form);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
return;
|
return;
|
||||||
|
|
@ -54,12 +57,14 @@ export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noun = kind === "channel" ? "Channel" : "Account";
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="modal-backdrop" role="presentation">
|
<div className="modal-backdrop" role="presentation">
|
||||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change avatar"}>
|
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change avatar"}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow">{"Account"}</div>
|
<div className="eyebrow">{noun}</div>
|
||||||
<h2>{"Change avatar"}</h2>
|
<h2>{"Change avatar"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
|
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { AccountAvatarModal } from "../components/AccountAvatarModal";
|
import { AvatarModal } from "../components/AvatarModal";
|
||||||
import { ActionButton } from "../components/ActionButton";
|
import { ActionButton } from "../components/ActionButton";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { AuthorizationTable } from "../components/AuthorizationTable";
|
import { AuthorizationTable } from "../components/AuthorizationTable";
|
||||||
|
|
@ -74,7 +74,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
||||||
<section className="entity-head">
|
<section className="entity-head">
|
||||||
<div className="entity-head-main">
|
<div className="entity-head-main">
|
||||||
<div className="avatar-edit-slot">
|
<div className="avatar-edit-slot">
|
||||||
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} refreshKey={avatarVersion || undefined} />
|
<Avatar id={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} refreshKey={avatarVersion || undefined} />
|
||||||
<button
|
<button
|
||||||
className="icon-btn avatar-edit-btn"
|
className="icon-btn avatar-edit-btn"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -305,8 +305,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{avatarModalOpen && (
|
{avatarModalOpen && (
|
||||||
<AccountAvatarModal
|
<AvatarModal
|
||||||
userID={account.ID}
|
kind="user"
|
||||||
|
id={account.ID}
|
||||||
onClose={() => setAvatarModalOpen(false)}
|
onClose={() => setAvatarModalOpen(false)}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setAvatarVersion((v) => v + 1);
|
setAvatarVersion((v) => v + 1);
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
||||||
<tbody>
|
<tbody>
|
||||||
{data?.rows.map((row) => (
|
{data?.rows.map((row) => (
|
||||||
<tr key={row.ID}>
|
<tr key={row.ID}>
|
||||||
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
|
<td className="avatar-col"><Avatar id={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
|
||||||
<td className="mono">{row.ID}</td>
|
<td className="mono">{row.ID}</td>
|
||||||
<td>{displayPhone(row.Phone)}</td>
|
<td>{displayPhone(row.Phone)}</td>
|
||||||
<td><UsernameCell username={row.Username} collectibles={row.Collectibles} /></td>
|
<td><UsernameCell username={row.Username} collectibles={row.Collectibles} /></td>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,25 @@
|
||||||
import { ArrowLeft, BadgeCheck, Trash2 } from "lucide-react";
|
import { ArrowLeft, BadgeCheck, ImagePlus, ScrollText, Settings2, Trash2, UserRound } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { ActionButton } from "../components/ActionButton";
|
import { ActionButton } from "../components/ActionButton";
|
||||||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { AvatarModal } from "../components/AvatarModal";
|
||||||
|
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||||
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
||||||
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
|
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
|
||||||
import { displayUsername, formatDate } from "../lib/format";
|
import { displayUsername, formatDate } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { BotDetail } from "../types";
|
import type { BotDetail } from "../types";
|
||||||
|
|
||||||
|
type Tab = "profile" | "actions";
|
||||||
|
|
||||||
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||||
const [detail, setDetail] = useState<BotDetail | null>(null);
|
const [detail, setDetail] = useState<BotDetail | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [tab, setTab] = useState<Tab>("profile");
|
||||||
|
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
|
||||||
|
const [avatarVersion, setAvatarVersion] = useState(0);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
@ -28,6 +35,8 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
|
setTab("profile");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -38,26 +47,59 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
|
||||||
}
|
}
|
||||||
|
|
||||||
const bot = detail.Bot;
|
const bot = detail.Bot;
|
||||||
|
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
|
||||||
|
{ key: "profile", label: "Profile & Status", icon: <UserRound size={15} /> },
|
||||||
|
{ key: "actions", label: "Actions & Management", icon: <Settings2 size={15} /> }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame
|
<PageFrame
|
||||||
title={`Bot #${bot.ID}`}
|
title={`Bot #${bot.ID}`}
|
||||||
eyebrow={"Bot Profile"}
|
eyebrow={"Bot Profile"}
|
||||||
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {"Back to list"}</button>}
|
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {"Back to list"}</button>}
|
||||||
>
|
>
|
||||||
<SplitLayout
|
|
||||||
main={
|
|
||||||
<div className="stacked-sections">
|
|
||||||
<section className="entity-head">
|
<section className="entity-head">
|
||||||
|
<div className="entity-head-main">
|
||||||
|
<div className="avatar-edit-slot">
|
||||||
|
<Avatar id={bot.ID} firstName={bot.FirstName} username={bot.Username} size={64} refreshKey={avatarVersion || undefined} />
|
||||||
|
<button
|
||||||
|
className="icon-btn avatar-edit-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label={"Change avatar"}
|
||||||
|
title={"Change avatar"}
|
||||||
|
onClick={() => setAvatarModalOpen(true)}
|
||||||
|
>
|
||||||
|
<ImagePlus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="entity-title">{bot.FirstName || "Unnamed bot"}</div>
|
<div className="entity-title">{bot.FirstName || "Unnamed bot"}</div>
|
||||||
<div className="entity-subtitle">{displayUsername(bot.Username) || "No username"}</div>
|
<div className="entity-subtitle">{displayUsername(bot.Username) || "No username"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="entity-badges">
|
<div className="entity-badges">
|
||||||
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? "System" : "User"}</Badge>
|
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? "System" : "User"}</Badge>
|
||||||
{bot.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
|
{bot.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
|
||||||
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
|
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="toolbar" role="group" aria-label={"Bot sections"}>
|
||||||
|
{tabs.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tab === item.key}
|
||||||
|
onClick={() => setTab(item.key)}
|
||||||
|
>
|
||||||
|
{item.icon} {item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "profile" && (
|
||||||
|
<div className="stacked-sections">
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
<Summary label={"Bot ID"} value={String(bot.ID)} mono />
|
<Summary label={"Bot ID"} value={String(bot.ID)} mono />
|
||||||
<Summary label={"Owner"} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : "None"} />
|
<Summary label={"Owner"} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : "None"} />
|
||||||
|
|
@ -67,15 +109,14 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
|
||||||
</div>
|
</div>
|
||||||
{detail.About && <p className="about-text">{detail.About}</p>}
|
{detail.About && <p className="about-text">{detail.About}</p>}
|
||||||
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
|
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
|
||||||
<section className="section-block">
|
|
||||||
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
|
|
||||||
<AuditTable rows={detail.AuditLogs} />
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
side={
|
|
||||||
<section className="action-dock">
|
{tab === "actions" && (
|
||||||
<div className="dock-title">{"Bot Actions"}</div>
|
<div className="stacked-sections">
|
||||||
|
<div className="action-groups">
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Verification & Moderation Flags"} />
|
||||||
<div className="action-stack">
|
<div className="action-stack">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={bot.Verified ? "Clear verified" : "Set verified"}
|
label={bot.Verified ? "Clear verified" : "Set verified"}
|
||||||
|
|
@ -87,10 +128,25 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
|
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
|
||||||
<div className="dock-title">{"Attributes"}</div>
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Username"} />
|
||||||
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
|
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Profile Color"} />
|
||||||
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
|
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Emoji Status"} />
|
||||||
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
|
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Danger Zone"} />
|
||||||
{bot.System ? (
|
{bot.System ? (
|
||||||
<p className="bot-create-note">{"System bots are built in and cannot be deleted."}</p>
|
<p className="bot-create-note">{"System bots are built in and cannot be deleted."}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -107,8 +163,26 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} action={<ScrollText size={16} />} />
|
||||||
|
<AuditTable rows={detail.AuditLogs} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avatarModalOpen && (
|
||||||
|
<AvatarModal
|
||||||
|
kind="user"
|
||||||
|
id={bot.ID}
|
||||||
|
onClose={() => setAvatarModalOpen(false)}
|
||||||
|
onDone={() => {
|
||||||
|
setAvatarVersion((v) => v + 1);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</PageFrame>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,28 @@
|
||||||
import { BadgeCheck, Bot, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
|
import { BadgeCheck, Bot, ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { ActionButton } from "../components/ActionButton";
|
import { ActionButton } from "../components/ActionButton";
|
||||||
|
import { Avatar } from "../components/Avatar";
|
||||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||||
import { ScamFakeBadges } from "../components/flags";
|
import { ScamFakeBadges } from "../components/flags";
|
||||||
import { displayUsername, formatDate, toInt } from "../lib/format";
|
import { displayUsername, formatDate, toInt } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { BotListResponse } from "../types";
|
import type { BotListResponse } from "../types";
|
||||||
|
|
||||||
|
type Cursor = { beforeID: number };
|
||||||
|
type BotPageSize = 10 | 20 | 50 | 100;
|
||||||
|
|
||||||
|
const zeroCursor: Cursor = { beforeID: 0 };
|
||||||
|
|
||||||
export function BotsPage({ navigate }: { navigate: Navigate }) {
|
export function BotsPage({ navigate }: { navigate: Navigate }) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [limit, setLimit] = useState("50");
|
const [limit, setLimit] = useState<BotPageSize>(50);
|
||||||
const [data, setData] = useState<BotListResponse | null>(null);
|
const [data, setData] = useState<BotListResponse | null>(null);
|
||||||
const [cursor, setCursor] = useState(0);
|
// history holds the cursor used to reach every page before the current
|
||||||
|
// one, so "Previous" can pop back without re-deriving offsets -- keyset
|
||||||
|
// pagination has no notion of "page N" to jump back to otherwise.
|
||||||
|
const [history, setHistory] = useState<Cursor[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
|
@ -20,40 +30,71 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
||||||
const [botName, setBotName] = useState("");
|
const [botName, setBotName] = useState("");
|
||||||
const [botUsername, setBotUsername] = useState("");
|
const [botUsername, setBotUsername] = useState("");
|
||||||
|
|
||||||
async function load(next = false) {
|
async function fetchPage(query: string, at: Cursor) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
const params = new URLSearchParams({ limit });
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
if (q.trim()) {
|
if (query.trim()) {
|
||||||
params.set("q", q.trim());
|
params.set("q", query.trim());
|
||||||
} else if (next) {
|
}
|
||||||
params.set("before_id", String(cursor));
|
if (at.beforeID) {
|
||||||
|
params.set("before_id", String(at.beforeID));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await api.bots(params);
|
const result = await api.bots(params);
|
||||||
setData(result);
|
setData(result);
|
||||||
setCursor(result.next_before_id);
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFresh() {
|
||||||
|
setHistory([]);
|
||||||
|
setCursor(zeroCursor);
|
||||||
|
await fetchPage(q, zeroCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNext() {
|
||||||
|
if (!data?.has_more) return;
|
||||||
|
const at = { beforeID: data.next_before_id };
|
||||||
|
const result = await fetchPage(q, 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(q, at);
|
||||||
|
if (result) {
|
||||||
|
setHistory((prev) => prev.slice(0, -1));
|
||||||
|
setCursor(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load(false);
|
void loadFresh();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const rows = data?.rows ?? [];
|
const rows = data?.rows ?? [];
|
||||||
const verified = rows.filter((row) => row.Verified).length;
|
const verified = rows.filter((row) => row.Verified).length;
|
||||||
const systemCount = rows.filter((row) => row.System).length;
|
const systemCount = rows.filter((row) => row.System).length;
|
||||||
|
const canGoPrev = history.length > 0 && !busy;
|
||||||
|
const canGoNext = Boolean(data?.has_more) && !busy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame
|
<PageFrame
|
||||||
title={"Bots"}
|
title={"Bots"}
|
||||||
eyebrow={data?.listing === false ? "Search results" : "Recently created bots"}
|
eyebrow={data?.listing === false ? "Search results" : "Recently created bots"}
|
||||||
actions={
|
actions={
|
||||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
<button className="btn" type="button" onClick={() => void loadFresh()} disabled={busy}>
|
||||||
<RefreshCw size={15} /> {"Refresh"}
|
<RefreshCw size={15} /> {"Refresh"}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
@ -104,29 +145,35 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
||||||
name: botName.trim(),
|
name: botName.trim(),
|
||||||
username: botUsername.trim().replace(/^@/, "")
|
username: botUsername.trim().replace(/^@/, "")
|
||||||
})}
|
})}
|
||||||
onDone={() => load(false)}
|
onDone={() => void loadFresh()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<QueryPanel>
|
<QueryPanel>
|
||||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
|
||||||
<label className="searchbox">
|
<label className="searchbox">
|
||||||
<Search size={15} />
|
<Search size={15} />
|
||||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Bot ID / username"} />
|
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Bot ID / username"} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field-inline">
|
<label className="gift-page-size">
|
||||||
<span>{"Limit"}</span>
|
<span>{"Limit"}</span>
|
||||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as BotPageSize)}>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||||
</button>
|
</button>
|
||||||
{data?.listing && data.has_more && (
|
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
|
||||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
<ChevronLeft size={15} /> {"Previous page"}
|
||||||
|
</button>
|
||||||
|
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
|
||||||
<ChevronRight size={15} /> {"Next page"}
|
<ChevronRight size={15} /> {"Next page"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</QueryPanel>
|
</QueryPanel>
|
||||||
|
|
||||||
|
|
@ -134,6 +181,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="avatar-col"></th>
|
||||||
<th>{"Bot ID"}</th>
|
<th>{"Bot ID"}</th>
|
||||||
<th>{"Username"}</th>
|
<th>{"Username"}</th>
|
||||||
<th>{"Name"}</th>
|
<th>{"Name"}</th>
|
||||||
|
|
@ -147,6 +195,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.ID}>
|
<tr key={row.ID}>
|
||||||
|
<td className="avatar-col"><Avatar id={row.ID} firstName={row.FirstName} username={row.Username} /></td>
|
||||||
<td className="mono">{row.ID}</td>
|
<td className="mono">{row.ID}</td>
|
||||||
<td>{displayUsername(row.Username) || "-"}</td>
|
<td>{displayUsername(row.Username) || "-"}</td>
|
||||||
<td>{row.FirstName || "-"}</td>
|
<td>{row.FirstName || "-"}</td>
|
||||||
|
|
@ -157,7 +206,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
||||||
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {"Details"} <ChevronRight size={14} /></button></td>
|
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {"Details"} <ChevronRight size={14} /></button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
{rows.length === 0 && <EmptyRow colSpan={9} />}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,82 @@
|
||||||
import { ArrowLeft, BadgeCheck } from "lucide-react";
|
import { ArrowLeft, BadgeCheck, ImagePlus, ScrollText, Settings2, UserRound } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { ActionButton } from "../components/ActionButton";
|
import { ActionButton } from "../components/ActionButton";
|
||||||
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { AvatarModal } from "../components/AvatarModal";
|
||||||
|
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||||
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
||||||
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
|
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
|
||||||
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
|
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { ChannelDetail } from "../types";
|
import type { ChannelDetail } from "../types";
|
||||||
|
|
||||||
|
type Tab = "profile" | "actions";
|
||||||
|
|
||||||
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||||
const [detail, setDetail] = useState<ChannelDetail | null>(null);
|
const [detail, setDetail] = useState<ChannelDetail | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [tab, setTab] = useState<Tab>("profile");
|
||||||
|
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
|
||||||
|
const [avatarVersion, setAvatarVersion] = useState(0);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
setDetail(await api.channel(id));
|
setDetail(await api.channel(id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
|
setTab("profile");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Alert>{error}</Alert>;
|
return <Alert>{error}</Alert>;
|
||||||
}
|
}
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
return <LoadingSurface label={"Loading channel detail"} />;
|
return <LoadingSurface label={busy ? "Loading channel detail" : "Waiting for data"} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ch = detail.Channel;
|
const ch = detail.Channel;
|
||||||
|
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
|
||||||
|
{ key: "profile", label: "Profile & Status", icon: <UserRound size={15} /> },
|
||||||
|
{ key: "actions", label: "Actions & Management", icon: <Settings2 size={15} /> }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame
|
<PageFrame
|
||||||
title={`${channelKind(ch)} #${ch.ID}`}
|
title={`${channelKind(ch)} #${ch.ID}`}
|
||||||
eyebrow={"Channel Profile"}
|
eyebrow={"Channel Profile"}
|
||||||
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {"Back to list"}</button>}
|
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {"Back to list"}</button>}
|
||||||
>
|
>
|
||||||
<SplitLayout
|
|
||||||
main={
|
|
||||||
<div className="stacked-sections">
|
|
||||||
<section className="entity-head">
|
<section className="entity-head">
|
||||||
|
<div className="entity-head-main">
|
||||||
|
<div className="avatar-edit-slot">
|
||||||
|
<Avatar id={ch.ID} kind="channel" title={ch.Title} size={64} refreshKey={avatarVersion || undefined} />
|
||||||
|
<button
|
||||||
|
className="icon-btn avatar-edit-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label={"Change avatar"}
|
||||||
|
title={"Change avatar"}
|
||||||
|
onClick={() => setAvatarModalOpen(true)}
|
||||||
|
>
|
||||||
|
<ImagePlus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="entity-title">{ch.Title || "-"}</div>
|
<div className="entity-title">{ch.Title || "-"}</div>
|
||||||
<div className="entity-subtitle">{displayUsername(ch.Username) || "No username"} · {`Creator ${ch.CreatorUserID}`}</div>
|
<div className="entity-subtitle">{displayUsername(ch.Username) || "No username"} · {`Creator ${ch.CreatorUserID}`}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="entity-badges">
|
<div className="entity-badges">
|
||||||
<Badge>{channelKind(ch)}</Badge>
|
<Badge>{channelKind(ch)}</Badge>
|
||||||
{ch.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
|
{ch.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
|
||||||
|
|
@ -55,6 +84,23 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
|
||||||
{ch.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Valid"}</Badge>}
|
{ch.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Valid"}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="toolbar" role="group" aria-label={"Channel sections"}>
|
||||||
|
{tabs.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tab === item.key}
|
||||||
|
onClick={() => setTab(item.key)}
|
||||||
|
>
|
||||||
|
{item.icon} {item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "profile" && (
|
||||||
|
<div className="stacked-sections">
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
<Summary label={"Channel ID"} value={String(ch.ID)} mono />
|
<Summary label={"Channel ID"} value={String(ch.ID)} mono />
|
||||||
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
|
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
|
||||||
|
|
@ -66,19 +112,19 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
|
||||||
<Summary label={"Updated"} value={formatDate(ch.UpdatedAt) || "-"} />
|
<Summary label={"Updated"} value={formatDate(ch.UpdatedAt) || "-"} />
|
||||||
</div>
|
</div>
|
||||||
{ch.About && <p className="about-text">{ch.About}</p>}
|
{ch.About && <p className="about-text">{ch.About}</p>}
|
||||||
<section className="section-block">
|
|
||||||
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
|
|
||||||
<AuditTable rows={detail.AuditLogs} />
|
|
||||||
</section>
|
|
||||||
<section className="section-block">
|
<section className="section-block">
|
||||||
<SectionHead title={"Channel Raw Row"} text={"Database read-only snapshot"} />
|
<SectionHead title={"Channel Raw Row"} text={"Database read-only snapshot"} />
|
||||||
<JsonBlock value={detail.ChannelJSON} />
|
<JsonBlock value={detail.ChannelJSON} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
side={
|
|
||||||
<section className="action-dock">
|
{tab === "actions" && (
|
||||||
<div className="dock-title">{"Channel Actions"}</div>
|
<div className="stacked-sections">
|
||||||
|
<div className="action-groups">
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Verification & Moderation Flags"} />
|
||||||
|
<div className="action-stack">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={ch.Verified ? "Clear verified" : "Set verified"}
|
label={ch.Verified ? "Clear verified" : "Set verified"}
|
||||||
icon={<BadgeCheck size={15} />}
|
icon={<BadgeCheck size={15} />}
|
||||||
|
|
@ -88,15 +134,48 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
|
||||||
onDone={load}
|
onDone={load}
|
||||||
/>
|
/>
|
||||||
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
|
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
|
||||||
<div className="dock-title">{"Settings"}</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Settings"} />
|
||||||
<ChannelSettingsAction channel={ch} onDone={load} />
|
<ChannelSettingsAction channel={ch} onDone={load} />
|
||||||
<div className="dock-title">{"Attributes"}</div>
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Username"} />
|
||||||
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
|
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Profile Color"} />
|
||||||
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
|
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Emoji Status"} />
|
||||||
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
|
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
|
||||||
</section>
|
</section>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} action={<ScrollText size={16} />} />
|
||||||
|
<AuditTable rows={detail.AuditLogs} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avatarModalOpen && (
|
||||||
|
<AvatarModal
|
||||||
|
kind="channel"
|
||||||
|
id={ch.ID}
|
||||||
|
onClose={() => setAvatarModalOpen(false)}
|
||||||
|
onDone={() => {
|
||||||
|
setAvatarVersion((v) => v + 1);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</PageFrame>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
|
import { Avatar } from "../components/Avatar";
|
||||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||||
import { ScamFakeBadges } from "../components/flags";
|
import { ScamFakeBadges } from "../components/flags";
|
||||||
import { channelKind, displayUsername, formatDate } from "../lib/format";
|
import { channelKind, displayUsername, formatDate } from "../lib/format";
|
||||||
|
|
@ -8,50 +9,87 @@ import { channelMetrics } from "../lib/metrics";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { ChannelListResponse } from "../types";
|
import type { ChannelListResponse } from "../types";
|
||||||
|
|
||||||
|
type Cursor = { beforeID: number; beforeUpdatedUS: number };
|
||||||
|
type ChannelPageSize = 10 | 20 | 50 | 100;
|
||||||
|
|
||||||
|
const zeroCursor: Cursor = { beforeID: 0, beforeUpdatedUS: 0 };
|
||||||
|
|
||||||
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [limit, setLimit] = useState("50");
|
const [limit, setLimit] = useState<ChannelPageSize>(50);
|
||||||
const [data, setData] = useState<ChannelListResponse | null>(null);
|
const [data, setData] = useState<ChannelListResponse | null>(null);
|
||||||
const [cursor, setCursor] = useState({ beforeID: 0, beforeUpdatedUS: 0 });
|
// history holds the cursor used to reach every page before the current
|
||||||
|
// one, so "Previous" can pop back without re-deriving offsets -- keyset
|
||||||
|
// pagination has no notion of "page N" to jump back to otherwise.
|
||||||
|
const [history, setHistory] = useState<Cursor[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
async function load(next = false) {
|
async function fetchPage(query: string, at: Cursor) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
const params = new URLSearchParams({ limit });
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
if (q.trim()) {
|
if (query.trim()) {
|
||||||
params.set("q", q.trim());
|
params.set("q", query.trim());
|
||||||
} else if (next) {
|
}
|
||||||
params.set("before_id", String(cursor.beforeID));
|
if (at.beforeID || at.beforeUpdatedUS) {
|
||||||
params.set("before_updated_us", String(cursor.beforeUpdatedUS));
|
params.set("before_id", String(at.beforeID));
|
||||||
|
params.set("before_updated_us", String(at.beforeUpdatedUS));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await api.channels(params);
|
const result = await api.channels(params);
|
||||||
setData(result);
|
setData(result);
|
||||||
setCursor({
|
return result;
|
||||||
beforeID: result.next_before_id,
|
|
||||||
beforeUpdatedUS: result.next_before_updated_us
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFresh() {
|
||||||
|
setHistory([]);
|
||||||
|
setCursor(zeroCursor);
|
||||||
|
await fetchPage(q, zeroCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNext() {
|
||||||
|
if (!data?.has_more) return;
|
||||||
|
const at = { beforeID: data.next_before_id, beforeUpdatedUS: data.next_before_updated_us };
|
||||||
|
const result = await fetchPage(q, 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(q, at);
|
||||||
|
if (result) {
|
||||||
|
setHistory((prev) => prev.slice(0, -1));
|
||||||
|
setCursor(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load(false);
|
void loadFresh();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const metrics = channelMetrics(data?.rows ?? []);
|
const metrics = channelMetrics(data?.rows ?? []);
|
||||||
|
const canGoPrev = history.length > 0 && !busy;
|
||||||
|
const canGoNext = Boolean(data?.has_more) && !busy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame
|
<PageFrame
|
||||||
title={"Supergroups and Channels"}
|
title={"Supergroups and Channels"}
|
||||||
eyebrow={data?.listing === false ? "Search results" : "Recently updated"}
|
eyebrow={data?.listing === false ? "Search results" : "Recently updated"}
|
||||||
actions={
|
actions={
|
||||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
<button className="btn" type="button" onClick={() => void loadFresh()} disabled={busy}>
|
||||||
<RefreshCw size={15} /> {"Refresh"}
|
<RefreshCw size={15} /> {"Refresh"}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
@ -64,29 +102,36 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
||||||
<Metric label={"Verified"} value={String(metrics.verified)} tone="good" />
|
<Metric label={"Verified"} value={String(metrics.verified)} tone="good" />
|
||||||
</div>
|
</div>
|
||||||
<QueryPanel>
|
<QueryPanel>
|
||||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
|
||||||
<label className="searchbox">
|
<label className="searchbox">
|
||||||
<Search size={15} />
|
<Search size={15} />
|
||||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Channel ID / username / title"} />
|
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Channel ID / username / title"} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field-inline">
|
<label className="gift-page-size">
|
||||||
<span>{"Limit"}</span>
|
<span>{"Limit"}</span>
|
||||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as ChannelPageSize)}>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||||
</button>
|
</button>
|
||||||
{data?.listing && data.has_more && (
|
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
|
||||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
<ChevronLeft size={15} /> {"Previous page"}
|
||||||
|
</button>
|
||||||
|
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
|
||||||
<ChevronRight size={15} /> {"Next page"}
|
<ChevronRight size={15} /> {"Next page"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</QueryPanel>
|
</QueryPanel>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="avatar-col"></th>
|
||||||
<th>{"Channel ID"}</th>
|
<th>{"Channel ID"}</th>
|
||||||
<th>{"Kind"}</th>
|
<th>{"Kind"}</th>
|
||||||
<th>{"Username"}</th>
|
<th>{"Username"}</th>
|
||||||
|
|
@ -102,6 +147,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
||||||
<tbody>
|
<tbody>
|
||||||
{data?.rows.map((row) => (
|
{data?.rows.map((row) => (
|
||||||
<tr key={row.ID}>
|
<tr key={row.ID}>
|
||||||
|
<td className="avatar-col"><Avatar id={row.ID} kind="channel" title={row.Title} /></td>
|
||||||
<td className="mono">{row.ID}</td>
|
<td className="mono">{row.ID}</td>
|
||||||
<td>{channelKind(row)}</td>
|
<td>{channelKind(row)}</td>
|
||||||
<td>{displayUsername(row.Username)}</td>
|
<td>{displayUsername(row.Username)}</td>
|
||||||
|
|
@ -114,7 +160,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
||||||
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
|
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
|
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ const (
|
||||||
ActionSetPhone = "account.set_phone"
|
ActionSetPhone = "account.set_phone"
|
||||||
ActionSetLoginEmail = "account.set_login_email"
|
ActionSetLoginEmail = "account.set_login_email"
|
||||||
ActionSetAccountAvatar = "account.set_avatar"
|
ActionSetAccountAvatar = "account.set_avatar"
|
||||||
|
ActionSetChannelAvatar = "channel.set_avatar"
|
||||||
ActionSetChannelUsername = "channel.set_username"
|
ActionSetChannelUsername = "channel.set_username"
|
||||||
ActionSetChannelSettings = "channel.set_settings"
|
ActionSetChannelSettings = "channel.set_settings"
|
||||||
ActionSetChannelColor = "channel.set_color"
|
ActionSetChannelColor = "channel.set_color"
|
||||||
|
|
@ -255,6 +256,7 @@ type ChannelsService interface {
|
||||||
AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error)
|
AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error)
|
||||||
AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
|
AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
|
||||||
AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
|
AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
|
||||||
|
AdminSetPhoto(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelNotifier interface {
|
type ChannelNotifier interface {
|
||||||
|
|
@ -302,6 +304,10 @@ type AvatarResolver interface {
|
||||||
ValidateAvatarUpload(data []byte) bool
|
ValidateAvatarUpload(data []byte) bool
|
||||||
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
|
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
|
||||||
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
|
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
|
||||||
|
// GetPhoto looks up a photo by id directly -- used to read a channel's
|
||||||
|
// current avatar, which is denormalized on the channel row as a bare
|
||||||
|
// photo_id rather than tracked through CurrentProfilePhotoKind.
|
||||||
|
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
|
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
|
||||||
|
|
@ -948,6 +954,13 @@ type SetAccountAvatarRequest struct {
|
||||||
Data []byte `json:"-"`
|
Data []byte `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetChannelAvatarRequest struct {
|
||||||
|
CommandMeta
|
||||||
|
ChannelID int64 `json:"channel_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
Data []byte `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type SetChannelUsernameRequest struct {
|
type SetChannelUsernameRequest struct {
|
||||||
CommandMeta
|
CommandMeta
|
||||||
ChannelID int64 `json:"channel_id"`
|
ChannelID int64 `json:"channel_id"`
|
||||||
|
|
@ -1798,6 +1811,97 @@ func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequ
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetChannelAvatar force-sets a channel's avatar from raw uploaded image
|
||||||
|
// bytes, reusing the same avatar rendition pipeline (s/a/c sizes) as
|
||||||
|
// SetAccountAvatar, but attaching the resulting photo directly to the
|
||||||
|
// channel row (photo_id) through the permission-check-free admin path
|
||||||
|
// instead of profile_photos history.
|
||||||
|
func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequest) (CommandResult, error) {
|
||||||
|
if req.ChannelID <= 0 {
|
||||||
|
return CommandResult{}, fmt.Errorf("channel_id is required")
|
||||||
|
}
|
||||||
|
if s == nil || s.photos == nil {
|
||||||
|
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
|
||||||
|
}
|
||||||
|
if s.channels == nil {
|
||||||
|
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
|
||||||
|
}
|
||||||
|
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
||||||
|
return CommandResult{}, domain.ErrPhotoInvalid
|
||||||
|
}
|
||||||
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
||||||
|
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelAvatar, 0, target, req, func() (CommandResult, error) {
|
||||||
|
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
|
||||||
|
if req.DryRun {
|
||||||
|
return CommandResult{Message: "avatar validated", Details: details}, nil
|
||||||
|
}
|
||||||
|
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, 0)
|
||||||
|
if err != nil {
|
||||||
|
return CommandResult{Details: details}, err
|
||||||
|
}
|
||||||
|
updated, err := s.channels.AdminSetPhoto(ctx, req.ChannelID, photo)
|
||||||
|
if err != nil {
|
||||||
|
return CommandResult{Details: details}, err
|
||||||
|
}
|
||||||
|
details["photo_id"] = photo.ID
|
||||||
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
||||||
|
details["notify_error"] = err.Error()
|
||||||
|
}
|
||||||
|
return CommandResult{Message: "avatar updated", Details: details}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelAvatar returns a channel's current avatar bytes and detected MIME
|
||||||
|
// type. Unlike a user's profile photo (tracked via profile_photos history),
|
||||||
|
// a channel's current photo is denormalized directly on the channel row as
|
||||||
|
// photo_id, so this resolves that id through GetPhoto instead of
|
||||||
|
// CurrentProfilePhotoKind.
|
||||||
|
func (s *Service) ChannelAvatar(ctx context.Context, channelID int64) ([]byte, string, bool, error) {
|
||||||
|
if s == nil || s.photos == nil || s.channels == nil || channelID <= 0 {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
channel, err := s.channels.GetChannelByID(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
if channel.PhotoID == 0 {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
photo, found, err := s.photos.GetPhoto(ctx, channel.PhotoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
|
||||||
|
if !ok {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
data := inline
|
||||||
|
if len(data) == 0 {
|
||||||
|
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
|
||||||
|
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
|
||||||
|
Limit: MaxAccountAvatarBytes + 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
data = chunk.Bytes
|
||||||
|
}
|
||||||
|
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
detected := http.DetectContentType(data)
|
||||||
|
if !safeAccountImageType(detected) {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
return data, detected, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetUserColor force-sets or clears a user's name/profile color.
|
// SetUserColor force-sets or clears a user's name/profile color.
|
||||||
func (s *Service) SetUserColor(ctx context.Context, req SetUserColorRequest) (CommandResult, error) {
|
func (s *Service) SetUserColor(ctx context.Context, req SetUserColorRequest) (CommandResult, error) {
|
||||||
if req.UserID <= 0 {
|
if req.UserID <= 0 {
|
||||||
|
|
|
||||||
|
|
@ -1034,6 +1034,16 @@ func (f *fakeChannelsService) AdminSetEmojiStatus(_ context.Context, channelID i
|
||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeChannelsService) AdminSetPhoto(_ context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
|
||||||
|
ch, ok := f.channels[channelID]
|
||||||
|
if !ok {
|
||||||
|
return domain.Channel{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
ch.PhotoID = photo.ID
|
||||||
|
f.channels[channelID] = ch
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
type fakeChannelNotifier struct {
|
type fakeChannelNotifier struct {
|
||||||
channels []int64
|
channels []int64
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ type Service interface {
|
||||||
SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error)
|
SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error)
|
||||||
SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error)
|
SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error)
|
||||||
SetAccountAvatar(ctx context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error)
|
SetAccountAvatar(ctx context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error)
|
||||||
|
ChannelAvatar(ctx context.Context, channelID int64) ([]byte, string, bool, error)
|
||||||
|
SetChannelAvatar(ctx context.Context, req admin.SetChannelAvatarRequest) (admin.CommandResult, error)
|
||||||
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
|
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
|
||||||
SetUserEmojiStatus(ctx context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error)
|
SetUserEmojiStatus(ctx context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error)
|
||||||
SetChannelSettings(ctx context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error)
|
SetChannelSettings(ctx context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error)
|
||||||
|
|
@ -203,6 +205,8 @@ func (s *Server) routes() http.Handler {
|
||||||
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
|
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
|
||||||
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
|
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
|
||||||
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
|
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
|
||||||
|
mux.HandleFunc("GET /v1/channels/{id}/avatar", s.authenticated(s.handleChannelAvatar))
|
||||||
|
mux.HandleFunc("POST /v1/channels/set-avatar", s.authenticated(s.handleSetChannelAvatar))
|
||||||
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
|
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
|
||||||
mux.HandleFunc("POST /v1/channels/set-flags", s.authenticated(s.handleSetChannelFlags))
|
mux.HandleFunc("POST /v1/channels/set-flags", s.authenticated(s.handleSetChannelFlags))
|
||||||
mux.HandleFunc("POST /v1/channels/set-settings", s.authenticated(s.handleSetChannelSettings))
|
mux.HandleFunc("POST /v1/channels/set-settings", s.authenticated(s.handleSetChannelSettings))
|
||||||
|
|
@ -453,6 +457,62 @@ func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request)
|
||||||
writeCommandResult(w, result, err)
|
writeCommandResult(w, result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || channelID <= 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, mimeType, found, err := s.svc.ChannelAvatar(r.Context(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", mimeType)
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetChannelAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
var req admin.SetChannelAvatarRequest
|
||||||
|
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "avatar file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||||
|
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
|
||||||
|
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.FileName = header.Filename
|
||||||
|
req.Data = data
|
||||||
|
result, err := s.svc.SetChannelAvatar(r.Context(), req)
|
||||||
|
writeCommandResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
|
||||||
var req admin.SetUserColorRequest
|
var req admin.SetUserColorRequest
|
||||||
if !decodeJSON(w, r, &req) {
|
if !decodeJSON(w, r, &req) {
|
||||||
|
|
|
||||||
|
|
@ -499,6 +499,14 @@ func (fakeService) SetAccountAvatar(_ context.Context, req admin.SetAccountAvata
|
||||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeService) ChannelAvatar(_ context.Context, _ int64) ([]byte, string, bool, error) {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) SetChannelAvatar(_ context.Context, req admin.SetChannelAvatarRequest) (admin.CommandResult, error) {
|
||||||
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (fakeService) SetUserColor(_ context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error) {
|
func (fakeService) SetUserColor(_ context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error) {
|
||||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -558,6 +558,15 @@ func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, stat
|
||||||
return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
|
return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminSetPhoto force-sets a channel's avatar through the admin path (no
|
||||||
|
// permission checks, no "changed photo" service message).
|
||||||
|
func (s *Service) AdminSetPhoto(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
|
||||||
|
if s == nil || s.channels == nil || channelID == 0 {
|
||||||
|
return domain.Channel{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
return s.channels.SetChannelPhotoAdmin(ctx, channelID, photo)
|
||||||
|
}
|
||||||
|
|
||||||
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
|
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
|
||||||
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
||||||
if s == nil || s.channels == nil || userID == 0 {
|
if s == nil || s.channels == nil || userID == 0 {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ type ChannelStore interface {
|
||||||
SetChannelUsernameAdmin(ctx context.Context, channelID int64, username string) (domain.Channel, error)
|
SetChannelUsernameAdmin(ctx context.Context, channelID int64, username string) (domain.Channel, error)
|
||||||
SetChannelColorAdmin(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
|
SetChannelColorAdmin(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
|
||||||
SetChannelEmojiStatusAdmin(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
|
SetChannelEmojiStatusAdmin(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
|
||||||
|
// SetChannelPhotoAdmin force-sets a channel's avatar with no permission
|
||||||
|
// checks and no service message (unlike SetChannelPhoto, which requires an
|
||||||
|
// acting admin member and posts a "changed photo" service message).
|
||||||
|
SetChannelPhotoAdmin(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error)
|
||||||
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
|
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
|
||||||
ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
|
ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
|
||||||
ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
|
ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,27 @@ func (s *ChannelStore) SetChannelEmojiStatusAdmin(_ context.Context, channelID i
|
||||||
return cloneChannel(channel), nil
|
return cloneChannel(channel), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) SetChannelPhotoAdmin(_ context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
|
||||||
|
if channelID == 0 {
|
||||||
|
return domain.Channel{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
channel, ok := s.channels[channelID]
|
||||||
|
if !ok || channel.Deleted {
|
||||||
|
return domain.Channel{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
stripped := domain.StrippedFromSizes(photo.Sizes)
|
||||||
|
if stripped == nil {
|
||||||
|
stripped = []byte{}
|
||||||
|
}
|
||||||
|
channel.PhotoID = photo.ID
|
||||||
|
channel.PhotoDCID = photo.DCID
|
||||||
|
channel.PhotoStripped = stripped
|
||||||
|
s.channels[channelID] = channel
|
||||||
|
return cloneChannel(channel), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
|
func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
|
||||||
_ = viewerUserID // zero is the anonymous public-web view; no membership state is projected.
|
_ = viewerUserID // zero is the anonymous public-web view; no membership state is projected.
|
||||||
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||||
|
|
|
||||||
|
|
@ -497,6 +497,35 @@ func (s *ChannelStore) SetChannelEmojiStatusAdmin(ctx context.Context, channelID
|
||||||
return channel, nil
|
return channel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetChannelPhotoAdmin force-sets a channel's avatar with no permission
|
||||||
|
// checks and no "changed photo" service message — unlike SetChannelPhoto,
|
||||||
|
// which requires an acting admin member and broadcasts to the channel's
|
||||||
|
// timeline. Mirrors SetChannelColorAdmin/SetChannelEmojiStatusAdmin's shape.
|
||||||
|
func (s *ChannelStore) SetChannelPhotoAdmin(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
|
||||||
|
if channelID == 0 {
|
||||||
|
return domain.Channel{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
channel, err := s.channelByID(ctx, s.db, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Channel{}, err
|
||||||
|
}
|
||||||
|
stripped := domain.StrippedFromSizes(photo.Sizes)
|
||||||
|
if stripped == nil {
|
||||||
|
stripped = []byte{}
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`,
|
||||||
|
channelID, photo.ID, photo.DCID, stripped); err != nil {
|
||||||
|
return domain.Channel{}, fmt.Errorf("set channel photo admin: %w", err)
|
||||||
|
}
|
||||||
|
if s.rowCache != nil {
|
||||||
|
s.rowCache.delete(channelID)
|
||||||
|
}
|
||||||
|
channel.PhotoID = photo.ID
|
||||||
|
channel.PhotoDCID = photo.DCID
|
||||||
|
channel.PhotoStripped = stripped
|
||||||
|
return channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
|
func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
|
||||||
_ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent.
|
_ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent.
|
||||||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue