feat(web): improve message UX, voice gestures, and attachments
Some checks failed
CI / test (push) Failing after 21s
Some checks failed
CI / test (push) Failing after 21s
This commit is contained in:
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import {
|
||||
addChatMember,
|
||||
createInviteLink,
|
||||
getChatAttachments,
|
||||
getChatNotificationSettings,
|
||||
getChatDetail,
|
||||
leaveChat,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
updateChatTitle
|
||||
} from "../api/chats";
|
||||
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users";
|
||||
import type { AuthUser, ChatDetail, ChatMember, UserSearchItem } from "../chat/types";
|
||||
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, UserSearchItem } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
|
||||
@@ -41,6 +42,9 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
const [counterpartBlocked, setCounterpartBlocked] = useState(false);
|
||||
const [savingBlock, setSavingBlock] = useState(false);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
||||
const [attachmentsLoading, setAttachmentsLoading] = useState(false);
|
||||
const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string } | null>(null);
|
||||
|
||||
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
|
||||
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
||||
@@ -67,6 +71,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAttachmentsLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await getChatDetail(chatId);
|
||||
@@ -92,10 +97,17 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
setCounterpartBlocked(false);
|
||||
}
|
||||
await refreshMembers(chatId);
|
||||
const chatAttachments = await getChatAttachments(chatId, 120);
|
||||
if (!cancelled) {
|
||||
setAttachments(chatAttachments);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError("Failed to load chat info");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setAttachmentsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -120,7 +132,13 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[120] bg-slate-950/55" onClick={onClose}>
|
||||
<div
|
||||
className="fixed inset-0 z-[120] bg-slate-950/55"
|
||||
onClick={() => {
|
||||
setAttachmentCtx(null);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-sm border-l border-slate-700/70 bg-slate-900/95 p-4 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">Chat info</p>
|
||||
@@ -321,6 +339,65 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">
|
||||
Media & Files {attachments.length > 0 ? `(${attachments.length})` : ""}
|
||||
</p>
|
||||
{attachmentsLoading ? <p className="text-xs text-slate-400">Loading attachments...</p> : null}
|
||||
{!attachmentsLoading && attachments.length === 0 ? <p className="text-xs text-slate-400">No attachments yet</p> : null}
|
||||
|
||||
{!attachmentsLoading ? (
|
||||
<>
|
||||
<div className="mb-2 grid grid-cols-3 gap-1">
|
||||
{attachments
|
||||
.filter((item) => item.file_type.startsWith("image/"))
|
||||
.slice(0, 9)
|
||||
.map((item) => (
|
||||
<button
|
||||
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
|
||||
key={`media-image-${item.id}`}
|
||||
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
setAttachmentCtx({
|
||||
x: Math.min(event.clientX + 4, window.innerWidth - 190),
|
||||
y: Math.min(event.clientY + 4, window.innerHeight - 120),
|
||||
url: item.file_url
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<img alt="attachment" className="h-full w-full object-cover transition group-hover:scale-105" src={item.file_url} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tg-scrollbar max-h-44 space-y-1 overflow-auto">
|
||||
{attachments.slice(0, 40).map((item) => (
|
||||
<button
|
||||
className="block w-full rounded-md border border-slate-700/60 bg-slate-900/70 px-2 py-1.5 text-left hover:bg-slate-700/70"
|
||||
key={`media-item-${item.id}`}
|
||||
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
setAttachmentCtx({
|
||||
x: Math.min(event.clientX + 4, window.innerWidth - 190),
|
||||
y: Math.min(event.clientY + 4, window.innerHeight - 120),
|
||||
url: item.file_url
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<p className="truncate text-xs font-semibold text-slate-200">{extractFileName(item.file_url)}</p>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{attachmentKind(item.file_type)} • {formatBytes(item.file_size)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? (
|
||||
<button
|
||||
className="mb-3 w-full rounded bg-slate-700 px-3 py-2 text-sm disabled:opacity-60"
|
||||
@@ -366,6 +443,34 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
</>
|
||||
) : null}
|
||||
</aside>
|
||||
{attachmentCtx ? (
|
||||
<div
|
||||
className="fixed z-[130] w-44 rounded-lg border border-slate-700/90 bg-slate-900/95 p-1 shadow-2xl"
|
||||
style={{ left: attachmentCtx.x, top: attachmentCtx.y }}
|
||||
>
|
||||
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} rel="noreferrer" target="_blank">
|
||||
Open
|
||||
</a>
|
||||
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} download>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(attachmentCtx.url);
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
setAttachmentCtx(null);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
@@ -383,3 +488,28 @@ function formatLastSeen(value: string): string {
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function extractFileName(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const value = parsed.pathname.split("/").pop();
|
||||
return decodeURIComponent(value || "file");
|
||||
} catch {
|
||||
const value = url.split("/").pop();
|
||||
return value ? decodeURIComponent(value) : "file";
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentKind(fileType: string): string {
|
||||
if (fileType.startsWith("image/")) return "Photo";
|
||||
if (fileType.startsWith("video/")) return "Video";
|
||||
if (fileType.startsWith("audio/")) return "Audio";
|
||||
if (fileType === "application/pdf") return "PDF";
|
||||
return "File";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user