feat: realtime sync, settings UX and chat list improvements
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:
2026-03-08 10:59:44 +03:00
parent a4fa72df30
commit 99e7c70901
18 changed files with 1007 additions and 78 deletions

View File

@@ -237,6 +237,29 @@ async def get_unread_count_for_chat(db: AsyncSession, *, chat_id: int, user_id:
return int(result.scalar_one() or 0)
async def get_last_visible_message_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
) -> Message | None:
stmt = (
select(Message)
.outerjoin(
MessageHidden,
(MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id),
)
.where(
Message.chat_id == chat_id,
MessageHidden.id.is_(None),
)
.order_by(Message.id.desc())
.limit(1)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_chat_notification_setting(
db: AsyncSession, *, chat_id: int, user_id: int
) -> ChatNotificationSetting | None:

View File

@@ -43,6 +43,7 @@ from app.chats.service import (
update_chat_title_for_user,
)
from app.database.session import get_db
from app.realtime.service import realtime_gateway
from app.users.models import User
router = APIRouter(prefix="/chats", tags=["chats"])
@@ -94,6 +95,7 @@ async def create_chat(
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
realtime_gateway.add_chat_subscription(chat_id=chat.id, user_id=current_user.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@@ -104,6 +106,8 @@ async def join_chat(
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
realtime_gateway.add_chat_subscription(chat_id=chat.id, user_id=current_user.id)
await realtime_gateway.publish_chat_updated(chat_id=chat.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@@ -131,6 +135,7 @@ async def update_chat_title(
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
await realtime_gateway.publish_chat_updated(chat_id=chat.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@@ -151,7 +156,10 @@ async def add_chat_member(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatMemberRead:
return await add_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=payload.user_id)
member = await add_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=payload.user_id)
realtime_gateway.add_chat_subscription(chat_id=chat_id, user_id=payload.user_id)
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
return member
@router.patch("/{chat_id}/members/{user_id}/role", response_model=ChatMemberRead)
@@ -162,13 +170,15 @@ async def update_chat_member_role(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatMemberRead:
return await update_chat_member_role_for_user(
member = await update_chat_member_role_for_user(
db,
chat_id=chat_id,
actor_user_id=current_user.id,
target_user_id=user_id,
role=payload.role,
)
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
return member
@router.delete("/{chat_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -179,6 +189,8 @@ async def remove_chat_member(
current_user: User = Depends(get_current_user),
) -> None:
await remove_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id)
realtime_gateway.remove_chat_subscription(chat_id=chat_id, user_id=user_id)
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
@router.post("/{chat_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
@@ -188,6 +200,8 @@ async def leave_chat(
current_user: User = Depends(get_current_user),
) -> None:
await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
realtime_gateway.remove_chat_subscription(chat_id=chat_id, user_id=current_user.id)
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
@router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -217,6 +231,7 @@ async def pin_chat_message(
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
await realtime_gateway.publish_chat_updated(chat_id=chat.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@@ -300,4 +315,6 @@ async def join_by_invite(
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await join_chat_by_invite_for_user(db, user_id=current_user.id, payload=payload)
realtime_gateway.add_chat_subscription(chat_id=chat.id, user_id=current_user.id)
await realtime_gateway.publish_chat_updated(chat_id=chat.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)

View File

@@ -3,6 +3,7 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.chats.models import ChatMemberRole, ChatType
from app.messages.models import MessageType
class ChatRead(BaseModel):
@@ -29,6 +30,9 @@ class ChatRead(BaseModel):
counterpart_username: str | None = None
counterpart_is_online: bool | None = None
counterpart_last_seen_at: datetime | None = None
last_message_text: str | None = None
last_message_type: MessageType | None = None
last_message_created_at: datetime | None = None
my_role: ChatMemberRole | None = None
created_at: datetime

View File

@@ -67,6 +67,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
subscribers_count = members_count
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id)
last_message = await repository.get_last_visible_message_for_user(db, chat_id=chat.id, user_id=user_id)
user_setting = await repository.get_chat_user_setting(db, chat_id=chat.id, user_id=user_id)
archived = bool(user_setting and user_setting.archived)
pinned = bool(user_setting and user_setting.pinned)
@@ -94,6 +95,9 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
"counterpart_username": counterpart_username,
"counterpart_is_online": counterpart_is_online,
"counterpart_last_seen_at": counterpart_last_seen_at,
"last_message_text": last_message.text if last_message else None,
"last_message_type": last_message.type if last_message else None,
"last_message_created_at": last_message.created_at if last_message else None,
"my_role": my_role,
"created_at": chat.created_at,
}

View File

@@ -37,6 +37,7 @@ class ChatAttachmentRead(BaseModel):
id: int
message_id: int
sender_id: int
message_type: str
message_created_at: datetime
file_url: str
file_type: str

View File

@@ -157,6 +157,7 @@ async def list_attachments_for_chat(
id=attachment.id,
message_id=attachment.message_id,
sender_id=message.sender_id,
message_type=message.type.value if hasattr(message.type, "value") else str(message.type),
message_created_at=message.created_at,
file_url=attachment.file_url,
file_type=attachment.file_type,

View File

@@ -17,6 +17,7 @@ RealtimeEventName = Literal[
"message_delivered",
"user_online",
"user_offline",
"chat_updated",
"pong",
"error",
]

View File

@@ -144,6 +144,24 @@ class RealtimeGateway:
async def load_user_chat_ids(self, db: AsyncSession, user_id: int) -> list[int]:
return await list_user_chat_ids(db, user_id=user_id)
def add_chat_subscription(self, *, chat_id: int, user_id: int) -> None:
self._chat_subscribers[chat_id].add(user_id)
def remove_chat_subscription(self, *, chat_id: int, user_id: int) -> None:
subscribers = self._chat_subscribers.get(chat_id)
if not subscribers:
return
subscribers.discard(user_id)
if not subscribers:
self._chat_subscribers.pop(chat_id, None)
async def publish_chat_updated(self, *, chat_id: int) -> None:
await self._publish_chat_event(
chat_id,
event="chat_updated",
payload={"chat_id": chat_id},
)
async def _handle_redis_event(self, channel: str, payload: dict) -> None:
chat_id = self._extract_chat_id(channel)
if chat_id is None:

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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())) {

View File

@@ -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();
}

View 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>
);
}

View File

@@ -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";
}

View File

@@ -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%),

View 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)));
}

View File

@@ -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"}