messages screen improvement
This commit is contained in:
parent
280321b902
commit
ae2cc3ba90
12 changed files with 438 additions and 111 deletions
|
|
@ -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