messages screen improvement
This commit is contained in:
parent
280321b902
commit
ae2cc3ba90
12 changed files with 438 additions and 111 deletions
9
cmd/telesrv-admin/web/dist/assets/index-C1sjJr8N.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-C1sjJr8N.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-DwoQ5cLE.js"></script>
|
<script type="module" crossorigin src="/assets/index-C1sjJr8N.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C8s1nuLP.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CHW7aAwX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -214,15 +214,6 @@ export function Shell({
|
||||||
// Third-party verification is additionally hidden by default (not fully
|
// Third-party verification is additionally hidden by default (not fully
|
||||||
// finished) regardless of what the session was granted -- see permissions.tsx.
|
// finished) regardless of what the session was granted -- see permissions.tsx.
|
||||||
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
|
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
|
||||||
const messagesActive = route.path.startsWith("/messages");
|
|
||||||
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (messagesActive) {
|
|
||||||
setMessagesOpen(true);
|
|
||||||
}
|
|
||||||
}, [messagesActive]);
|
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await api.logout().catch(() => undefined);
|
await api.logout().catch(() => undefined);
|
||||||
onLogout();
|
onLogout();
|
||||||
|
|
@ -277,38 +268,17 @@ export function Shell({
|
||||||
{canReadContent && (
|
{canReadContent && (
|
||||||
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
|
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
|
||||||
)}
|
)}
|
||||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
{canReadMessages && (
|
||||||
<button
|
<NavLink
|
||||||
className="nav-section-toggle"
|
icon={<MessageSquareText size={16} />}
|
||||||
type="button"
|
href="/messages/private"
|
||||||
aria-expanded={messagesOpen}
|
route={route}
|
||||||
onClick={() => setMessagesOpen((open) => !open)}
|
navigate={navigate}
|
||||||
|
activeWhen={(path) => path.startsWith("/messages")}
|
||||||
>
|
>
|
||||||
<MessageSquareText size={16} />
|
{"Messages"}
|
||||||
<span>{"Messages"}</span>
|
</NavLink>
|
||||||
<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>
|
|
||||||
{canManageAdmins && (
|
{canManageAdmins && (
|
||||||
<NavLink icon={<UserCog size={16} />} href="/admin-users" route={route} navigate={navigate}>{"Operators"}</NavLink>
|
<NavLink icon={<UserCog size={16} />} href="/admin-users" route={route} navigate={navigate}>{"Operators"}</NavLink>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
163
cmd/telesrv-admin/web/src/components/MessageView.tsx
Normal file
163
cmd/telesrv-admin/web/src/components/MessageView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
|
import { MessageView } from "../components/MessageView";
|
||||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||||
import { formatUnix } from "../lib/format";
|
import { formatUnix } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
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>}
|
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {"Back to group messages"}</button>}
|
||||||
>
|
>
|
||||||
<div className="stacked-sections">
|
<div className="stacked-sections">
|
||||||
<section className="entity-head">
|
<MessageView
|
||||||
<div>
|
body={msg.Body}
|
||||||
<div className="entity-title">{`Channel / Group ${msg.ChannelID}`}</div>
|
media={msg.Media}
|
||||||
<div className="entity-subtitle">{`Sender ${msg.SenderUserID} · ${formatUnix(msg.Date)}`}</div>
|
sender={msg.Post ? `Channel post in ${msg.ChannelID}` : `From ${msg.SenderUserID}`}
|
||||||
</div>
|
meta={`${formatUnix(msg.Date)}${msg.EditDate ? ` · edited ${formatUnix(msg.EditDate)}` : ""}${msg.ViewsCount ? ` · ${msg.ViewsCount} views` : ""}`}
|
||||||
<div className="entity-badges">
|
badges={
|
||||||
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
<>
|
||||||
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
|
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||||
{msg.Post && <Badge>{"Channel post"}</Badge>}
|
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
|
||||||
<Badge>pts {msg.PTS}</Badge>
|
{msg.Post && <Badge>{"Channel post"}</Badge>}
|
||||||
</div>
|
</>
|
||||||
</section>
|
}
|
||||||
|
/>
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
<Summary label={"Message ID"} value={String(msg.ID)} mono />
|
<Summary label={"Message ID"} value={String(msg.ID)} mono />
|
||||||
<Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono />
|
<Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono />
|
||||||
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} 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>
|
</div>
|
||||||
<section className="section-block">
|
<details className="raw-details">
|
||||||
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
|
<summary>{"Stored rows (JSON)"}</summary>
|
||||||
<JsonBlock value={detail.MessageJSON} />
|
<div className="stacked-sections">
|
||||||
</section>
|
<section className="section-block">
|
||||||
<section className="section-block">
|
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
|
||||||
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
|
<JsonBlock value={detail.MessageJSON} />
|
||||||
<JsonBlock value={detail.ChannelJSON} />
|
</section>
|
||||||
</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">
|
<section className="section-block">
|
||||||
<SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} />
|
<SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} />
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { ChevronRight, Search } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { ChannelPicker } from "../components/EntityPicker";
|
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 { channelKind, formatUnix } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { ChannelRow, GroupMessageListResponse } from "../types";
|
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 [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||||
const [beforeDate, setBeforeDate] = useState("");
|
const [beforeDate, setBeforeDate] = useState("");
|
||||||
const [beforeID, setBeforeID] = useState("");
|
const [beforeID, setBeforeID] = useState("");
|
||||||
|
|
@ -52,7 +52,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
||||||
const rows = data?.rows ?? [];
|
const rows = data?.rows ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame title={"Group Messages"} eyebrow={"Supergroup / channel messages"}>
|
<>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
<QueryPanel>
|
<QueryPanel>
|
||||||
<div className="message-selector-grid single">
|
<div className="message-selector-grid single">
|
||||||
|
|
@ -114,6 +114,6 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</PageFrame>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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 { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
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 { formatDate, formatUnix } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { MessageDetail } from "../types";
|
import type { MessageDetail } from "../types";
|
||||||
|
|
@ -41,37 +42,46 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
main={
|
main={
|
||||||
<div className="stacked-sections">
|
<div className="stacked-sections">
|
||||||
<section className="entity-head">
|
<MessageView
|
||||||
<div>
|
body={msg.Body}
|
||||||
<div className="entity-title">{`Owner ${msg.OwnerUserID} · Peer ${msg.PeerID}`}</div>
|
media={msg.Media}
|
||||||
<div className="entity-subtitle">{`Sender ${msg.FromUserID} · ${formatUnix(msg.Date)}`}</div>
|
sender={`From ${msg.FromUserID}`}
|
||||||
</div>
|
meta={`${msg.Outgoing ? "Sent to" : "Received from"} ${msg.PeerID} · ${formatUnix(msg.Date)}`}
|
||||||
<div className="entity-badges">
|
badges={
|
||||||
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
<>
|
||||||
<Badge>pts {msg.PTS}</Badge>
|
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||||
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
|
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
|
||||||
</div>
|
</>
|
||||||
</section>
|
}
|
||||||
|
/>
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
<Summary label={"Message box ID"} value={String(msg.BoxID)} mono />
|
<Summary label={"Message box ID"} value={String(msg.BoxID)} mono />
|
||||||
<Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono />
|
<Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono />
|
||||||
<Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono />
|
<Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono />
|
||||||
<Summary label={"Time"} value={formatUnix(msg.Date)} />
|
<Summary label={"pts"} value={String(msg.PTS)} mono />
|
||||||
</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>
|
|
||||||
</div>
|
</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">
|
<section className="section-block">
|
||||||
<SectionHead title={"Update Events"} text={"durable user_update_events"} />
|
<SectionHead title={"Update Events"} text={"durable user_update_events"} />
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../compon
|
||||||
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
|
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { AccountRow, MessageListResponse } from "../types";
|
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 [owner, setOwner] = useState<AccountRow | null>(null);
|
||||||
const [peer, setPeer] = useState<AccountRow | null>(null);
|
const [peer, setPeer] = useState<AccountRow | null>(null);
|
||||||
const [beforeDate, setBeforeDate] = useState("");
|
const [beforeDate, setBeforeDate] = useState("");
|
||||||
|
|
@ -65,7 +66,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame title={"Private Messages"} eyebrow={"Private message boxes"}>
|
<>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
<QueryPanel>
|
<QueryPanel>
|
||||||
<div className="message-selector-grid">
|
<div className="message-selector-grid">
|
||||||
|
|
@ -152,6 +153,42 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { BotsPage } from "./BotsPage";
|
||||||
import { BroadcastsPage } from "./BroadcastsPage";
|
import { BroadcastsPage } from "./BroadcastsPage";
|
||||||
import { Dashboard } from "./Dashboard";
|
import { Dashboard } from "./Dashboard";
|
||||||
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
||||||
import { GroupMessagesPage } from "./GroupMessagesPage";
|
|
||||||
import { MessageDetailPage } from "./MessageDetailPage";
|
import { MessageDetailPage } from "./MessageDetailPage";
|
||||||
import { MessagesPage } from "./MessagesPage";
|
import { MessagesPage } from "./MessagesPage";
|
||||||
import { StickerSetsPage } from "./StickerSetsPage";
|
import { StickerSetsPage } from "./StickerSetsPage";
|
||||||
|
|
@ -159,11 +158,16 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (route.path === "/messages/groups") {
|
// Both tabs keep their own path so a link to one still opens on it -- the
|
||||||
return <GroupMessagesPage navigate={navigate} />;
|
// 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") {
|
||||||
if (route.path === "/messages" || route.path === "/messages/private") {
|
return (
|
||||||
return <MessagesPage navigate={navigate} />;
|
<MessagesPage
|
||||||
|
navigate={navigate}
|
||||||
|
tab={route.path === "/messages/groups" ? "groups" : "private"}
|
||||||
|
onTab={(tab) => navigate(tab === "groups" ? "/messages/groups" : "/messages/private")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <Dashboard navigate={navigate} />;
|
return <Dashboard navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1250,3 +1250,139 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue