feat: realtime sync, settings UX and chat list improvements
Some checks failed
CI / test (push) Failing after 21s
Some checks failed
CI / test (push) Failing after 21s
- add chat_updated realtime event and dynamic chat subscriptions - auto-join invite links in web app - implement Telegram-like settings panel (general/notifications/privacy) - add browser notification preferences and keyboard send mode - improve chat list with last message preview/time and online badge - rework chat members UI with context actions and role crowns
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { joinByInvite } from "../api/chats";
|
||||
import { ToastViewport } from "../components/ToastViewport";
|
||||
import { AuthPage } from "../pages/AuthPage";
|
||||
import { ChatsPage } from "../pages/ChatsPage";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
|
||||
const PENDING_INVITE_TOKEN_KEY = "bm_pending_invite_token";
|
||||
|
||||
export function App() {
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
@@ -10,6 +14,9 @@ export function App() {
|
||||
const loadMe = useAuthStore((s) => s.loadMe);
|
||||
const refresh = useAuthStore((s) => s.refresh);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const loadChats = useChatStore((s) => s.loadChats);
|
||||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||||
const [joiningInvite, setJoiningInvite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
@@ -25,6 +32,36 @@ export function App() {
|
||||
});
|
||||
}, [accessToken, loadMe, refresh, logout]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = extractInviteTokenFromLocation();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(PENDING_INVITE_TOKEN_KEY, token);
|
||||
window.history.replaceState(null, "", "/");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !me || joiningInvite) {
|
||||
return;
|
||||
}
|
||||
const token = window.localStorage.getItem(PENDING_INVITE_TOKEN_KEY);
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
setJoiningInvite(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const chat = await joinByInvite(token);
|
||||
await loadChats();
|
||||
setActiveChatId(chat.id);
|
||||
window.localStorage.removeItem(PENDING_INVITE_TOKEN_KEY);
|
||||
} finally {
|
||||
setJoiningInvite(false);
|
||||
}
|
||||
})();
|
||||
}, [accessToken, me, joiningInvite, loadChats, setActiveChatId]);
|
||||
|
||||
if (!accessToken || !me) {
|
||||
return <AuthPage />;
|
||||
}
|
||||
@@ -35,3 +72,16 @@ export function App() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function extractInviteTokenFromLocation(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
const tokenFromQuery = url.searchParams.get("token")?.trim();
|
||||
if (tokenFromQuery) {
|
||||
return tokenFromQuery;
|
||||
}
|
||||
const match = url.pathname.match(/^\/join\/([^/]+)$/i);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface Chat {
|
||||
counterpart_username?: string | null;
|
||||
counterpart_is_online?: boolean | null;
|
||||
counterpart_last_seen_at?: string | null;
|
||||
last_message_text?: string | null;
|
||||
last_message_type?: MessageType | null;
|
||||
last_message_created_at?: string | null;
|
||||
my_role?: ChatMemberRole | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -102,6 +105,7 @@ export interface ChatAttachment {
|
||||
id: number;
|
||||
message_id: number;
|
||||
sender_id: number;
|
||||
message_type: MessageType;
|
||||
message_created_at: string;
|
||||
file_url: string;
|
||||
file_type: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addChatMember,
|
||||
createInviteLink,
|
||||
getChatAttachments,
|
||||
getMessages,
|
||||
getChatNotificationSettings,
|
||||
getChatDetail,
|
||||
leaveChat,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
updateChatTitle
|
||||
} from "../api/chats";
|
||||
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users";
|
||||
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, UserSearchItem } from "../chat/types";
|
||||
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
@@ -29,6 +30,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
const me = useAuthStore((s) => s.me);
|
||||
const loadChats = useChatStore((s) => s.loadChats);
|
||||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||||
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
||||
const showToast = useUiStore((s) => s.showToast);
|
||||
const [chat, setChat] = useState<ChatDetail | null>(null);
|
||||
const [members, setMembers] = useState<ChatMember[]>([]);
|
||||
@@ -46,22 +48,29 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
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 [attachmentsTab, setAttachmentsTab] = useState<"media" | "files">("media");
|
||||
const [linkItems, setLinkItems] = useState<Array<{ url: string; messageId: number; createdAt: string }>>([]);
|
||||
const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string; messageId: number } | null>(null);
|
||||
const [memberCtx, setMemberCtx] = useState<{ x: number; y: number; member: ChatMember } | null>(null);
|
||||
const [attachmentsTab, setAttachmentsTab] = useState<"all" | "photos" | "videos" | "audio" | "links" | "voice">("all");
|
||||
const [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null);
|
||||
|
||||
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
|
||||
const myRole = useMemo(() => {
|
||||
if (chat?.my_role) {
|
||||
return chat.my_role;
|
||||
}
|
||||
return members.find((m) => m.user_id === me?.id)?.role;
|
||||
}, [chat?.my_role, members, me?.id]);
|
||||
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
|
||||
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
|
||||
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
|
||||
const canChangeRoles = Boolean(isGroupLike && myRole === "owner");
|
||||
const mediaAttachments = useMemo(
|
||||
() => attachments.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/")),
|
||||
[attachments]
|
||||
);
|
||||
const fileAttachments = useMemo(
|
||||
() => attachments.filter((item) => !item.file_type.startsWith("image/") && !item.file_type.startsWith("video/")),
|
||||
const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")), [attachments]);
|
||||
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")), [attachments]);
|
||||
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice"), [attachments]);
|
||||
const audioAttachments = useMemo(
|
||||
() => attachments.filter((item) => item.message_type === "audio" || (item.file_type.startsWith("audio/") && item.message_type !== "voice")),
|
||||
[attachments]
|
||||
);
|
||||
const allAttachmentItems = useMemo(() => [...attachments].sort((a, b) => b.id - a.id), [attachments]);
|
||||
|
||||
async function refreshMembers(targetChatId: number) {
|
||||
const nextMembers = await listChatMembers(targetChatId);
|
||||
@@ -75,6 +84,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
setMemberUsers(byId);
|
||||
}
|
||||
|
||||
function jumpToMessage(messageId: number) {
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
setActiveChatId(chatId);
|
||||
setFocusedMessage(chatId, messageId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !chatId) {
|
||||
return;
|
||||
@@ -109,8 +127,10 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
}
|
||||
await refreshMembers(chatId);
|
||||
const chatAttachments = await getChatAttachments(chatId, 120);
|
||||
const messages = await getRecentMessagesForLinks(chatId);
|
||||
if (!cancelled) {
|
||||
setAttachments(chatAttachments);
|
||||
setLinkItems(extractLinkItems(messages));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError("Failed to load chat info");
|
||||
@@ -126,6 +146,17 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
};
|
||||
}, [open, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setInviteLink(null);
|
||||
setMembers([]);
|
||||
setMemberUsers({});
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
}, [chatId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
@@ -143,14 +174,22 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
<div
|
||||
className="fixed inset-0 z-[120] bg-slate-950/55"
|
||||
onClick={() => {
|
||||
setAttachmentCtx(null);
|
||||
setMemberCtx(null);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<aside className="absolute right-0 top-0 flex h-full w-full max-w-sm flex-col border-l border-slate-700/70 bg-slate-900/95 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<aside
|
||||
className="absolute right-0 top-0 flex h-full w-full max-w-sm flex-col border-l border-slate-700/70 bg-slate-900/95 shadow-2xl"
|
||||
onClick={(e) => {
|
||||
setAttachmentCtx(null);
|
||||
setMemberCtx(null);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
|
||||
<p className="text-sm font-semibold">Chat info</p>
|
||||
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={onClose}>Close</button>
|
||||
@@ -245,45 +284,36 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
<div className="tg-scrollbar max-h-56 space-y-2 overflow-auto">
|
||||
{members.map((member) => {
|
||||
const user = memberUsers[member.user_id];
|
||||
const isSelf = member.user_id === me?.id;
|
||||
const canOpenMemberMenu =
|
||||
canManageMembers &&
|
||||
!isSelf &&
|
||||
(myRole === "owner" || (myRole === "admin" && member.role === "member"));
|
||||
return (
|
||||
<div className="rounded border border-slate-700/60 bg-slate-900/60 p-2" key={member.id}>
|
||||
<p className="truncate text-sm font-semibold">{user?.name || `user #${member.user_id}`}</p>
|
||||
<button
|
||||
className="block w-full rounded border border-slate-700/60 bg-slate-900/60 p-2 text-left hover:bg-slate-800/70"
|
||||
key={member.id}
|
||||
onContextMenu={(event) => {
|
||||
if (!canOpenMemberMenu) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setMemberCtx({
|
||||
x: Math.min(event.clientX + 4, window.innerWidth - 210),
|
||||
y: Math.min(event.clientY + 4, window.innerHeight - 170),
|
||||
member,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<p className="flex items-center gap-1 truncate text-sm font-semibold">
|
||||
<span className="truncate">{user?.name || `user #${member.user_id}`}</span>
|
||||
{member.role === "owner" ? <span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-300">👑 owner</span> : null}
|
||||
{member.role === "admin" ? <span className="rounded bg-sky-500/20 px-1.5 py-0.5 text-[10px] text-sky-300">👑 admin</span> : null}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-400">@{user?.username || "unknown"}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
className="flex-1 rounded bg-slate-800 px-2 py-1 text-xs"
|
||||
disabled={!canChangeRoles || member.user_id === me?.id}
|
||||
value={member.role}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await updateChatMemberRole(chatId, member.user_id, e.target.value as ChatMember["role"]);
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to update role");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select>
|
||||
{canManageMembers && member.user_id !== me?.id ? (
|
||||
<button
|
||||
className="rounded bg-red-500 px-2 py-1 text-xs font-semibold text-white"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeChatMember(chatId, member.user_id);
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to remove member");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{canOpenMemberMenu ? <p className="mt-1 text-[11px] text-slate-500">Right click for actions</p> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -358,37 +388,74 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
</p>
|
||||
<div className="mb-2 flex items-center gap-2 border-b border-slate-700/60 pb-2 text-xs">
|
||||
<button
|
||||
className={`rounded px-2 py-1 ${attachmentsTab === "media" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
|
||||
onClick={() => setAttachmentsTab("media")}
|
||||
className={`rounded px-2 py-1 ${attachmentsTab === "all" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
|
||||
onClick={() => setAttachmentsTab("all")}
|
||||
type="button"
|
||||
>
|
||||
Media
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={`rounded px-2 py-1 ${attachmentsTab === "files" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
|
||||
onClick={() => setAttachmentsTab("files")}
|
||||
className={`rounded px-2 py-1 ${attachmentsTab === "photos" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
|
||||
onClick={() => setAttachmentsTab("photos")}
|
||||
type="button"
|
||||
>
|
||||
Files
|
||||
Photos
|
||||
</button>
|
||||
<button
|
||||
className={`rounded px-2 py-1 ${attachmentsTab === "videos" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
|
||||
onClick={() => setAttachmentsTab("videos")}
|
||||
type="button"
|
||||
>
|
||||
Videos
|
||||
</button>
|
||||
<button
|
||||
className={`rounded px-2 py-1 ${attachmentsTab === "audio" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
|
||||
onClick={() => setAttachmentsTab("audio")}
|
||||
type="button"
|
||||
>
|
||||
Audio
|
||||
</button>
|
||||
<button
|
||||
className={`rounded px-2 py-1 ${attachmentsTab === "voice" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
|
||||
onClick={() => setAttachmentsTab("voice")}
|
||||
type="button"
|
||||
>
|
||||
Voice
|
||||
</button>
|
||||
<button
|
||||
className={`rounded px-2 py-1 ${attachmentsTab === "links" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
|
||||
onClick={() => setAttachmentsTab("links")}
|
||||
type="button"
|
||||
>
|
||||
Links
|
||||
</button>
|
||||
</div>
|
||||
{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 && attachmentsTab === "media" ? (
|
||||
{!attachmentsLoading && (attachmentsTab === "all" || attachmentsTab === "photos" || attachmentsTab === "videos") ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{mediaAttachments.slice(0, 90).map((item) => (
|
||||
{(attachmentsTab === "photos" ? photoAttachments : attachmentsTab === "videos" ? videoAttachments : [...photoAttachments, ...videoAttachments])
|
||||
.slice(0, 120)
|
||||
.map((item) => (
|
||||
<button
|
||||
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
|
||||
key={`media-item-${item.id}`}
|
||||
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
||||
onClick={() => {
|
||||
const mediaItems = [...photoAttachments, ...videoAttachments]
|
||||
.sort((a, b) => b.id - a.id)
|
||||
.map((it) => ({ url: it.file_url, type: it.file_type.startsWith("video/") ? "video" as const : "image" as const, messageId: it.message_id }));
|
||||
const idx = mediaItems.findIndex((it) => it.url === item.file_url && it.messageId === item.message_id);
|
||||
setMediaViewer({ items: mediaItems, index: idx >= 0 ? idx : 0 });
|
||||
}}
|
||||
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
|
||||
url: item.file_url,
|
||||
messageId: item.message_id,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
@@ -403,19 +470,23 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{!attachmentsLoading && attachmentsTab === "files" ? (
|
||||
{!attachmentsLoading && (attachmentsTab === "all" || attachmentsTab === "audio" || attachmentsTab === "voice") ? (
|
||||
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto">
|
||||
{fileAttachments.slice(0, 100).map((item) => (
|
||||
{(attachmentsTab === "audio" ? audioAttachments : attachmentsTab === "voice" ? voiceAttachments : allAttachmentItems)
|
||||
.filter((item) => !item.file_type.startsWith("image/") && !item.file_type.startsWith("video/"))
|
||||
.slice(0, 120)
|
||||
.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={`file-item-${item.id}`}
|
||||
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
||||
onClick={() => jumpToMessage(item.message_id)}
|
||||
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
|
||||
url: item.file_url,
|
||||
messageId: item.message_id,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
@@ -428,6 +499,31 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!attachmentsLoading && attachmentsTab === "links" ? (
|
||||
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto">
|
||||
{linkItems.length === 0 ? <p className="text-xs text-slate-400">No links found</p> : null}
|
||||
{linkItems.map((item, index) => (
|
||||
<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={`link-item-${item.messageId}-${index}`}
|
||||
onClick={() => jumpToMessage(item.messageId)}
|
||||
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.url,
|
||||
messageId: item.messageId,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<p className="truncate text-xs font-semibold text-sky-300">{item.url}</p>
|
||||
<p className="text-[11px] text-slate-400">{new Date(item.createdAt).toLocaleString()}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? (
|
||||
@@ -504,6 +600,142 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
jumpToMessage(attachmentCtx.messageId);
|
||||
setAttachmentCtx(null);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Jump to message
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{memberCtx ? (
|
||||
<div
|
||||
className="fixed z-[130] w-52 rounded-lg border border-slate-700/90 bg-slate-900/95 p-1 shadow-2xl"
|
||||
style={{ left: memberCtx.x, top: memberCtx.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{myRole === "owner" && memberCtx.member.role === "member" ? (
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateChatMemberRole(chatId, memberCtx.member.user_id, "admin");
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to update role");
|
||||
} finally {
|
||||
setMemberCtx(null);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Make admin
|
||||
</button>
|
||||
) : null}
|
||||
{myRole === "owner" && memberCtx.member.role === "admin" ? (
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateChatMemberRole(chatId, memberCtx.member.user_id, "member");
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to update role");
|
||||
} finally {
|
||||
setMemberCtx(null);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Remove admin rights
|
||||
</button>
|
||||
) : null}
|
||||
{myRole === "owner" && memberCtx.member.role !== "owner" ? (
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateChatMemberRole(chatId, memberCtx.member.user_id, "owner");
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to transfer ownership");
|
||||
} finally {
|
||||
setMemberCtx(null);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Transfer ownership
|
||||
</button>
|
||||
) : null}
|
||||
{(myRole === "owner" || (myRole === "admin" && memberCtx.member.role === "member")) ? (
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeChatMember(chatId, memberCtx.member.user_id);
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to remove member");
|
||||
} finally {
|
||||
setMemberCtx(null);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Remove from chat
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||
onClick={() => setMemberCtx(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{mediaViewer ? (
|
||||
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/90 p-2 md:p-4" onClick={() => setMediaViewer(null)}>
|
||||
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
||||
<button className="absolute left-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs" onClick={() => setMediaViewer(null)} type="button">
|
||||
Close
|
||||
</button>
|
||||
{mediaViewer.items.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
|
||||
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index <= 0 ? prev.items.length - 1 : prev.index - 1 } : prev))}
|
||||
type="button"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
|
||||
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index >= prev.items.length - 1 ? 0 : prev.index + 1 } : prev))}
|
||||
type="button"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
className="absolute right-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs"
|
||||
onClick={() => jumpToMessage(mediaViewer.items[mediaViewer.index].messageId)}
|
||||
type="button"
|
||||
>
|
||||
Jump
|
||||
</button>
|
||||
{mediaViewer.items[mediaViewer.index].type === "image" ? (
|
||||
<img className="max-h-full max-w-full rounded-xl object-contain" src={mediaViewer.items[mediaViewer.index].url} alt="media" />
|
||||
) : (
|
||||
<video className="max-h-full max-w-full rounded-xl" controls src={mediaViewer.items[mediaViewer.index].url} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
@@ -548,3 +780,44 @@ function attachmentKind(fileType: string): string {
|
||||
if (fileType === "application/pdf") return "PDF";
|
||||
return "File";
|
||||
}
|
||||
|
||||
async function getRecentMessagesForLinks(chatId: number): Promise<Message[]> {
|
||||
const pages = 4;
|
||||
const all: Message[] = [];
|
||||
let beforeId: number | undefined;
|
||||
for (let i = 0; i < pages; i += 1) {
|
||||
const batch = await getMessages(chatId, beforeId);
|
||||
if (!batch.length) {
|
||||
break;
|
||||
}
|
||||
all.push(...batch);
|
||||
beforeId = batch[batch.length - 1]?.id;
|
||||
if (!beforeId || batch.length < 50) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function extractLinkItems(messages: Message[]): Array<{ url: string; messageId: number; createdAt: string }> {
|
||||
const out: Array<{ url: string; messageId: number; createdAt: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const regex = /\bhttps?:\/\/[^\s<>"')]+/gi;
|
||||
for (const message of messages) {
|
||||
if (!message.text) {
|
||||
continue;
|
||||
}
|
||||
const links = message.text.match(regex) ?? [];
|
||||
for (const raw of links) {
|
||||
const url = raw.trim();
|
||||
const key = `${message.id}:${url}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
out.push({ url, messageId: message.id, createdAt: message.created_at });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.messageId - a.messageId);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
|
||||
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
|
||||
import { globalSearch } from "../api/search";
|
||||
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
|
||||
import { updateMyProfile } from "../api/users";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { NewChatPanel } from "./NewChatPanel";
|
||||
import { SettingsPanel } from "./SettingsPanel";
|
||||
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
|
||||
|
||||
export function ChatList() {
|
||||
const chats = useChatStore((s) => s.chats);
|
||||
const messagesByChat = useChatStore((s) => s.messagesByChat);
|
||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||||
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
||||
@@ -28,6 +29,8 @@ export function ChatList() {
|
||||
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
|
||||
const [deleteForAll, setDeleteForAll] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [profileUsername, setProfileUsername] = useState("");
|
||||
const [profileBio, setProfileBio] = useState("");
|
||||
@@ -119,6 +122,10 @@ export function ChatList() {
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyAppearancePreferences(getAppPreferences());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
return;
|
||||
@@ -130,6 +137,12 @@ export function ChatList() {
|
||||
setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
|
||||
}, [me]);
|
||||
|
||||
async function openSavedMessages() {
|
||||
const saved = await getSavedMessagesChat();
|
||||
const updatedChats = await getChats();
|
||||
useChatStore.setState({ chats: updatedChats, activeChatId: saved.id });
|
||||
}
|
||||
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
if (chat.archived) {
|
||||
return false;
|
||||
@@ -157,10 +170,29 @@ export function ChatList() {
|
||||
const visibleChats = tab === "archived" ? archivedChats : filteredChats;
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
|
||||
<aside className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); setMenuOpen(false); }}>
|
||||
<div className="border-b border-slate-700/50 px-3 py-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs">☰</button>
|
||||
<div className="relative mb-2 flex items-center gap-2">
|
||||
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs" onClick={() => setMenuOpen((v) => !v)}>☰</button>
|
||||
{menuOpen ? (
|
||||
<div className="absolute left-0 top-11 z-40 w-56 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl">
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setProfileOpen(true); setMenuOpen(false); }}>
|
||||
My Profile
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { void openSavedMessages(); setMenuOpen(false); }}>
|
||||
Saved Messages
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setTab("people"); setMenuOpen(false); }}>
|
||||
Contacts
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}>
|
||||
Settings
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => setMenuOpen(false)}>
|
||||
More
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="block flex-1">
|
||||
<input
|
||||
className="w-full rounded-full 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"
|
||||
@@ -282,8 +314,13 @@ export function ChatList() {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
||||
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
|
||||
<div className="relative mt-0.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
||||
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
|
||||
</div>
|
||||
{chat.type === "private" && chat.counterpart_is_online ? (
|
||||
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-slate-900 bg-emerald-400" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -294,11 +331,11 @@ export function ChatList() {
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-[11px] text-slate-400">
|
||||
{messagesByChat[chat.id]?.length ? "now" : ""}
|
||||
{formatChatListTime(chat.last_message_created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-slate-400">{chatMetaLabel(chat)}</p>
|
||||
<p className="truncate text-xs text-slate-400">{chatPreviewLabel(chat)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -468,6 +505,7 @@ export function ChatList() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -520,6 +558,50 @@ function chatMetaLabel(chat: {
|
||||
return `${subscribers} subscribers`;
|
||||
}
|
||||
|
||||
function chatPreviewLabel(chat: {
|
||||
last_message_text?: string | null;
|
||||
last_message_type?: "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video" | null;
|
||||
type: "private" | "group" | "channel";
|
||||
is_saved?: boolean;
|
||||
counterpart_is_online?: boolean | null;
|
||||
counterpart_last_seen_at?: string | null;
|
||||
members_count?: number | null;
|
||||
online_count?: number | null;
|
||||
subscribers_count?: number | null;
|
||||
}): string {
|
||||
if (chat.last_message_text?.trim()) {
|
||||
return chat.last_message_text.trim();
|
||||
}
|
||||
if (chat.last_message_type) {
|
||||
if (chat.last_message_type === "image") return "Photo";
|
||||
if (chat.last_message_type === "video") return "Video";
|
||||
if (chat.last_message_type === "audio") return "Audio";
|
||||
if (chat.last_message_type === "voice") return "Voice message";
|
||||
if (chat.last_message_type === "file") return "File";
|
||||
if (chat.last_message_type === "circle_video") return "Video message";
|
||||
}
|
||||
return chatMetaLabel(chat);
|
||||
}
|
||||
|
||||
function formatChatListTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
if (sameDay) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
|
||||
function formatLastSeen(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresigne
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { buildWsUrl } from "../utils/ws";
|
||||
import { getAppPreferences } from "../utils/preferences";
|
||||
|
||||
type RecordingState = "idle" | "recording" | "locked";
|
||||
|
||||
@@ -149,7 +150,19 @@ export function MessageComposer() {
|
||||
}
|
||||
|
||||
function onComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
const prefs = getAppPreferences();
|
||||
const sendWithCtrlEnter = prefs.sendMode === "ctrl_enter";
|
||||
if (sendWithCtrlEnter) {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
|
||||
288
web/src/components/SettingsPanel.tsx
Normal file
288
web/src/components/SettingsPanel.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { listBlockedUsers, updateMyProfile } from "../api/users";
|
||||
import type { AuthUser } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
|
||||
|
||||
type SettingsPage = "main" | "general" | "notifications" | "privacy";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SettingsPanel({ open, onClose }: Props) {
|
||||
const me = useAuthStore((s) => s.me);
|
||||
const [page, setPage] = useState<SettingsPage>("main");
|
||||
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
|
||||
const [blockedCount, setBlockedCount] = useState(0);
|
||||
const [savingPrivacy, setSavingPrivacy] = useState(false);
|
||||
const [allowPrivateMessages, setAllowPrivateMessages] = useState(true);
|
||||
const [profileDraft, setProfileDraft] = useState({
|
||||
name: "",
|
||||
username: "",
|
||||
bio: "",
|
||||
avatarUrl: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
return;
|
||||
}
|
||||
setAllowPrivateMessages(me.allow_private_messages);
|
||||
setProfileDraft({
|
||||
name: me.name || "",
|
||||
username: me.username || "",
|
||||
bio: me.bio || "",
|
||||
avatarUrl: me.avatar_url || "",
|
||||
});
|
||||
}, [me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setPrefs(getAppPreferences());
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || page !== "privacy") {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const blocked = await listBlockedUsers();
|
||||
if (!cancelled) {
|
||||
setBlockedCount(blocked.length);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setBlockedCount(0);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, page]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (page === "general") return "General";
|
||||
if (page === "notifications") return "Notifications";
|
||||
if (page === "privacy") return "Privacy and Security";
|
||||
return "Settings";
|
||||
}, [page]);
|
||||
|
||||
if (!open || !me) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function updatePrefs(patch: Partial<AppPreferences>) {
|
||||
setPrefs(updateAppPreferences(patch));
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[150] bg-slate-950/60" onClick={onClose}>
|
||||
<aside
|
||||
className="absolute left-0 top-0 flex h-full w-full max-w-md flex-col border-r border-slate-700/70 bg-slate-900/95 shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{page !== "main" ? (
|
||||
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setPage("main")}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<p className="text-lg font-semibold">{title}</p>
|
||||
</div>
|
||||
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tg-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
{page === "main" ? (
|
||||
<>
|
||||
<div className="border-b border-slate-700/60 px-4 py-4">
|
||||
<p className="text-sm text-slate-300">Profile</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
|
||||
placeholder="Name"
|
||||
value={profileDraft.name}
|
||||
onChange={(e) => setProfileDraft((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
|
||||
placeholder="Username"
|
||||
value={profileDraft.username}
|
||||
onChange={(e) => setProfileDraft((prev) => ({ ...prev, username: e.target.value.replace("@", "") }))}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
|
||||
placeholder="Bio"
|
||||
value={profileDraft.bio}
|
||||
onChange={(e) => setProfileDraft((prev) => ({ ...prev, bio: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
|
||||
placeholder="Avatar URL"
|
||||
value={profileDraft.avatarUrl}
|
||||
onChange={(e) => setProfileDraft((prev) => ({ ...prev, avatarUrl: e.target.value }))}
|
||||
/>
|
||||
<button
|
||||
className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950"
|
||||
onClick={async () => {
|
||||
const updated = await updateMyProfile({
|
||||
name: profileDraft.name.trim() || undefined,
|
||||
username: profileDraft.username.trim() || undefined,
|
||||
bio: profileDraft.bio.trim() || null,
|
||||
avatar_url: profileDraft.avatarUrl.trim() || null,
|
||||
});
|
||||
useAuthStore.setState({ me: updated as AuthUser });
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Save profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MenuItem label="General Settings" onClick={() => setPage("general")} />
|
||||
<MenuItem label="Notifications" onClick={() => setPage("notifications")} />
|
||||
<MenuItem label="Privacy and Security" onClick={() => setPage("privacy")} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{page === "general" ? (
|
||||
<div className="space-y-4 px-4 py-3">
|
||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||
<p className="mb-2 text-sm text-slate-300">Message Font Size</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
className="w-full"
|
||||
min={12}
|
||||
max={24}
|
||||
step={1}
|
||||
type="range"
|
||||
value={prefs.messageFontSize}
|
||||
onChange={(e) => updatePrefs({ messageFontSize: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="text-sm text-slate-200">{prefs.messageFontSize}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||
<p className="mb-2 text-sm text-slate-300">Theme</p>
|
||||
<RadioOption checked={prefs.theme === "light"} label="Light" onChange={() => updatePrefs({ theme: "light" })} />
|
||||
<RadioOption checked={prefs.theme === "dark"} label="Dark" onChange={() => updatePrefs({ theme: "dark" })} />
|
||||
<RadioOption checked={prefs.theme === "system"} label="System" onChange={() => updatePrefs({ theme: "system" })} />
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||
<p className="mb-2 text-sm text-slate-300">Keyboard</p>
|
||||
<RadioOption checked={prefs.sendMode === "enter"} label="Send with Enter" onChange={() => updatePrefs({ sendMode: "enter" })} />
|
||||
<RadioOption checked={prefs.sendMode === "ctrl_enter"} label="Send with Ctrl+Enter" onChange={() => updatePrefs({ sendMode: "ctrl_enter" })} />
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "notifications" ? (
|
||||
<div className="space-y-4 px-4 py-3">
|
||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||
<CheckboxOption
|
||||
checked={prefs.webNotifications}
|
||||
label="Web Notifications"
|
||||
onChange={async (checked) => {
|
||||
updatePrefs({ webNotifications: checked });
|
||||
if (checked && "Notification" in window && Notification.permission === "default") {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CheckboxOption
|
||||
checked={prefs.messagePreview}
|
||||
label="Message Preview"
|
||||
onChange={(checked) => updatePrefs({ messagePreview: checked })}
|
||||
/>
|
||||
</section>
|
||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||
<p className="mb-2 text-sm text-slate-300">Chats</p>
|
||||
<CheckboxOption checked={prefs.privateNotifications} label="Private chats" onChange={(checked) => updatePrefs({ privateNotifications: checked })} />
|
||||
<CheckboxOption checked={prefs.groupNotifications} label="Groups" onChange={(checked) => updatePrefs({ groupNotifications: checked })} />
|
||||
<CheckboxOption checked={prefs.channelNotifications} label="Channels" onChange={(checked) => updatePrefs({ channelNotifications: checked })} />
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{page === "privacy" ? (
|
||||
<div className="space-y-4 px-4 py-3">
|
||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||
<p className="text-sm text-slate-300">Blocked Users</p>
|
||||
<p className="text-xs text-slate-400">{blockedCount}</p>
|
||||
</section>
|
||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||
<CheckboxOption
|
||||
checked={allowPrivateMessages}
|
||||
label="Who can send me messages: Everybody"
|
||||
onChange={async (checked) => {
|
||||
setAllowPrivateMessages(checked);
|
||||
setSavingPrivacy(true);
|
||||
try {
|
||||
const updated = await updateMyProfile({ allow_private_messages: checked });
|
||||
useAuthStore.setState({ me: updated as AuthUser });
|
||||
} finally {
|
||||
setSavingPrivacy(false);
|
||||
}
|
||||
}}
|
||||
disabled={savingPrivacy}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button className="block w-full border-b border-slate-800/60 px-4 py-3 text-left text-sm hover:bg-slate-800/70" onClick={onClick} type="button">
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioOption({ checked, label, onChange }: { checked: boolean; label: string; onChange: () => void }) {
|
||||
return (
|
||||
<label className="mb-2 flex items-center gap-2 text-sm">
|
||||
<input checked={checked} onChange={onChange} type="radio" />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxOption({
|
||||
checked,
|
||||
label,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="mb-2 flex items-center gap-2 text-sm">
|
||||
<input checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} type="checkbox" />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import type { Message } from "../chat/types";
|
||||
import { getAppPreferences } from "../utils/preferences";
|
||||
import { buildWsUrl } from "../utils/ws";
|
||||
|
||||
interface RealtimeEnvelope {
|
||||
@@ -21,6 +22,7 @@ export function useRealtime() {
|
||||
const lastPongAtRef = useRef<number>(Date.now());
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const manualCloseRef = useRef(false);
|
||||
const notificationPermissionRequestedRef = useRef(false);
|
||||
|
||||
const wsUrl = useMemo(() => {
|
||||
return accessToken ? buildWsUrl(accessToken) : null;
|
||||
@@ -67,6 +69,10 @@ export function useRealtime() {
|
||||
if (store.activeChatId) {
|
||||
void store.loadMessages(store.activeChatId);
|
||||
}
|
||||
if ("Notification" in window && Notification.permission === "default" && !notificationPermissionRequestedRef.current) {
|
||||
notificationPermissionRequestedRef.current = true;
|
||||
void Notification.requestPermission();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (messageEvent) => {
|
||||
@@ -99,11 +105,21 @@ export function useRealtime() {
|
||||
} else if (wasInserted) {
|
||||
chatStore.incrementUnread(chatId);
|
||||
}
|
||||
maybeShowBrowserNotification(chatId, message, chatStore.activeChatId);
|
||||
}
|
||||
if (!chatStore.chats.some((chat) => chat.id === chatId)) {
|
||||
void chatStore.loadChats();
|
||||
}
|
||||
}
|
||||
if (event.event === "chat_updated") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
if (Number.isFinite(chatId)) {
|
||||
void chatStore.loadChats();
|
||||
if (chatStore.activeChatId === chatId) {
|
||||
void chatStore.loadMessages(chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.event === "pong") {
|
||||
lastPongAtRef.current = Date.now();
|
||||
}
|
||||
@@ -216,3 +232,49 @@ export function useRealtime() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function maybeShowBrowserNotification(chatId: number, message: Message, activeChatId: number | null): void {
|
||||
const prefs = getAppPreferences();
|
||||
if (!prefs.webNotifications) {
|
||||
return;
|
||||
}
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") {
|
||||
return;
|
||||
}
|
||||
if (!document.hidden && activeChatId === chatId) {
|
||||
return;
|
||||
}
|
||||
const chat = useChatStore.getState().chats.find((item) => item.id === chatId);
|
||||
if (chat?.type === "private" && !prefs.privateNotifications) {
|
||||
return;
|
||||
}
|
||||
if (chat?.type === "group" && !prefs.groupNotifications) {
|
||||
return;
|
||||
}
|
||||
if (chat?.type === "channel" && !prefs.channelNotifications) {
|
||||
return;
|
||||
}
|
||||
const title = chat?.display_title || chat?.title || "New message";
|
||||
const body = prefs.messagePreview ? (message.text?.trim() || messagePreviewByType(message.type)) : "New message";
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
tag: `chat-${chatId}`,
|
||||
});
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
const store = useChatStore.getState();
|
||||
store.setActiveChatId(chatId);
|
||||
store.setFocusedMessage(chatId, message.id);
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
|
||||
function messagePreviewByType(type: Message["type"]): string {
|
||||
if (type === "image") return "Photo";
|
||||
if (type === "video") return "Video";
|
||||
if (type === "audio") return "Audio";
|
||||
if (type === "voice") return "Voice message";
|
||||
if (type === "file") return "File";
|
||||
if (type === "circle_video") return "Video message";
|
||||
return "New message";
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ body,
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Manrope", "Segoe UI", sans-serif;
|
||||
font-size: var(--bm-font-size, 16px);
|
||||
background:
|
||||
radial-gradient(circle at 22% 18%, rgba(36, 68, 117, 0.35), transparent 26%),
|
||||
radial-gradient(circle at 82% 12%, rgba(24, 95, 102, 0.25), transparent 30%),
|
||||
|
||||
87
web/src/utils/preferences.ts
Normal file
87
web/src/utils/preferences.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
export type SendMode = "enter" | "ctrl_enter";
|
||||
|
||||
export interface AppPreferences {
|
||||
theme: ThemeMode;
|
||||
messageFontSize: number;
|
||||
sendMode: SendMode;
|
||||
webNotifications: boolean;
|
||||
privateNotifications: boolean;
|
||||
groupNotifications: boolean;
|
||||
channelNotifications: boolean;
|
||||
messagePreview: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "bm_preferences_v1";
|
||||
|
||||
const DEFAULTS: AppPreferences = {
|
||||
theme: "system",
|
||||
messageFontSize: 16,
|
||||
sendMode: "enter",
|
||||
webNotifications: true,
|
||||
privateNotifications: true,
|
||||
groupNotifications: true,
|
||||
channelNotifications: true,
|
||||
messagePreview: true,
|
||||
};
|
||||
|
||||
export function getAppPreferences(): AppPreferences {
|
||||
if (typeof window === "undefined") {
|
||||
return DEFAULTS;
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return DEFAULTS;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<AppPreferences>;
|
||||
return {
|
||||
theme: parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system" ? parsed.theme : DEFAULTS.theme,
|
||||
messageFontSize: normalizeFontSize(parsed.messageFontSize),
|
||||
sendMode: parsed.sendMode === "ctrl_enter" ? "ctrl_enter" : "enter",
|
||||
webNotifications: typeof parsed.webNotifications === "boolean" ? parsed.webNotifications : DEFAULTS.webNotifications,
|
||||
privateNotifications: typeof parsed.privateNotifications === "boolean" ? parsed.privateNotifications : DEFAULTS.privateNotifications,
|
||||
groupNotifications: typeof parsed.groupNotifications === "boolean" ? parsed.groupNotifications : DEFAULTS.groupNotifications,
|
||||
channelNotifications: typeof parsed.channelNotifications === "boolean" ? parsed.channelNotifications : DEFAULTS.channelNotifications,
|
||||
messagePreview: typeof parsed.messagePreview === "boolean" ? parsed.messagePreview : DEFAULTS.messagePreview,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAppPreferences(next: AppPreferences): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export function updateAppPreferences(patch: Partial<AppPreferences>): AppPreferences {
|
||||
const current = getAppPreferences();
|
||||
const next: AppPreferences = {
|
||||
...current,
|
||||
...patch,
|
||||
messageFontSize: normalizeFontSize((patch.messageFontSize ?? current.messageFontSize)),
|
||||
};
|
||||
saveAppPreferences(next);
|
||||
applyAppearancePreferences(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function applyAppearancePreferences(prefs: AppPreferences): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
document.documentElement.style.setProperty("--bm-font-size", `${prefs.messageFontSize}px`);
|
||||
document.documentElement.setAttribute("data-theme", prefs.theme);
|
||||
}
|
||||
|
||||
function normalizeFontSize(value: number | undefined): number {
|
||||
const input = Number(value);
|
||||
if (!Number.isFinite(input)) {
|
||||
return DEFAULTS.messageFontSize;
|
||||
}
|
||||
return Math.max(12, Math.min(24, Math.round(input)));
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/ws.ts"],"version":"5.9.2"}
|
||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
||||
Reference in New Issue
Block a user