feat: add saved messages, public chat discovery/join, and chat delete options
All checks were successful
CI / test (push) Successful in 19s

- add Saved Messages system chat with dedicated API

- add public group/channel metadata and discover/join endpoints

- add chat delete flow with for_all option and channel-wide delete

- switch message actions to context menu and improve reply/forward visuals

- improve microphone permission handling for voice recording
This commit is contained in:
2026-03-08 00:41:35 +03:00
parent b5a7d733c6
commit b9f71b9528
12 changed files with 529 additions and 119 deletions

View File

@@ -1,9 +1,15 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { forwardMessage, pinMessage } from "../api/chats";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { formatTime } from "../utils/format";
type ContextMenuState = {
x: number;
y: number;
messageId: number;
} | null;
export function MessageList() {
const me = useAuthStore((s) => s.me);
const activeChatId = useChatStore((s) => s.activeChatId);
@@ -12,6 +18,7 @@ export function MessageList() {
const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const messages = useMemo(() => {
if (!activeChatId) {
@@ -19,6 +26,8 @@ export function MessageList() {
}
return messagesByChat[activeChatId] ?? [];
}, [activeChatId, messagesByChat]);
const messagesMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const activeChat = chats.find((chat) => chat.id === activeChatId);
if (!activeChatId) {
@@ -28,88 +37,100 @@ export function MessageList() {
async function handleForward(messageId: number) {
const targetRaw = window.prompt("Forward to chat id:");
if (!targetRaw) {
return;
}
if (!targetRaw) return;
const targetId = Number(targetRaw);
if (!Number.isFinite(targetId) || targetId <= 0) {
return;
}
if (!Number.isFinite(targetId) || targetId <= 0) return;
await forwardMessage(messageId, targetId);
setCtx(null);
}
async function handlePin(messageId: number) {
const nextPinned = activeChat?.pinned_message_id === messageId ? null : messageId;
const chat = await pinMessage(chatId, nextPinned);
updateChatPinnedMessage(chatId, chat.pinned_message_id ?? null);
setCtx(null);
}
return (
<div className="flex h-full flex-col">
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
{activeChat?.pinned_message_id ? (
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
Pinned message ID: {activeChat.pinned_message_id}
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || `Pinned message #${activeChat.pinned_message_id}`}
</div>
) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
{messages.map((message) => {
const own = message.sender_id === me?.id;
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
return (
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`} key={`${message.id}-${message.client_message_id ?? ""}`}>
<div
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
own
? "rounded-br-md bg-sky-500/90 text-slate-950"
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
}`}
onContextMenu={(e) => {
e.preventDefault();
setCtx({ x: e.clientX, y: e.clientY, messageId: message.id });
}}
>
{message.forwarded_from_message_id ? (
<p className={`mb-1 text-[11px] font-semibold ${own ? "text-slate-900/75" : "text-sky-300"}`}>Forwarded</p>
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-sky-300"}`}>
Forwarded message
</div>
) : null}
{message.reply_to_message_id ? (
<p className={`mb-1 text-[11px] ${own ? "text-slate-900/75" : "text-slate-300"}`}>Reply to #{message.reply_to_message_id}</p>
{replySource ? (
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-slate-300"}`}>
<p className="truncate font-semibold">{replySource.sender_id === me?.id ? "You" : "Reply"}</p>
<p className="truncate">{replySource.text || "[media]"}</p>
</div>
) : null}
{renderContent(message.type, message.text)}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="flex gap-1">
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => setReplyToMessage(chatId, message)}>
Reply
</button>
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handleForward(message.id)}>
Fwd
</button>
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handlePin(message.id)}>
Pin
</button>
</div>
<p className={`flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p>
</div>
</p>
</div>
</div>
);
})}
</div>
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
{ctx ? (
<div
className="fixed z-50 w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: ctx.x, top: ctx.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, msg);
}
setCtx(null);
}}
>
Reply
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handleForward(ctx.messageId)}>
Forward
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
</div>
) : null}
</div>
);
}
function renderContent(messageType: string, text: string | null) {
if (!text) {
return <p className="opacity-80">[empty]</p>;
}
if (messageType === "image") {
return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
}
if (messageType === "video" || messageType === "circle_video") {
return <video className="max-h-72 rounded-lg" controls src={text} />;
}
if (messageType === "audio" || messageType === "voice") {
return <audio controls src={text} />;
}
if (!text) return <p className="opacity-80">[empty]</p>;
if (messageType === "image") return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
if (messageType === "video" || messageType === "circle_video") return <video className="max-h-72 rounded-lg" controls src={text} />;
if (messageType === "audio" || messageType === "voice") return <audio controls src={text} />;
if (messageType === "file") {
return (
<a className="underline" href={text} rel="noreferrer" target="_blank">
@@ -121,14 +142,8 @@ function renderContent(messageType: string, text: string | null) {
}
function renderStatus(status: string | undefined): string {
if (status === "sending") {
return "";
}
if (status === "delivered") {
return "✓✓";
}
if (status === "read") {
return "✓✓";
}
if (status === "sending") return "⌛";
if (status === "delivered") return "✓✓";
if (status === "read") return "✓✓";
return "✓";
}