Compare commits
8 Commits
74d9163dde
...
bc483afd78
| Author | SHA1 | Date | |
|---|---|---|---|
| bc483afd78 | |||
| 76ab9c72f5 | |||
| f1b2e47df8 | |||
| d74e2c08c1 | |||
| eef89983e0 | |||
| 874f9da12c | |||
| 71d0472337 | |||
| df79a70baf |
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
28
alembic/versions/0012_user_private_message_privacy.py
Normal file
28
alembic/versions/0012_user_private_message_privacy.py
Normal 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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
37
app/main.py
37
app/main.py
@@ -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)
|
||||
|
||||
@@ -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
2
app/search/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
__all__ = []
|
||||
|
||||
30
app/search/router.py
Normal file
30
app/search/router.py
Normal 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
12
app/search/schemas.py
Normal 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
82
app/search/service.py
Normal 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,
|
||||
)
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
12
docker/backend-entrypoint.sh
Normal file
12
docker/backend-entrypoint.sh
Normal 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 "$@"
|
||||
14
web/src/api/notifications.ts
Normal file
14
web/src/api/notifications.ts
Normal 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
16
web/src/api/search.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user