fix(web): improve context menu positioning and forward UX
Some checks failed
CI / test (push) Failing after 18s
Some checks failed
CI / test (push) Failing after 18s
- render chat/message context menus via portal to document.body - clamp menu coordinates to viewport while keeping near-cursor placement - remove visible chat id fallbacks from chat/discover UI - hide 'delete for everyone' checkbox for channels; show channel-specific hint - replace forward-by-chat-id prompt with searchable chat picker modal
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { deleteChat } from "../api/chats";
|
import { deleteChat } from "../api/chats";
|
||||||
import { updateMyProfile } from "../api/users";
|
import { updateMyProfile } from "../api/users";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
@@ -26,7 +27,7 @@ export function ChatList() {
|
|||||||
const [profileError, setProfileError] = useState<string | null>(null);
|
const [profileError, setProfileError] = useState<string | null>(null);
|
||||||
const [profileSaving, setProfileSaving] = useState(false);
|
const [profileSaving, setProfileSaving] = useState(false);
|
||||||
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
|
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
|
||||||
const canDeleteForEveryone = Boolean(deleteModalChat && !deleteModalChat.is_saved && deleteModalChat.type !== "private");
|
const canDeleteForEveryone = Boolean(deleteModalChat && !deleteModalChat.is_saved && deleteModalChat.type === "group");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -115,7 +116,7 @@ export function ChatList() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="truncate text-sm font-semibold">{chat.display_title || chat.title || `${chat.type} #${chat.id}`}</p>
|
<p className="truncate text-sm font-semibold">{chatLabel(chat)}</p>
|
||||||
<span className="shrink-0 text-[11px] text-slate-400">
|
<span className="shrink-0 text-[11px] text-slate-400">
|
||||||
{messagesByChat[chat.id]?.length ? "now" : ""}
|
{messagesByChat[chat.id]?.length ? "now" : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -128,8 +129,9 @@ export function ChatList() {
|
|||||||
</div>
|
</div>
|
||||||
<NewChatPanel />
|
<NewChatPanel />
|
||||||
|
|
||||||
{ctxChatId && ctxPos ? (
|
{ctxChatId && ctxPos
|
||||||
<div className="fixed z-50 w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" style={{ left: ctxPos.x, top: ctxPos.y }} onClick={(e) => e.stopPropagation()}>
|
? createPortal(
|
||||||
|
<div className="fixed z-[100] w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" style={{ left: ctxPos.x, top: ctxPos.y }} onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -141,13 +143,18 @@ export function ChatList() {
|
|||||||
>
|
>
|
||||||
Delete chat
|
Delete chat
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>,
|
||||||
) : null}
|
document.body
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
{deleteModalChatId ? (
|
{deleteModalChatId ? (
|
||||||
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
||||||
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
|
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
|
||||||
<p className="mb-2 text-sm font-semibold">Delete chat #{deleteModalChatId}</p>
|
<p className="mb-2 text-sm font-semibold">Delete chat: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}</p>
|
||||||
|
{deleteModalChat?.type === "channel" ? (
|
||||||
|
<p className="mb-3 text-xs text-slate-400">Channels are removed for all subscribers.</p>
|
||||||
|
) : null}
|
||||||
{canDeleteForEveryone ? (
|
{canDeleteForEveryone ? (
|
||||||
<label className="mb-3 flex items-center gap-2 text-sm">
|
<label className="mb-3 flex items-center gap-2 text-sm">
|
||||||
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
|
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
|
||||||
@@ -232,3 +239,12 @@ export function ChatList() {
|
|||||||
const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad);
|
const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad);
|
||||||
return { x: safeX, y: safeY };
|
return { x: safeX, y: safeY };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chatLabel(chat: { display_title?: string | null; title: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }): string {
|
||||||
|
if (chat.display_title?.trim()) return chat.display_title;
|
||||||
|
if (chat.title?.trim()) return chat.title;
|
||||||
|
if (chat.is_saved) return "Saved Messages";
|
||||||
|
if (chat.type === "private") return "Direct chat";
|
||||||
|
if (chat.type === "group") return "Group";
|
||||||
|
return "Channel";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { forwardMessage, pinMessage } from "../api/chats";
|
import { forwardMessage, pinMessage } from "../api/chats";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
@@ -19,6 +20,10 @@ export function MessageList() {
|
|||||||
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
||||||
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
||||||
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
||||||
|
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
|
||||||
|
const [forwardQuery, setForwardQuery] = useState("");
|
||||||
|
const [forwardError, setForwardError] = useState<string | null>(null);
|
||||||
|
const [isForwarding, setIsForwarding] = useState(false);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
@@ -35,12 +40,29 @@ export function MessageList() {
|
|||||||
}
|
}
|
||||||
const chatId = activeChatId;
|
const chatId = activeChatId;
|
||||||
|
|
||||||
async function handleForward(messageId: number) {
|
const forwardTargets = useMemo(() => {
|
||||||
const targetRaw = window.prompt("Forward to chat id:");
|
const q = forwardQuery.trim().toLowerCase();
|
||||||
if (!targetRaw) return;
|
if (!q) return chats;
|
||||||
const targetId = Number(targetRaw);
|
return chats.filter((chat) => {
|
||||||
if (!Number.isFinite(targetId) || targetId <= 0) return;
|
const label = chatLabel(chat).toLowerCase();
|
||||||
await forwardMessage(messageId, targetId);
|
const handle = (chat.handle || "").toLowerCase();
|
||||||
|
return label.includes(q) || handle.includes(q);
|
||||||
|
});
|
||||||
|
}, [chats, forwardQuery]);
|
||||||
|
|
||||||
|
async function handleForward(targetChatId: number) {
|
||||||
|
if (!forwardMessageId) return;
|
||||||
|
setIsForwarding(true);
|
||||||
|
setForwardError(null);
|
||||||
|
try {
|
||||||
|
await forwardMessage(forwardMessageId, targetChatId);
|
||||||
|
setForwardMessageId(null);
|
||||||
|
setForwardQuery("");
|
||||||
|
} catch {
|
||||||
|
setForwardError("Failed to forward message");
|
||||||
|
} finally {
|
||||||
|
setIsForwarding(false);
|
||||||
|
}
|
||||||
setCtx(null);
|
setCtx(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +77,7 @@ export function MessageList() {
|
|||||||
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
|
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
|
||||||
{activeChat?.pinned_message_id ? (
|
{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">
|
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
|
||||||
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || `Pinned message #${activeChat.pinned_message_id}`}
|
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
|
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
|
||||||
@@ -97,9 +119,10 @@ export function MessageList() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
|
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
|
||||||
|
|
||||||
{ctx ? (
|
{ctx
|
||||||
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-50 w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
|
className="fixed z-[100] w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
|
||||||
style={{ left: ctx.x, top: ctx.y }}
|
style={{ left: ctx.x, top: ctx.y }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -115,12 +138,54 @@ export function MessageList() {
|
|||||||
>
|
>
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</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)}>
|
<button
|
||||||
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
setForwardMessageId(ctx.messageId);
|
||||||
|
setForwardQuery("");
|
||||||
|
setForwardError(null);
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
Forward
|
Forward
|
||||||
</button>
|
</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)}>
|
<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
|
Pin / Unpin
|
||||||
</button>
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{forwardMessageId ? (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setForwardMessageId(null)}>
|
||||||
|
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<p className="mb-2 text-sm font-semibold">Forward message</p>
|
||||||
|
<input
|
||||||
|
className="mb-2 w-full rounded-lg border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
||||||
|
placeholder="Search chats"
|
||||||
|
value={forwardQuery}
|
||||||
|
onChange={(e) => setForwardQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="tg-scrollbar max-h-56 space-y-1 overflow-auto">
|
||||||
|
{forwardTargets.map((chat) => (
|
||||||
|
<button
|
||||||
|
className="block w-full rounded-lg bg-slate-800 px-3 py-2 text-left text-sm hover:bg-slate-700 disabled:opacity-60"
|
||||||
|
disabled={isForwarding}
|
||||||
|
key={chat.id}
|
||||||
|
onClick={() => void handleForward(chat.id)}
|
||||||
|
>
|
||||||
|
<p className="truncate font-semibold">{chatLabel(chat)}</p>
|
||||||
|
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{forwardTargets.length === 0 ? <p className="px-1 py-2 text-xs text-slate-400">No chats found</p> : null}
|
||||||
|
</div>
|
||||||
|
{forwardError ? <p className="mt-2 text-xs text-red-400">{forwardError}</p> : null}
|
||||||
|
<button className="mt-3 w-full rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setForwardMessageId(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,3 +223,12 @@ function getSafeContextPosition(x: number, y: number, menuWidth: number, menuHei
|
|||||||
const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad);
|
const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad);
|
||||||
return { x: safeX, y: safeY };
|
return { x: safeX, y: safeY };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chatLabel(chat: { display_title?: string | null; title: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }): string {
|
||||||
|
if (chat.display_title?.trim()) return chat.display_title;
|
||||||
|
if (chat.title?.trim()) return chat.title;
|
||||||
|
if (chat.is_saved) return "Saved Messages";
|
||||||
|
if (chat.type === "private") return "Direct chat";
|
||||||
|
if (chat.type === "group") return "Group";
|
||||||
|
return "Channel";
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export function NewChatPanel() {
|
|||||||
{discoverResults.map((chat) => (
|
{discoverResults.map((chat) => (
|
||||||
<div className="mb-1 flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2" key={chat.id}>
|
<div className="mb-1 flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2" key={chat.id}>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold">{chat.title || `${chat.type} #${chat.id}`}</p>
|
<p className="truncate text-sm font-semibold">{chat.display_title || chat.title || (chat.type === "group" ? "Group" : "Channel")}</p>
|
||||||
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
|
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
|
||||||
</div>
|
</div>
|
||||||
{chat.is_member ? (
|
{chat.is_member ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user