Compare commits

...

8 Commits

Author SHA1 Message Date
bc483afd78 feat(search): add unified global search for users/chats/messages
Some checks failed
CI / test (push) Failing after 24s
2026-03-08 09:41:20 +03:00
76ab9c72f5 feat(privacy): add private-message permission toggle 2026-03-08 02:56:58 +03:00
f1b2e47df8 feat(notifications): add in-app notification center panel
- add notifications API client
- add notifications modal in chat page header
- show recent notification events with timestamps and count badge
2026-03-08 02:54:16 +03:00
d74e2c08c1 feat(drafts): persist chat drafts in localStorage
- load drafts from localStorage on startup
- write drafts to localStorage on update/clear
- keep per-chat draft restore across page reload
2026-03-08 02:53:32 +03:00
eef89983e0 feat(search): focus and highlight found message in chat
- store focused message id per chat
- scroll to target message and highlight it after search selection
- clear focus automatically after short timeout
2026-03-08 02:53:03 +03:00
874f9da12c fix(realtime): resync active chat messages on websocket reconnect
- on ws reconnect, reload chats and active chat history
- reduce status/history drift after transient disconnects
2026-03-08 02:52:26 +03:00
71d0472337 feat(web-chat): add message history pagination
- add loadMoreMessages with before_id cursor in chat store
- track hasMore/loading state per chat
- add 'Load older messages' control in message list
2026-03-08 02:52:01 +03:00
df79a70baf chore(prod): startup migrations, readiness checks and backend healthcheck
- add backend entrypoint that can run alembic upgrade head on startup
- add RUN_MIGRATIONS_ON_STARTUP setting and compose wiring
- add /health/live and /health/ready endpoints with db+redis checks
- add backend container healthcheck against readiness endpoint
- document readiness and startup migration behavior
2026-03-08 02:50:57 +03:00
27 changed files with 526 additions and 27 deletions

View File

@@ -9,7 +9,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x /app/docker/backend-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/app/docker/backend-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -38,12 +38,17 @@ Run full stack (web + api + worker + postgres + redis + minio + mailpit):
1. cp .env.docker.example .env
2. edit `.env` (`SECRET_KEY`, passwords, domain, `S3_PUBLIC_ENDPOINT_URL`)
3. docker compose up -d --build
2. Open:
4. check backend readiness:
- `http://localhost:8000/health/live`
- `http://localhost:8000/health/ready`
5. Open:
- Web: http://localhost
- API docs: http://localhost:8000/docs
- Mailpit UI: http://localhost:8025
- MinIO console: http://localhost:9001
`RUN_MIGRATIONS_ON_STARTUP=true` (default in compose) runs `alembic upgrade head` before backend start.
### Production Mode
Use production override to close internal ports (postgres/redis/minio/mailpit/backend):

View File

@@ -0,0 +1,28 @@
"""add allow_private_messages setting
Revision ID: 0012_user_private_message_privacy
Revises: 0011_chat_public_id
Create Date: 2026-03-08 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0012_user_private_message_privacy"
down_revision: Union[str, Sequence[str], None] = "0011_chat_public_id"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("allow_private_messages", sa.Boolean(), nullable=False, server_default=sa.text("true")),
)
def downgrade() -> None:
op.drop_column("users", "allow_private_messages")

View File

@@ -104,6 +104,12 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch
detail="Private chat requires exactly one target user.",
)
if payload.type == ChatType.PRIVATE:
target_user = await get_user_by_id(db, member_ids[0])
if target_user and not target_user.allow_private_messages:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User does not accept private messages",
)
if await has_block_relation_between_users(db, user_a_id=creator_id, user_b_id=member_ids[0]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,

View File

@@ -8,6 +8,7 @@ class Settings(BaseSettings):
debug: bool = True
api_v1_prefix: str = "/api/v1"
auto_create_tables: bool = True
run_migrations_on_startup: bool = False
secret_key: str = Field(default="change-me-please-12345", min_length=16)
access_token_expire_minutes: int = 30

View File

@@ -1,6 +1,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, status
from sqlalchemy import text
from app.auth.router import router as auth_router
from app.chats.router import router as chats_router
@@ -12,9 +13,10 @@ from app.media.router import router as media_router
from app.messages.router import router as messages_router
from app.notifications.router import router as notifications_router
from app.realtime.router import router as realtime_router
from app.search.router import router as search_router
from app.realtime.service import realtime_gateway
from app.users.router import router as users_router
from app.utils.redis_client import close_redis_client
from app.utils.redis_client import close_redis_client, get_redis_client
@asynccontextmanager
@@ -36,6 +38,36 @@ async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/health/live", tags=["health"])
async def health_live() -> dict[str, str]:
return {"status": "ok"}
@app.get("/health/ready", tags=["health"])
async def health_ready() -> dict[str, str]:
db_ok = False
redis_ok = False
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception:
db_ok = False
try:
redis = get_redis_client()
pong = await redis.ping()
redis_ok = bool(pong)
except Exception:
redis_ok = False
if not db_ok or not redis_ok:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"status": "not_ready", "db": db_ok, "redis": redis_ok},
)
return {"status": "ready", "db": "ok", "redis": "ok"}
app.include_router(auth_router, prefix=settings.api_v1_prefix)
app.include_router(users_router, prefix=settings.api_v1_prefix)
app.include_router(chats_router, prefix=settings.api_v1_prefix)
@@ -43,3 +75,4 @@ app.include_router(messages_router, prefix=settings.api_v1_prefix)
app.include_router(media_router, prefix=settings.api_v1_prefix)
app.include_router(notifications_router, prefix=settings.api_v1_prefix)
app.include_router(realtime_router, prefix=settings.api_v1_prefix)
app.include_router(search_router, prefix=settings.api_v1_prefix)

View File

@@ -11,6 +11,7 @@ from app.messages.spam_guard import enforce_message_spam_policy
from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageStatusUpdateRequest, MessageUpdateRequest
from app.notifications.service import dispatch_message_notifications
from app.users.repository import has_block_relation_between_users
from app.users.service import get_user_by_id
async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message:
@@ -27,6 +28,10 @@ async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: Mess
counterpart_id = await chats_repository.get_private_counterpart_user_id(db, chat_id=payload.chat_id, user_id=sender_id)
if counterpart_id and await has_block_relation_between_users(db, user_a_id=sender_id, user_b_id=counterpart_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send message due to block settings")
if counterpart_id:
counterpart = await get_user_by_id(db, counterpart_id)
if counterpart and not counterpart.allow_private_messages:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User does not accept private messages")
if payload.reply_to_message_id is not None:
reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id)
if not reply_to or reply_to.chat_id != payload.chat_id:

2
app/search/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
__all__ = []

30
app/search/router.py Normal file
View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import get_current_user
from app.database.session import get_db
from app.search.schemas import GlobalSearchRead
from app.search.service import global_search
from app.users.models import User
router = APIRouter(prefix="/search", tags=["search"])
@router.get("", response_model=GlobalSearchRead)
async def global_search_endpoint(
query: str,
users_limit: int = 10,
chats_limit: int = 10,
messages_limit: int = 10,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> GlobalSearchRead:
return await global_search(
db,
user_id=current_user.id,
query=query,
users_limit=users_limit,
chats_limit=chats_limit,
messages_limit=messages_limit,
)

12
app/search/schemas.py Normal file
View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
from app.chats.schemas import ChatDiscoverRead
from app.messages.schemas import MessageRead
from app.users.schemas import UserSearchRead
class GlobalSearchRead(BaseModel):
users: list[UserSearchRead]
chats: list[ChatDiscoverRead]
messages: list[MessageRead]

82
app/search/service.py Normal file
View File

@@ -0,0 +1,82 @@
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import Chat
from app.chats import repository as chats_repository
from app.chats.schemas import ChatDiscoverRead
from app.chats.service import serialize_chat_for_user
from app.messages.service import search_messages
from app.search.schemas import GlobalSearchRead
from app.users.service import search_users_by_username
async def global_search(
db: AsyncSession,
*,
user_id: int,
query: str,
users_limit: int = 10,
chats_limit: int = 10,
messages_limit: int = 10,
) -> GlobalSearchRead:
normalized = query.strip()
if len(normalized.lstrip("@")) < 2:
return GlobalSearchRead(users=[], chats=[], messages=[])
users_task = search_users_by_username(
db,
query=normalized,
limit=max(1, min(users_limit, 50)),
exclude_user_id=user_id,
)
messages_task = search_messages(
db,
user_id=user_id,
query=normalized,
chat_id=None,
limit=max(1, min(messages_limit, 50)),
)
users, messages = await asyncio.gather(users_task, messages_task)
# Combine own chats and discoverable public chats into one chat result list.
own_chats = await chats_repository.list_user_chats(
db,
user_id=user_id,
limit=max(1, min(chats_limit, 50)),
query=normalized,
)
own_chat_ids = {chat.id for chat in own_chats}
discovered_rows = await chats_repository.discover_public_chats(
db,
user_id=user_id,
query=normalized,
limit=max(1, min(chats_limit, 50)),
)
merged_chats: list[tuple[Chat, bool]] = [(chat, True) for chat in own_chats]
for chat, is_member in discovered_rows:
if chat.id in own_chat_ids:
continue
merged_chats.append((chat, is_member))
if len(merged_chats) >= max(1, min(chats_limit, 50)):
break
chats: list[ChatDiscoverRead] = []
for chat, is_member in merged_chats:
serialized = await serialize_chat_for_user(db, user_id=user_id, chat=chat)
chats.append(
ChatDiscoverRead.model_validate(
{
**serialized.model_dump(),
"is_member": bool(is_member),
}
)
)
return GlobalSearchRead(
users=users,
chats=chats,
messages=messages,
)

View File

@@ -23,6 +23,7 @@ class User(Base):
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
bio: Mapped[str | None] = mapped_column(String(500), nullable=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
allow_private_messages: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),

View File

@@ -59,6 +59,7 @@ async def update_profile(
username=payload.username,
bio=payload.bio,
avatar_url=payload.avatar_url,
allow_private_messages=payload.allow_private_messages,
)
return updated

View File

@@ -20,6 +20,7 @@ class UserRead(UserBase):
avatar_url: str | None = None
bio: str | None = None
email_verified: bool
allow_private_messages: bool
created_at: datetime
updated_at: datetime
@@ -29,6 +30,7 @@ class UserProfileUpdate(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=50)
bio: str | None = Field(default=None, max_length=500)
avatar_url: str | None = Field(default=None, max_length=512)
allow_private_messages: bool | None = None
class UserSearchRead(BaseModel):

View File

@@ -40,6 +40,7 @@ async def update_user_profile(
username: str | None = None,
bio: str | None = None,
avatar_url: str | None = None,
allow_private_messages: bool | None = None,
) -> User:
if name is not None:
user.name = name
@@ -49,6 +50,8 @@ async def update_user_profile(
user.bio = bio
if avatar_url is not None:
user.avatar_url = avatar_url
if allow_private_messages is not None:
user.allow_private_messages = allow_private_messages
await db.commit()
await db.refresh(user)
return user

View File

@@ -110,8 +110,14 @@ services:
condition: service_completed_successfully
environment:
<<: *app-env
RUN_MIGRATIONS_ON_STARTUP: ${RUN_MIGRATIONS_ON_STARTUP:-true}
ports:
- "${BACKEND_PORT:-8000}:8000"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready').read()\""]
interval: 10s
timeout: 5s
retries: 12
worker:
build:
@@ -127,6 +133,7 @@ services:
environment:
<<: *app-env
AUTO_CREATE_TABLES: false
RUN_MIGRATIONS_ON_STARTUP: false
mailpit:
image: axllent/mailpit:latest

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu
echo "[entrypoint] starting backend container"
if [ "${RUN_MIGRATIONS_ON_STARTUP:-false}" = "true" ]; then
echo "[entrypoint] running alembic migrations"
alembic upgrade head
fi
echo "[entrypoint] launching application"
exec "$@"

View File

@@ -0,0 +1,14 @@
import { http } from "./http";
export interface NotificationItem {
id: number;
user_id: number;
event_type: string;
payload: string;
created_at: string;
}
export async function getNotifications(limit = 30): Promise<NotificationItem[]> {
const { data } = await http.get<NotificationItem[]>("/notifications", { params: { limit } });
return data;
}

16
web/src/api/search.ts Normal file
View File

@@ -0,0 +1,16 @@
import { http } from "./http";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
export interface GlobalSearchResponse {
users: UserSearchItem[];
chats: DiscoverChat[];
messages: Message[];
}
export async function globalSearch(query: string): Promise<GlobalSearchResponse> {
const { data } = await http.get<GlobalSearchResponse>("/search", {
params: { query, users_limit: 10, chats_limit: 10, messages_limit: 10 }
});
return data;
}

View File

@@ -13,6 +13,7 @@ interface UserProfileUpdatePayload {
username?: string;
bio?: string | null;
avatar_url?: string | null;
allow_private_messages?: boolean;
}
export async function updateMyProfile(payload: UserProfileUpdatePayload): Promise<AuthUser> {

View File

@@ -66,6 +66,7 @@ export interface AuthUser {
bio?: string | null;
avatar_url: string | null;
email_verified: boolean;
allow_private_messages: boolean;
created_at: string;
updated_at: string;
}

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { clearChat, createPrivateChat, discoverChats, deleteChat, getChats, joinChat } from "../api/chats";
import { searchUsers } from "../api/users";
import type { DiscoverChat, UserSearchItem } from "../chat/types";
import { clearChat, createPrivateChat, deleteChat, getChats, joinChat } 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";
@@ -13,11 +13,13 @@ export function ChatList() {
const messagesByChat = useChatStore((s) => s.messagesByChat);
const activeChatId = useChatStore((s) => s.activeChatId);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
const loadChats = useChatStore((s) => s.loadChats);
const me = useAuthStore((s) => s.me);
const [search, setSearch] = useState("");
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [messageResults, setMessageResults] = useState<Message[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
@@ -29,6 +31,7 @@ export function ChatList() {
const [profileUsername, setProfileUsername] = useState("");
const [profileBio, setProfileBio] = useState("");
const [profileAvatarUrl, setProfileAvatarUrl] = useState("");
const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true);
const [profileError, setProfileError] = useState<string | null>(null);
const [profileSaving, setProfileSaving] = useState(false);
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
@@ -39,17 +42,15 @@ export function ChatList() {
);
useEffect(() => {
const timer = setTimeout(() => {
void loadChats(search.trim() ? search : undefined);
}, 250);
return () => clearTimeout(timer);
}, [search, loadChats]);
void loadChats();
}, [loadChats]);
useEffect(() => {
const term = search.trim();
if (term.replace("@", "").length < 2) {
setUserResults([]);
setDiscoverResults([]);
setMessageResults([]);
setSearchLoading(false);
return;
}
@@ -57,16 +58,18 @@ export function ChatList() {
setSearchLoading(true);
void (async () => {
try {
const [users, publicChats] = await Promise.all([searchUsers(term), discoverChats(term)]);
const result = await globalSearch(term);
if (cancelled) {
return;
}
setUserResults(users);
setDiscoverResults(publicChats);
setUserResults(result.users);
setDiscoverResults(result.chats);
setMessageResults(result.messages);
} catch {
if (!cancelled) {
setUserResults([]);
setDiscoverResults([]);
setMessageResults([]);
}
} finally {
if (!cancelled) {
@@ -101,6 +104,7 @@ export function ChatList() {
setProfileUsername(me.username || "");
setProfileBio(me.bio || "");
setProfileAvatarUrl(me.avatar_url || "");
setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
}, [me]);
const filteredChats = chats.filter((chat) => {
@@ -154,7 +158,7 @@ export function ChatList() {
{search.trim().replace("@", "").length >= 2 ? (
<div className="mt-3 rounded-xl border border-slate-700/70 bg-slate-900/70 p-2">
{searchLoading ? <p className="px-2 py-1 text-xs text-slate-400">Searching...</p> : null}
{!searchLoading && userResults.length === 0 && discoverResults.length === 0 ? (
{!searchLoading && userResults.length === 0 && discoverResults.length === 0 && messageResults.length === 0 ? (
<p className="px-2 py-1 text-xs text-slate-400">Nothing found</p>
) : null}
{userResults.length > 0 ? (
@@ -171,6 +175,7 @@ export function ChatList() {
setSearch("");
setUserResults([]);
setDiscoverResults([]);
setMessageResults([]);
}}
>
<p className="truncate text-xs font-semibold">{user.name}</p>
@@ -195,6 +200,7 @@ export function ChatList() {
setSearch("");
setUserResults([]);
setDiscoverResults([]);
setMessageResults([]);
}}
>
<div className="min-w-0">
@@ -206,6 +212,28 @@ export function ChatList() {
))}
</div>
) : null}
{messageResults.length > 0 ? (
<div>
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">Messages</p>
{messageResults.slice(0, 5).map((message) => (
<button
className="block w-full rounded-lg px-2 py-1.5 text-left hover:bg-slate-800"
key={`message-${message.id}`}
onClick={async () => {
setActiveChatId(message.chat_id);
setFocusedMessage(message.chat_id, message.id);
setSearch("");
setUserResults([]);
setDiscoverResults([]);
setMessageResults([]);
}}
>
<p className="truncate text-[11px] text-slate-400">chat #{message.chat_id}</p>
<p className="truncate text-xs font-semibold">{message.text || "[media]"}</p>
</button>
))}
</div>
) : null}
</div>
) : null}
</div>
@@ -297,7 +325,7 @@ export function ChatList() {
return;
}
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
await loadChats(search.trim() ? search : undefined);
await loadChats();
if (activeChatId === deleteModalChatId) {
setActiveChatId(null);
}
@@ -323,6 +351,14 @@ export function ChatList() {
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Username" value={profileUsername} onChange={(e) => setProfileUsername(e.target.value.replace("@", ""))} />
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Bio (optional)" value={profileBio} onChange={(e) => setProfileBio(e.target.value)} />
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Avatar URL (optional)" value={profileAvatarUrl} onChange={(e) => setProfileAvatarUrl(e.target.value)} />
<label className="flex items-center gap-2 rounded bg-slate-800 px-3 py-2 text-sm">
<input
type="checkbox"
checked={profileAllowPrivateMessages}
onChange={(e) => setProfileAllowPrivateMessages(e.target.checked)}
/>
Allow private messages
</label>
</div>
{profileError ? <p className="mt-2 text-xs text-red-400">{profileError}</p> : null}
<div className="mt-3 flex gap-2">
@@ -337,10 +373,11 @@ export function ChatList() {
name: profileName.trim() || undefined,
username: profileUsername.trim() || undefined,
bio: profileBio.trim() || null,
avatar_url: profileAvatarUrl.trim() || null
avatar_url: profileAvatarUrl.trim() || null,
allow_private_messages: profileAllowPrivateMessages
});
useAuthStore.setState({ me: updated });
await loadChats(search.trim() ? search : undefined);
await loadChats();
setProfileOpen(false);
} catch {
setProfileError("Failed to update profile");

View File

@@ -24,7 +24,12 @@ export function MessageList() {
const activeChatId = useChatStore((s) => s.activeChatId);
const messagesByChat = useChatStore((s) => s.messagesByChat);
const typingByChat = useChatStore((s) => s.typingByChat);
const hasMoreByChat = useChatStore((s) => s.hasMoreByChat);
const loadingMoreByChat = useChatStore((s) => s.loadingMoreByChat);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const unreadBoundaryByChat = useChatStore((s) => s.unreadBoundaryByChat);
const focusedMessageIdByChat = useChatStore((s) => s.focusedMessageIdByChat);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
@@ -61,6 +66,9 @@ export function MessageList() {
}, [chats, forwardQuery]);
const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0;
const unreadBoundaryIndex = unreadBoundaryCount > 0 ? Math.max(0, messages.length - unreadBoundaryCount) : -1;
const focusedMessageId = activeChatId ? (focusedMessageIdByChat[activeChatId] ?? null) : null;
const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]);
const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]);
const selectedMessages = useMemo(
() => messages.filter((m) => selectedIds.has(m.id)),
[messages, selectedIds]
@@ -99,6 +107,19 @@ export function MessageList() {
return () => window.clearInterval(interval);
}, [pendingDelete]);
useEffect(() => {
if (!activeChatId || !focusedMessageId) {
return;
}
const element = document.getElementById(`message-${focusedMessageId}`);
if (!element) {
return;
}
element.scrollIntoView({ behavior: "smooth", block: "center" });
const timer = window.setTimeout(() => setFocusedMessage(activeChatId, null), 2500);
return () => window.clearTimeout(timer);
}, [activeChatId, focusedMessageId, messages.length, setFocusedMessage]);
if (!activeChatId) {
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
}
@@ -254,6 +275,17 @@ export function MessageList() {
</div>
) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
{hasMore ? (
<div className="mb-3 flex justify-center">
<button
className="rounded-full border border-slate-700/80 bg-slate-900/70 px-3 py-1 text-xs text-slate-300 hover:bg-slate-800 disabled:opacity-60"
disabled={isLoadingMore}
onClick={() => void loadMoreMessages(chatId)}
>
{isLoadingMore ? "Loading..." : "Load older messages"}
</button>
</div>
) : null}
{messages.map((message, messageIndex) => {
const own = message.sender_id === me?.id;
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
@@ -271,9 +303,10 @@ export function MessageList() {
) : null}
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
<div
id={`message-${message.id}`}
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
} ${isSelected ? "ring-2 ring-sky-400/80" : ""}`}
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
onClick={() => {
if (selectedIds.size > 0) {
toggleSelected(message.id);

View File

@@ -62,7 +62,11 @@ export function useRealtime() {
ws.close();
}
}, 15000);
void useChatStore.getState().loadChats();
const store = useChatStore.getState();
void store.loadChats();
if (store.activeChatId) {
void store.loadMessages(store.activeChatId);
}
};
ws.onmessage = (messageEvent) => {

View File

@@ -3,6 +3,7 @@ import { ChatList } from "../components/ChatList";
import { ChatInfoPanel } from "../components/ChatInfoPanel";
import { MessageComposer } from "../components/MessageComposer";
import { MessageList } from "../components/MessageList";
import { getNotifications, type NotificationItem } from "../api/notifications";
import { searchMessages } from "../api/chats";
import type { Message } from "../chat/types";
import { useRealtime } from "../hooks/useRealtime";
@@ -17,6 +18,7 @@ export function ChatsPage() {
const activeChatId = useChatStore((s) => s.activeChatId);
const chats = useChatStore((s) => s.chats);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
const loadMessages = useChatStore((s) => s.loadMessages);
const activeChat = chats.find((chat) => chat.id === activeChatId);
const isReadOnlyChannel = Boolean(activeChat && activeChat.type === "channel" && activeChat.my_role === "member");
@@ -25,6 +27,9 @@ export function ChatsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [searchLoading, setSearchLoading] = useState(false);
const [searchResults, setSearchResults] = useState<Message[]>([]);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loadingNotifications, setLoadingNotifications] = useState(false);
useRealtime();
@@ -71,6 +76,33 @@ export function ChatsPage() {
};
}, [searchOpen, searchQuery, activeChatId]);
useEffect(() => {
if (!notificationsOpen) {
return;
}
let cancelled = false;
setLoadingNotifications(true);
void (async () => {
try {
const items = await getNotifications(30);
if (!cancelled) {
setNotifications(items);
}
} catch {
if (!cancelled) {
setNotifications([]);
}
} finally {
if (!cancelled) {
setLoadingNotifications(false);
}
}
})();
return () => {
cancelled = true;
};
}, [notificationsOpen]);
return (
<main className="h-screen w-full p-2 text-text md:p-4">
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-4">
@@ -95,6 +127,15 @@ export function ChatsPage() {
</div>
</div>
<div className="flex items-center gap-2">
<button
className="relative rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
onClick={() => setNotificationsOpen(true)}
>
Notifications
{notifications.length > 0 ? (
<span className="ml-1 rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] text-slate-950">{notifications.length}</span>
) : null}
</button>
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
Search
</button>
@@ -147,6 +188,7 @@ export function ChatsPage() {
key={`search-msg-${message.id}`}
onClick={() => {
setActiveChatId(message.chat_id);
setFocusedMessage(message.chat_id, message.id);
setSearchOpen(false);
}}
>
@@ -159,6 +201,26 @@ export function ChatsPage() {
</div>
</div>
) : null}
{notificationsOpen ? (
<div className="fixed inset-0 z-[130] flex items-start justify-center bg-slate-950/60 p-3 md:p-6" onClick={() => setNotificationsOpen(false)}>
<div className="w-full max-w-xl rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">Notifications</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setNotificationsOpen(false)}>Close</button>
</div>
{loadingNotifications ? <p className="px-2 py-1 text-xs text-slate-400">Loading...</p> : null}
{!loadingNotifications && notifications.length === 0 ? <p className="px-2 py-1 text-xs text-slate-400">No notifications</p> : null}
<div className="tg-scrollbar max-h-[50vh] space-y-1 overflow-auto">
{notifications.map((item) => (
<div className="rounded-lg bg-slate-800/80 px-3 py-2" key={item.id}>
<p className="text-xs font-semibold text-slate-200">{item.event_type}</p>
<p className="text-[11px] text-slate-400">{new Date(item.created_at).toLocaleString()}</p>
</div>
))}
</div>
</div>
</div>
) : null}
</main>
);
}

View File

@@ -2,17 +2,57 @@ import { create } from "zustand";
import { getChats, getMessages, updateMessageStatus } from "../api/chats";
import type { Chat, DeliveryStatus, Message, MessageType } from "../chat/types";
const DRAFTS_STORAGE_KEY = "bm_drafts_v1";
function loadDraftsFromStorage(): Record<number, string> {
if (typeof window === "undefined") {
return {};
}
try {
const raw = window.localStorage.getItem(DRAFTS_STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as Record<string, string>;
const result: Record<number, string> = {};
for (const [key, value] of Object.entries(parsed)) {
const chatId = Number(key);
if (Number.isFinite(chatId) && typeof value === "string") {
result[chatId] = value;
}
}
return result;
} catch {
return {};
}
}
function saveDraftsToStorage(drafts: Record<number, string>): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(DRAFTS_STORAGE_KEY, JSON.stringify(drafts));
} catch {
return;
}
}
interface ChatState {
chats: Chat[];
activeChatId: number | null;
messagesByChat: Record<number, Message[]>;
draftsByChat: Record<number, string>;
hasMoreByChat: Record<number, boolean>;
loadingMoreByChat: Record<number, boolean>;
typingByChat: Record<number, number[]>;
replyToByChat: Record<number, Message | null>;
unreadBoundaryByChat: Record<number, number>;
focusedMessageIdByChat: Record<number, number | null>;
loadChats: (query?: string) => Promise<void>;
setActiveChatId: (chatId: number | null) => void;
loadMessages: (chatId: number) => Promise<void>;
loadMoreMessages: (chatId: number) => Promise<void>;
prependMessage: (chatId: number, message: Message) => boolean;
addOptimisticMessage: (params: {
chatId: number;
@@ -41,16 +81,20 @@ interface ChatState {
applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void;
setDraft: (chatId: number, text: string) => void;
clearDraft: (chatId: number) => void;
setFocusedMessage: (chatId: number, messageId: number | null) => void;
}
export const useChatStore = create<ChatState>((set, get) => ({
chats: [],
activeChatId: null,
messagesByChat: {},
draftsByChat: {},
draftsByChat: loadDraftsFromStorage(),
hasMoreByChat: {},
loadingMoreByChat: {},
typingByChat: {},
replyToByChat: {},
unreadBoundaryByChat: {},
focusedMessageIdByChat: {},
loadChats: async (query) => {
const chats = await getChats(query);
const currentActive = get().activeChatId;
@@ -71,6 +115,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
...state.unreadBoundaryByChat,
[chatId]: unreadCount
},
hasMoreByChat: {
...state.hasMoreByChat,
[chatId]: messages.length >= 50
},
loadingMoreByChat: {
...state.loadingMoreByChat,
[chatId]: false
},
chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, unread_count: 0 } : chat))
}));
const lastMessage = sorted[sorted.length - 1];
@@ -78,6 +130,43 @@ export const useChatStore = create<ChatState>((set, get) => ({
void updateMessageStatus(chatId, lastMessage.id, "message_read");
}
},
loadMoreMessages: async (chatId) => {
if (get().loadingMoreByChat[chatId]) {
return;
}
const existing = get().messagesByChat[chatId] ?? [];
if (!existing.length) {
await get().loadMessages(chatId);
return;
}
const oldestId = existing[0]?.id;
if (!oldestId) {
return;
}
set((state) => ({
loadingMoreByChat: { ...state.loadingMoreByChat, [chatId]: true }
}));
try {
const older = await getMessages(chatId, oldestId);
const olderSorted = [...older].reverse();
const knownIds = new Set(existing.map((m) => m.id));
const merged = [...olderSorted.filter((m) => !knownIds.has(m.id)), ...existing];
set((state) => ({
messagesByChat: {
...state.messagesByChat,
[chatId]: merged
},
hasMoreByChat: {
...state.hasMoreByChat,
[chatId]: older.length >= 50
}
}));
} finally {
set((state) => ({
loadingMoreByChat: { ...state.loadingMoreByChat, [chatId]: false }
}));
}
},
prependMessage: (chatId, message) => {
const old = get().messagesByChat[chatId] ?? [];
if (old.some((m) => m.id === message.id || (message.client_message_id && m.client_message_id === message.client_message_id))) {
@@ -278,12 +367,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
})
})),
setDraft: (chatId, text) =>
set((state) => ({
draftsByChat: {
set((state) => {
const nextDrafts = {
...state.draftsByChat,
[chatId]: text
}
})),
};
saveDraftsToStorage(nextDrafts);
return { draftsByChat: nextDrafts };
}),
clearDraft: (chatId) =>
set((state) => {
if (!(chatId in state.draftsByChat)) {
@@ -291,6 +382,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
const next = { ...state.draftsByChat };
delete next[chatId];
saveDraftsToStorage(next);
return { draftsByChat: next };
})
}),
setFocusedMessage: (chatId, messageId) =>
set((state) => ({
focusedMessageIdByChat: {
...state.focusedMessageIdByChat,
[chatId]: messageId
}
}))
}));

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/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/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/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/ws.ts"],"version":"5.9.2"}