feat: add saved messages, public chat discovery/join, and chat delete options
All checks were successful
CI / test (push) Successful in 19s
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:
@@ -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 "✓";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user