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 type="module" crossorigin src="/assets/index-DwoQ5cLE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C8s1nuLP.css">
|
||||
<script type="module" crossorigin src="/assets/index-C1sjJr8N.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CHW7aAwX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
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 { 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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue