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) 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( async def get_chat_notification_setting(
db: AsyncSession, *, chat_id: int, user_id: int db: AsyncSession, *, chat_id: int, user_id: int
) -> ChatNotificationSetting | None: ) -> ChatNotificationSetting | None:

View File

@@ -43,6 +43,7 @@ from app.chats.service import (
update_chat_title_for_user, update_chat_title_for_user,
) )
from app.database.session import get_db from app.database.session import get_db
from app.realtime.service import realtime_gateway
from app.users.models import User from app.users.models import User
router = APIRouter(prefix="/chats", tags=["chats"]) router = APIRouter(prefix="/chats", tags=["chats"])
@@ -94,6 +95,7 @@ async def create_chat(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
chat = await create_chat_for_user(db, creator_id=current_user.id, payload=payload) 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) 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), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
chat = await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) 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) 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), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
chat = await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) 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) 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), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ChatMemberRead: ) -> 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) @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), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ChatMemberRead: ) -> ChatMemberRead:
return await update_chat_member_role_for_user( member = await update_chat_member_role_for_user(
db, db,
chat_id=chat_id, chat_id=chat_id,
actor_user_id=current_user.id, actor_user_id=current_user.id,
target_user_id=user_id, target_user_id=user_id,
role=payload.role, 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) @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), current_user: User = Depends(get_current_user),
) -> None: ) -> None:
await remove_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id) 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) @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), current_user: User = Depends(get_current_user),
) -> None: ) -> None:
await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) 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) @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), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
chat = await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) 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) 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), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
chat = await join_chat_by_invite_for_user(db, user_id=current_user.id, payload=payload) 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) 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 pydantic import BaseModel, ConfigDict, Field
from app.chats.models import ChatMemberRole, ChatType from app.chats.models import ChatMemberRole, ChatType
from app.messages.models import MessageType
class ChatRead(BaseModel): class ChatRead(BaseModel):
@@ -29,6 +30,9 @@ class ChatRead(BaseModel):
counterpart_username: str | None = None counterpart_username: str | None = None
counterpart_is_online: bool | None = None counterpart_is_online: bool | None = None
counterpart_last_seen_at: datetime | 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 my_role: ChatMemberRole | None = None
created_at: datetime 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 subscribers_count = members_count
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id) 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) 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) archived = bool(user_setting and user_setting.archived)
pinned = bool(user_setting and user_setting.pinned) 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_username": counterpart_username,
"counterpart_is_online": counterpart_is_online, "counterpart_is_online": counterpart_is_online,
"counterpart_last_seen_at": counterpart_last_seen_at, "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, "my_role": my_role,
"created_at": chat.created_at, "created_at": chat.created_at,
} }

View File

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

View File

@@ -157,6 +157,7 @@ async def list_attachments_for_chat(
id=attachment.id, id=attachment.id,
message_id=attachment.message_id, message_id=attachment.message_id,
sender_id=message.sender_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, message_created_at=message.created_at,
file_url=attachment.file_url, file_url=attachment.file_url,
file_type=attachment.file_type, file_type=attachment.file_type,

View File

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

View File

@@ -144,6 +144,24 @@ class RealtimeGateway:
async def load_user_chat_ids(self, db: AsyncSession, user_id: int) -> list[int]: 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) 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: async def _handle_redis_event(self, channel: str, payload: dict) -> None:
chat_id = self._extract_chat_id(channel) chat_id = self._extract_chat_id(channel)
if chat_id is None: 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 { ToastViewport } from "../components/ToastViewport";
import { AuthPage } from "../pages/AuthPage"; import { AuthPage } from "../pages/AuthPage";
import { ChatsPage } from "../pages/ChatsPage"; import { ChatsPage } from "../pages/ChatsPage";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
const PENDING_INVITE_TOKEN_KEY = "bm_pending_invite_token";
export function App() { export function App() {
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
@@ -10,6 +14,9 @@ export function App() {
const loadMe = useAuthStore((s) => s.loadMe); const loadMe = useAuthStore((s) => s.loadMe);
const refresh = useAuthStore((s) => s.refresh); const refresh = useAuthStore((s) => s.refresh);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const loadChats = useChatStore((s) => s.loadChats);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const [joiningInvite, setJoiningInvite] = useState(false);
useEffect(() => { useEffect(() => {
if (!accessToken) { if (!accessToken) {
@@ -25,6 +32,36 @@ export function App() {
}); });
}, [accessToken, loadMe, refresh, logout]); }, [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) { if (!accessToken || !me) {
return <AuthPage />; 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_username?: string | null;
counterpart_is_online?: boolean | null; counterpart_is_online?: boolean | null;
counterpart_last_seen_at?: string | 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; my_role?: ChatMemberRole | null;
created_at: string; created_at: string;
} }
@@ -102,6 +105,7 @@ export interface ChatAttachment {
id: number; id: number;
message_id: number; message_id: number;
sender_id: number; sender_id: number;
message_type: MessageType;
message_created_at: string; message_created_at: string;
file_url: string; file_url: string;
file_type: string; file_type: string;

View File

@@ -4,6 +4,7 @@ import {
addChatMember, addChatMember,
createInviteLink, createInviteLink,
getChatAttachments, getChatAttachments,
getMessages,
getChatNotificationSettings, getChatNotificationSettings,
getChatDetail, getChatDetail,
leaveChat, leaveChat,
@@ -14,7 +15,7 @@ import {
updateChatTitle updateChatTitle
} from "../api/chats"; } from "../api/chats";
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users"; 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 { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore"; import { useUiStore } from "../store/uiStore";
@@ -29,6 +30,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const me = useAuthStore((s) => s.me); const me = useAuthStore((s) => s.me);
const loadChats = useChatStore((s) => s.loadChats); const loadChats = useChatStore((s) => s.loadChats);
const setActiveChatId = useChatStore((s) => s.setActiveChatId); const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
const showToast = useUiStore((s) => s.showToast); const showToast = useUiStore((s) => s.showToast);
const [chat, setChat] = useState<ChatDetail | null>(null); const [chat, setChat] = useState<ChatDetail | null>(null);
const [members, setMembers] = useState<ChatMember[]>([]); const [members, setMembers] = useState<ChatMember[]>([]);
@@ -46,22 +48,29 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]); const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [attachmentsLoading, setAttachmentsLoading] = useState(false); const [attachmentsLoading, setAttachmentsLoading] = useState(false);
const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string } | null>(null); const [linkItems, setLinkItems] = useState<Array<{ url: string; messageId: number; createdAt: string }>>([]);
const [attachmentsTab, setAttachmentsTab] = useState<"media" | "files">("media"); 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 isGroupLike = chat?.type === "group" || chat?.type === "channel";
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved); const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin")); const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
const canChangeRoles = Boolean(isGroupLike && myRole === "owner"); const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")), [attachments]);
const mediaAttachments = useMemo( const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")), [attachments]);
() => attachments.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/")), const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice"), [attachments]);
[attachments] const audioAttachments = useMemo(
); () => attachments.filter((item) => item.message_type === "audio" || (item.file_type.startsWith("audio/") && item.message_type !== "voice")),
const fileAttachments = useMemo(
() => attachments.filter((item) => !item.file_type.startsWith("image/") && !item.file_type.startsWith("video/")),
[attachments] [attachments]
); );
const allAttachmentItems = useMemo(() => [...attachments].sort((a, b) => b.id - a.id), [attachments]);
async function refreshMembers(targetChatId: number) { async function refreshMembers(targetChatId: number) {
const nextMembers = await listChatMembers(targetChatId); const nextMembers = await listChatMembers(targetChatId);
@@ -75,6 +84,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
setMemberUsers(byId); setMemberUsers(byId);
} }
function jumpToMessage(messageId: number) {
if (!chatId) {
return;
}
setActiveChatId(chatId);
setFocusedMessage(chatId, messageId);
onClose();
}
useEffect(() => { useEffect(() => {
if (!open || !chatId) { if (!open || !chatId) {
return; return;
@@ -109,8 +127,10 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
} }
await refreshMembers(chatId); await refreshMembers(chatId);
const chatAttachments = await getChatAttachments(chatId, 120); const chatAttachments = await getChatAttachments(chatId, 120);
const messages = await getRecentMessagesForLinks(chatId);
if (!cancelled) { if (!cancelled) {
setAttachments(chatAttachments); setAttachments(chatAttachments);
setLinkItems(extractLinkItems(messages));
} }
} catch { } catch {
if (!cancelled) setError("Failed to load chat info"); if (!cancelled) setError("Failed to load chat info");
@@ -126,6 +146,17 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
}; };
}, [open, chatId]); }, [open, chatId]);
useEffect(() => {
if (!open) {
return;
}
setInviteLink(null);
setMembers([]);
setMemberUsers({});
setSearchQuery("");
setSearchResults([]);
}, [chatId, open]);
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === "Escape") {
@@ -143,14 +174,22 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
} }
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-[120] bg-slate-950/55" className="fixed inset-0 z-[120] bg-slate-950/55"
onClick={() => { onClick={() => {
setAttachmentCtx(null); setAttachmentCtx(null);
setMemberCtx(null);
onClose(); 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"> <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> <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> <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"> <div className="tg-scrollbar max-h-56 space-y-2 overflow-auto">
{members.map((member) => { {members.map((member) => {
const user = memberUsers[member.user_id]; 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 ( return (
<div className="rounded border border-slate-700/60 bg-slate-900/60 p-2" key={member.id}> <button
<p className="truncate text-sm font-semibold">{user?.name || `user #${member.user_id}`}</p> 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> <p className="truncate text-xs text-slate-400">@{user?.username || "unknown"}</p>
<div className="mt-2 flex items-center gap-2"> {canOpenMemberMenu ? <p className="mt-1 text-[11px] text-slate-500">Right click for actions</p> : null}
<select </button>
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>
); );
})} })}
</div> </div>
@@ -358,37 +388,74 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</p> </p>
<div className="mb-2 flex items-center gap-2 border-b border-slate-700/60 pb-2 text-xs"> <div className="mb-2 flex items-center gap-2 border-b border-slate-700/60 pb-2 text-xs">
<button <button
className={`rounded px-2 py-1 ${attachmentsTab === "media" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`} className={`rounded px-2 py-1 ${attachmentsTab === "all" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("media")} onClick={() => setAttachmentsTab("all")}
type="button" type="button"
> >
Media All
</button> </button>
<button <button
className={`rounded px-2 py-1 ${attachmentsTab === "files" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`} className={`rounded px-2 py-1 ${attachmentsTab === "photos" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("files")} onClick={() => setAttachmentsTab("photos")}
type="button" 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> </button>
</div> </div>
{attachmentsLoading ? <p className="text-xs text-slate-400">Loading attachments...</p> : null} {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 && 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"> <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 <button
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900" className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
key={`media-item-${item.id}`} 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) => { onContextMenu={(event) => {
event.preventDefault(); event.preventDefault();
setAttachmentCtx({ setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190), x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120), y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url url: item.file_url,
messageId: item.message_id,
}); });
}} }}
type="button" type="button"
@@ -403,19 +470,23 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</div> </div>
</> </>
) : null} ) : null}
{!attachmentsLoading && attachmentsTab === "files" ? ( {!attachmentsLoading && (attachmentsTab === "all" || attachmentsTab === "audio" || attachmentsTab === "voice") ? (
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto"> <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 <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" 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}`} key={`file-item-${item.id}`}
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")} onClick={() => jumpToMessage(item.message_id)}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault(); event.preventDefault();
setAttachmentCtx({ setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190), x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120), y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url url: item.file_url,
messageId: item.message_id,
}); });
}} }}
type="button" type="button"
@@ -428,6 +499,31 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
))} ))}
</div> </div>
) : null} ) : 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> </div>
{chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? ( {chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? (
@@ -504,6 +600,142 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
> >
Copy link Copy link
</button> </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> </div>
) : null} ) : null}
</div>, </div>,
@@ -548,3 +780,44 @@ function attachmentKind(fileType: string): string {
if (fileType === "application/pdf") return "PDF"; if (fileType === "application/pdf") return "PDF";
return "File"; 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 { useEffect, useState } from "react";
import { createPortal } from "react-dom"; 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 { globalSearch } from "../api/search";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { updateMyProfile } from "../api/users"; import { updateMyProfile } from "../api/users";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { NewChatPanel } from "./NewChatPanel"; import { NewChatPanel } from "./NewChatPanel";
import { SettingsPanel } from "./SettingsPanel";
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
export function ChatList() { export function ChatList() {
const chats = useChatStore((s) => s.chats); const chats = useChatStore((s) => s.chats);
const messagesByChat = useChatStore((s) => s.messagesByChat);
const activeChatId = useChatStore((s) => s.activeChatId); const activeChatId = useChatStore((s) => s.activeChatId);
const setActiveChatId = useChatStore((s) => s.setActiveChatId); const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage); const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
@@ -28,6 +29,8 @@ export function ChatList() {
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null); const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
const [deleteForAll, setDeleteForAll] = useState(false); const [deleteForAll, setDeleteForAll] = useState(false);
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [profileName, setProfileName] = useState(""); const [profileName, setProfileName] = useState("");
const [profileUsername, setProfileUsername] = useState(""); const [profileUsername, setProfileUsername] = useState("");
const [profileBio, setProfileBio] = useState(""); const [profileBio, setProfileBio] = useState("");
@@ -119,6 +122,10 @@ export function ChatList() {
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, []); }, []);
useEffect(() => {
applyAppearancePreferences(getAppPreferences());
}, []);
useEffect(() => { useEffect(() => {
if (!me) { if (!me) {
return; return;
@@ -130,6 +137,12 @@ export function ChatList() {
setProfileAllowPrivateMessages(me.allow_private_messages ?? true); setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
}, [me]); }, [me]);
async function openSavedMessages() {
const saved = await getSavedMessagesChat();
const updatedChats = await getChats();
useChatStore.setState({ chats: updatedChats, activeChatId: saved.id });
}
const filteredChats = chats.filter((chat) => { const filteredChats = chats.filter((chat) => {
if (chat.archived) { if (chat.archived) {
return false; return false;
@@ -157,10 +170,29 @@ export function ChatList() {
const visibleChats = tab === "archived" ? archivedChats : filteredChats; const visibleChats = tab === "archived" ? archivedChats : filteredChats;
return ( 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="border-b border-slate-700/50 px-3 py-3">
<div className="mb-2 flex items-center gap-2"> <div className="relative mb-2 flex items-center gap-2">
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs"></button> <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"> <label className="block flex-1">
<input <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" 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="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"> <div className="relative mt-0.5">
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)} <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>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -294,11 +331,11 @@ export function ChatList() {
</span> </span>
) : ( ) : (
<span className="shrink-0 text-[11px] text-slate-400"> <span className="shrink-0 text-[11px] text-slate-400">
{messagesByChat[chat.id]?.length ? "now" : ""} {formatChatListTime(chat.last_message_created_at)}
</span> </span>
)} )}
</div> </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>
</div> </div>
</button> </button>
@@ -468,6 +505,7 @@ export function ChatList() {
</div> </div>
</div> </div>
) : null} ) : null}
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</aside> </aside>
); );
} }
@@ -520,6 +558,50 @@ function chatMetaLabel(chat: {
return `${subscribers} subscribers`; 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 { function formatLastSeen(value: string): string {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {

View File

@@ -3,6 +3,7 @@ import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresigne
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { buildWsUrl } from "../utils/ws"; import { buildWsUrl } from "../utils/ws";
import { getAppPreferences } from "../utils/preferences";
type RecordingState = "idle" | "recording" | "locked"; type RecordingState = "idle" | "recording" | "locked";
@@ -149,7 +150,19 @@ export function MessageComposer() {
} }
function onComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) { 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(); event.preventDefault();
void handleSend(); 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 { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import type { Message } from "../chat/types"; import type { Message } from "../chat/types";
import { getAppPreferences } from "../utils/preferences";
import { buildWsUrl } from "../utils/ws"; import { buildWsUrl } from "../utils/ws";
interface RealtimeEnvelope { interface RealtimeEnvelope {
@@ -21,6 +22,7 @@ export function useRealtime() {
const lastPongAtRef = useRef<number>(Date.now()); const lastPongAtRef = useRef<number>(Date.now());
const reconnectAttemptsRef = useRef(0); const reconnectAttemptsRef = useRef(0);
const manualCloseRef = useRef(false); const manualCloseRef = useRef(false);
const notificationPermissionRequestedRef = useRef(false);
const wsUrl = useMemo(() => { const wsUrl = useMemo(() => {
return accessToken ? buildWsUrl(accessToken) : null; return accessToken ? buildWsUrl(accessToken) : null;
@@ -67,6 +69,10 @@ export function useRealtime() {
if (store.activeChatId) { if (store.activeChatId) {
void store.loadMessages(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) => { ws.onmessage = (messageEvent) => {
@@ -99,11 +105,21 @@ export function useRealtime() {
} else if (wasInserted) { } else if (wasInserted) {
chatStore.incrementUnread(chatId); chatStore.incrementUnread(chatId);
} }
maybeShowBrowserNotification(chatId, message, chatStore.activeChatId);
} }
if (!chatStore.chats.some((chat) => chat.id === chatId)) { if (!chatStore.chats.some((chat) => chat.id === chatId)) {
void chatStore.loadChats(); 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") { if (event.event === "pong") {
lastPongAtRef.current = Date.now(); lastPongAtRef.current = Date.now();
} }
@@ -216,3 +232,49 @@ export function useRealtime() {
return null; 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 { body {
margin: 0; margin: 0;
font-family: "Manrope", "Segoe UI", sans-serif; font-family: "Manrope", "Segoe UI", sans-serif;
font-size: var(--bm-font-size, 16px);
background: background:
radial-gradient(circle at 22% 18%, rgba(36, 68, 117, 0.35), transparent 26%), 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%), 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"}