messages screen improvement

This commit is contained in:
onysd 2026-09-08 01:19:33 +03:00
parent 280321b902
commit ae2cc3ba90
12 changed files with 438 additions and 111 deletions

View file

@ -214,15 +214,6 @@ export function Shell({
// Third-party verification is additionally hidden by default (not fully
// finished) regardless of what the session was granted -- see permissions.tsx.
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
useEffect(() => {
if (messagesActive) {
setMessagesOpen(true);
}
}, [messagesActive]);
async function logout() {
await api.logout().catch(() => undefined);
onLogout();
@ -277,38 +268,17 @@ export function Shell({
{canReadContent && (
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
)}
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"
type="button"
aria-expanded={messagesOpen}
onClick={() => setMessagesOpen((open) => !open)}
{canReadMessages && (
<NavLink
icon={<MessageSquareText size={16} />}
href="/messages/private"
route={route}
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages")}
>
<MessageSquareText size={16} />
<span>{"Messages"}</span>
<ChevronDown className="nav-section-chevron" size={15} />
</button>
{messagesOpen && (
<div className="nav-children">
<NavLink
href="/messages/private"
route={route}
navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
>
{"Private"}
</NavLink>
<NavLink
href="/messages/groups"
route={route}
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")}
>
{"Groups"}
</NavLink>
</div>
)}
</div>
{"Messages"}
</NavLink>
)}
{canManageAdmins && (
<NavLink icon={<UserCog size={16} />} href="/admin-users" route={route} navigate={navigate}>{"Operators"}</NavLink>
)}

View file

@ -0,0 +1,163 @@
import {
Contact,
Dice5,
FileText,
Gift,
Image,
Link2,
ListChecks,
MapPin,
Radio,
Settings2,
Sparkles,
type LucideIcon
} from "lucide-react";
import type { ReactNode } from "react";
import { formatBytes } from "../lib/format";
// What a message actually was, rendered the way a person reads it: the text
// first, then what was attached to it. The database rows behind it are still
// available further down each detail page, but an operator opening a message
// is nearly always asking "what does it say", and answering that with a JSON
// dump made them decode the answer themselves.
// Mirrors domain.MessageMedia's JSON. Everything is optional because the
// snapshot is written by a server that keeps gaining media kinds -- an unknown
// one has to degrade to "there is media of kind X" rather than blow up.
type MediaSnapshot = {
kind?: string;
document?: { file_name?: string; mime_type?: string; size?: number; duration?: number };
photo?: { id?: number | string };
contact?: { first_name?: string; last_name?: string; phone_number?: string };
geo?: { lat?: number; long?: number };
geo_live?: { lat?: number; long?: number; period?: number };
venue?: { title?: string; address?: string };
poll?: { question?: string; answers?: unknown[]; closed?: boolean };
web_page?: { url?: string; title?: string; site_name?: string };
story?: { id?: number };
todo?: { title?: string };
dice?: { emoticon?: string; value?: number };
giveaway?: unknown;
service_action?: { kind?: string; type?: string };
spoiler?: boolean;
ttl_seconds?: number;
voice?: boolean;
round?: boolean;
video?: boolean;
};
function parseMedia(raw: string | undefined): MediaSnapshot | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as MediaSnapshot;
if (!parsed || typeof parsed !== "object") return null;
// "{}" is what a text-only message stores, not a media object.
if (Object.keys(parsed).length === 0) return null;
return parsed;
} catch {
return null;
}
}
// describeMedia turns the snapshot into a line a person can read plus the icon
// that goes with it. Unknown kinds still get a row, named after the kind.
function describeMedia(media: MediaSnapshot): { icon: LucideIcon; title: string; detail: string } {
const kind = media.kind ?? "";
switch (kind) {
case "photo":
return { icon: Image, title: "Photo", detail: media.photo?.id ? `id ${media.photo.id}` : "" };
case "document": {
const doc = media.document ?? {};
const bits = [doc.mime_type, doc.size ? formatBytes(String(doc.size)) : "", doc.duration ? `${doc.duration}s` : ""].filter(Boolean);
const title = media.voice ? "Voice message" : media.round ? "Round video" : media.video ? "Video" : "File";
return { icon: FileText, title, detail: [doc.file_name, bits.join(" · ")].filter(Boolean).join(" — ") };
}
case "contact": {
const c = media.contact ?? {};
const name = [c.first_name, c.last_name].filter(Boolean).join(" ");
return { icon: Contact, title: "Contact", detail: [name, c.phone_number].filter(Boolean).join(" · ") };
}
case "geo":
return { icon: MapPin, title: "Location", detail: media.geo ? `${media.geo.lat}, ${media.geo.long}` : "" };
case "geo_live":
return { icon: MapPin, title: "Live location", detail: media.geo_live ? `${media.geo_live.lat}, ${media.geo_live.long}` : "" };
case "venue":
return { icon: MapPin, title: "Venue", detail: [media.venue?.title, media.venue?.address].filter(Boolean).join(" — ") };
case "poll":
return {
icon: ListChecks,
title: media.poll?.closed ? "Poll (closed)" : "Poll",
detail: [media.poll?.question, media.poll?.answers ? `${media.poll.answers.length} options` : ""].filter(Boolean).join(" — ")
};
case "web_page":
return { icon: Link2, title: "Link preview", detail: [media.web_page?.title, media.web_page?.url].filter(Boolean).join(" — ") };
case "story":
return { icon: Sparkles, title: "Story", detail: media.story?.id ? `id ${media.story.id}` : "" };
case "todo":
return { icon: ListChecks, title: "Checklist", detail: media.todo?.title ?? "" };
case "dice":
return { icon: Dice5, title: "Dice", detail: [media.dice?.emoticon, media.dice?.value].filter(Boolean).join(" ") };
case "giveaway":
return { icon: Gift, title: "Giveaway", detail: "" };
case "service":
return { icon: Settings2, title: "Service action", detail: media.service_action?.kind ?? media.service_action?.type ?? "" };
default:
return { icon: Radio, title: kind ? `Media (${kind})` : "Media", detail: "" };
}
}
export function MessageView({
body,
media,
sender,
meta,
badges
}: {
body: string;
media?: string;
// Who sent it, already resolved to something readable by the caller.
sender: string;
// When, and anything else that belongs on the header line.
meta: string;
badges?: ReactNode;
}) {
const parsed = parseMedia(media);
const described = parsed ? describeMedia(parsed) : null;
const Icon = described?.icon;
const text = body?.trim() ?? "";
return (
<section className="message-view">
<div className="message-view-head">
<div>
<strong>{sender}</strong>
<small>{meta}</small>
</div>
{badges && <div className="entity-badges">{badges}</div>}
</div>
<div className="message-bubble">
{text
? <p className="message-text">{text}</p>
: <p className="message-text empty">{described ? "No caption" : "No text"}</p>}
{described && Icon && (
<div className="message-attachment">
<span className="message-attachment-icon"><Icon size={16} /></span>
<span className="message-attachment-copy">
<strong>{described.title}</strong>
{described.detail && <small>{described.detail}</small>}
</span>
</div>
)}
{parsed && (parsed.spoiler || parsed.ttl_seconds) && (
<div className="message-flags">
{parsed.spoiler && <span className="chip">{"Spoiler"}</span>}
{parsed.ttl_seconds ? <span className="chip">{`Self-destructs after ${parsed.ttl_seconds}s`}</span> : null}
</div>
)}
</div>
</section>
);
}

View file

@ -1,6 +1,7 @@
import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { MessageView } from "../components/MessageView";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
@ -38,32 +39,38 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {"Back to group messages"}</button>}
>
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{`Channel / Group ${msg.ChannelID}`}</div>
<div className="entity-subtitle">{`Sender ${msg.SenderUserID} · ${formatUnix(msg.Date)}`}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
{msg.Post && <Badge>{"Channel post"}</Badge>}
<Badge>pts {msg.PTS}</Badge>
</div>
</section>
<MessageView
body={msg.Body}
media={msg.Media}
sender={msg.Post ? `Channel post in ${msg.ChannelID}` : `From ${msg.SenderUserID}`}
meta={`${formatUnix(msg.Date)}${msg.EditDate ? ` · edited ${formatUnix(msg.EditDate)}` : ""}${msg.ViewsCount ? ` · ${msg.ViewsCount} views` : ""}`}
badges={
<>
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
{msg.Post && <Badge>{"Channel post"}</Badge>}
</>
}
/>
<div className="summary-grid">
<Summary label={"Message ID"} value={String(msg.ID)} mono />
<Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label={"Views"} value={String(msg.ViewsCount)} />
<Summary label={"pts"} value={String(msg.PTS)} mono />
</div>
<section className="section-block">
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} />
</section>
<section className="section-block">
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} />
</section>
<details className="raw-details">
<summary>{"Stored rows (JSON)"}</summary>
<div className="stacked-sections">
<section className="section-block">
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} />
</section>
<section className="section-block">
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
</details>
<section className="section-block">
<SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} />
<div className="table-wrap">

View file

@ -2,12 +2,12 @@ import { ChevronRight, Search } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { Alert, Badge, EmptyRow, Metric, QueryPanel } from "../components/ui";
import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
export function GroupMessagesTab({ navigate }: { navigate: Navigate }) {
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
@ -52,7 +52,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const rows = data?.rows ?? [];
return (
<PageFrame title={"Group Messages"} eyebrow={"Supergroup / channel messages"}>
<>
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid single">
@ -114,6 +114,6 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
</tbody>
</table>
</div>
</PageFrame>
</>
);
}

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { MessageView } from "../components/MessageView";
import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { MessageDetail } from "../types";
@ -41,37 +42,46 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{`Owner ${msg.OwnerUserID} · Peer ${msg.PeerID}`}</div>
<div className="entity-subtitle">{`Sender ${msg.FromUserID} · ${formatUnix(msg.Date)}`}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
<Badge>pts {msg.PTS}</Badge>
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
</div>
</section>
<MessageView
body={msg.Body}
media={msg.Media}
sender={`From ${msg.FromUserID}`}
meta={`${msg.Outgoing ? "Sent to" : "Received from"} ${msg.PeerID} · ${formatUnix(msg.Date)}`}
badges={
<>
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
</>
}
/>
<div className="summary-grid">
<Summary label={"Message box ID"} value={String(msg.BoxID)} mono />
<Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono />
<Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono />
<Summary label={"Time"} value={formatUnix(msg.Date)} />
</div>
<section className="section-block">
<SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
<JsonBlock value={detail.PrivateJSON} />
</section>
<Summary label={"pts"} value={String(msg.PTS)} mono />
</div>
{/* The stored rows stay reachable, but folded: they answer "why is
this message in this state", which is a rarer question than
"what does it say". */}
<details className="raw-details">
<summary>{"Stored rows (JSON)"}</summary>
<div className="stacked-sections">
<section className="section-block">
<SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div>
</div>
</details>
<section className="section-block">
<SectionHead title={"Update Events"} text={"durable user_update_events"} />
<div className="table-wrap">

View file

@ -7,8 +7,9 @@ import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../compon
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types";
import { GroupMessagesTab } from "./GroupMessagesPage";
export function MessagesPage({ navigate }: { navigate: Navigate }) {
export function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
@ -65,7 +66,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
}
return (
<PageFrame title={"Private Messages"} eyebrow={"Private message boxes"}>
<>
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid">
@ -152,6 +153,42 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
</tbody>
</table>
</div>
</>
);
}
// The two message stores are one screen with two tabs rather than two sidebar
// entries: they are the same job ("look at what was said") over different peer
// kinds, and a nested menu made that look like two unrelated sections.
export function MessagesPage({ navigate, tab, onTab }: {
navigate: Navigate;
tab: "private" | "groups";
onTab: (tab: "private" | "groups") => void;
}) {
return (
<PageFrame title={"Messages"} eyebrow={"Message boxes and channel history"}>
<div className="tab-bar" role="tablist" aria-label={"Message sections"}>
<button
className={`tab-btn ${tab === "private" ? "active" : ""}`}
type="button"
role="tab"
aria-selected={tab === "private"}
onClick={() => onTab("private")}
>
{"Private"}
</button>
<button
className={`tab-btn ${tab === "groups" ? "active" : ""}`}
type="button"
role="tab"
aria-selected={tab === "groups"}
onClick={() => onTab("groups")}
>
{"Groups and channels"}
</button>
</div>
{tab === "private" ? <PrivateMessagesTab navigate={navigate} /> : <GroupMessagesTab navigate={navigate} />}
</PageFrame>
);
}

View file

@ -11,7 +11,6 @@ import { BotsPage } from "./BotsPage";
import { BroadcastsPage } from "./BroadcastsPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { StickerSetsPage } from "./StickerSetsPage";
@ -159,11 +158,16 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
/>
);
}
if (route.path === "/messages/groups") {
return <GroupMessagesPage navigate={navigate} />;
}
if (route.path === "/messages" || route.path === "/messages/private") {
return <MessagesPage navigate={navigate} />;
// Both tabs keep their own path so a link to one still opens on it -- the
// tab is a view of /messages, not a hidden bit of component state.
if (route.path === "/messages" || route.path === "/messages/private" || route.path === "/messages/groups") {
return (
<MessagesPage
navigate={navigate}
tab={route.path === "/messages/groups" ? "groups" : "private"}
onTab={(tab) => navigate(tab === "groups" ? "/messages/groups" : "/messages/private")}
/>
);
}
return <Dashboard navigate={navigate} />;
}

View file

@ -1250,3 +1250,139 @@
align-items: center;
gap: 8px;
}
/* The message itself, rendered the way it was read rather than the way it is
stored. Leads every message detail page; the database rows sit below it in a
folded block. */
.message-view {
display: grid;
gap: 10px;
}
.message-view-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.message-view-head strong {
display: block;
color: var(--heading);
font-size: 14px;
}
.message-view-head small {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 12px;
}
/* Given a bubble's shape on purpose: it is the one element on the page that is
the message rather than a fact about it. */
.message-bubble {
display: grid;
gap: 10px;
max-width: 720px;
padding: 14px 16px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
border-top-left-radius: var(--radius-xs);
}
.message-text {
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
font-size: 14px;
line-height: 1.5;
/* Message text keeps its own line breaks; collapsing them would silently
reformat what was actually sent. */
white-space: pre-wrap;
}
.message-text.empty {
color: var(--muted-2);
font-style: italic;
}
.message-attachment {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.message-attachment-icon {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 auto;
place-items: center;
color: var(--brand);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: var(--radius-sm);
}
.message-attachment-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.message-attachment-copy strong {
color: var(--text);
font-size: 13px;
}
.message-attachment-copy small {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-flags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* Folded raw rows. Closed by default so the page opens on the message, not on
three JSON dumps -- still one click away for the times the stored state is
the actual question. */
.raw-details > summary {
padding: 8px 10px;
color: var(--text-soft);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
font-weight: 700;
list-style: none;
}
.raw-details > summary::-webkit-details-marker {
display: none;
}
.raw-details > summary::before {
content: "▸ ";
color: var(--muted);
}
.raw-details[open] > summary::before {
content: "▾ ";
}
.raw-details[open] > summary {
margin-bottom: 12px;
}