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
|
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN chmod +x /app/docker/backend-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/docker/backend-entrypoint.sh"]
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
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
|
1. cp .env.docker.example .env
|
||||||
2. edit `.env` (`SECRET_KEY`, passwords, domain, `S3_PUBLIC_ENDPOINT_URL`)
|
2. edit `.env` (`SECRET_KEY`, passwords, domain, `S3_PUBLIC_ENDPOINT_URL`)
|
||||||
3. docker compose up -d --build
|
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
|
- Web: http://localhost
|
||||||
- API docs: http://localhost:8000/docs
|
- API docs: http://localhost:8000/docs
|
||||||
- Mailpit UI: http://localhost:8025
|
- Mailpit UI: http://localhost:8025
|
||||||
- MinIO console: http://localhost:9001
|
- MinIO console: http://localhost:9001
|
||||||
|
|
||||||
|
`RUN_MIGRATIONS_ON_STARTUP=true` (default in compose) runs `alembic upgrade head` before backend start.
|
||||||
|
|
||||||
### Production Mode
|
### Production Mode
|
||||||
|
|
||||||
Use production override to close internal ports (postgres/redis/minio/mailpit/backend):
|
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.",
|
detail="Private chat requires exactly one target user.",
|
||||||
)
|
)
|
||||||
if payload.type == ChatType.PRIVATE:
|
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]):
|
if await has_block_relation_between_users(db, user_a_id=creator_id, user_b_id=member_ids[0]):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class Settings(BaseSettings):
|
|||||||
debug: bool = True
|
debug: bool = True
|
||||||
api_v1_prefix: str = "/api/v1"
|
api_v1_prefix: str = "/api/v1"
|
||||||
auto_create_tables: bool = True
|
auto_create_tables: bool = True
|
||||||
|
run_migrations_on_startup: bool = False
|
||||||
|
|
||||||
secret_key: str = Field(default="change-me-please-12345", min_length=16)
|
secret_key: str = Field(default="change-me-please-12345", min_length=16)
|
||||||
access_token_expire_minutes: int = 30
|
access_token_expire_minutes: int = 30
|
||||||
|
|||||||
37
app/main.py
37
app/main.py
@@ -1,6 +1,7 @@
|
|||||||
from contextlib import asynccontextmanager
|
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.auth.router import router as auth_router
|
||||||
from app.chats.router import router as chats_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.messages.router import router as messages_router
|
||||||
from app.notifications.router import router as notifications_router
|
from app.notifications.router import router as notifications_router
|
||||||
from app.realtime.router import router as realtime_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.realtime.service import realtime_gateway
|
||||||
from app.users.router import router as users_router
|
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
|
@asynccontextmanager
|
||||||
@@ -36,6 +38,36 @@ async def health() -> dict[str, str]:
|
|||||||
return {"status": "ok"}
|
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(auth_router, prefix=settings.api_v1_prefix)
|
||||||
app.include_router(users_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)
|
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(media_router, prefix=settings.api_v1_prefix)
|
||||||
app.include_router(notifications_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(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.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageStatusUpdateRequest, MessageUpdateRequest
|
||||||
from app.notifications.service import dispatch_message_notifications
|
from app.notifications.service import dispatch_message_notifications
|
||||||
from app.users.repository import has_block_relation_between_users
|
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:
|
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)
|
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):
|
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")
|
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:
|
if payload.reply_to_message_id is not None:
|
||||||
reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id)
|
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:
|
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)
|
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
bio: Mapped[str | None] = mapped_column(String(500), 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)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ async def update_profile(
|
|||||||
username=payload.username,
|
username=payload.username,
|
||||||
bio=payload.bio,
|
bio=payload.bio,
|
||||||
avatar_url=payload.avatar_url,
|
avatar_url=payload.avatar_url,
|
||||||
|
allow_private_messages=payload.allow_private_messages,
|
||||||
)
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class UserRead(UserBase):
|
|||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
bio: str | None = None
|
bio: str | None = None
|
||||||
email_verified: bool
|
email_verified: bool
|
||||||
|
allow_private_messages: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ class UserProfileUpdate(BaseModel):
|
|||||||
username: str | None = Field(default=None, min_length=3, max_length=50)
|
username: str | None = Field(default=None, min_length=3, max_length=50)
|
||||||
bio: str | None = Field(default=None, max_length=500)
|
bio: str | None = Field(default=None, max_length=500)
|
||||||
avatar_url: str | None = Field(default=None, max_length=512)
|
avatar_url: str | None = Field(default=None, max_length=512)
|
||||||
|
allow_private_messages: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserSearchRead(BaseModel):
|
class UserSearchRead(BaseModel):
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ async def update_user_profile(
|
|||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
bio: str | None = None,
|
bio: str | None = None,
|
||||||
avatar_url: str | None = None,
|
avatar_url: str | None = None,
|
||||||
|
allow_private_messages: bool | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
if name is not None:
|
if name is not None:
|
||||||
user.name = name
|
user.name = name
|
||||||
@@ -49,6 +50,8 @@ async def update_user_profile(
|
|||||||
user.bio = bio
|
user.bio = bio
|
||||||
if avatar_url is not None:
|
if avatar_url is not None:
|
||||||
user.avatar_url = avatar_url
|
user.avatar_url = avatar_url
|
||||||
|
if allow_private_messages is not None:
|
||||||
|
user.allow_private_messages = allow_private_messages
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -110,8 +110,14 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
environment:
|
environment:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
|
RUN_MIGRATIONS_ON_STARTUP: ${RUN_MIGRATIONS_ON_STARTUP:-true}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
- "${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:
|
worker:
|
||||||
build:
|
build:
|
||||||
@@ -127,6 +133,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
AUTO_CREATE_TABLES: false
|
AUTO_CREATE_TABLES: false
|
||||||
|
RUN_MIGRATIONS_ON_STARTUP: false
|
||||||
|
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:latest
|
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;
|
username?: string;
|
||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
|
allow_private_messages?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMyProfile(payload: UserProfileUpdatePayload): Promise<AuthUser> {
|
export async function updateMyProfile(payload: UserProfileUpdatePayload): Promise<AuthUser> {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export interface AuthUser {
|
|||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
|
allow_private_messages: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { clearChat, createPrivateChat, discoverChats, deleteChat, getChats, joinChat } from "../api/chats";
|
import { clearChat, createPrivateChat, deleteChat, getChats, joinChat } from "../api/chats";
|
||||||
import { searchUsers } from "../api/users";
|
import { globalSearch } from "../api/search";
|
||||||
import type { DiscoverChat, 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";
|
||||||
@@ -13,11 +13,13 @@ export function ChatList() {
|
|||||||
const messagesByChat = useChatStore((s) => s.messagesByChat);
|
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 loadChats = useChatStore((s) => s.loadChats);
|
const loadChats = useChatStore((s) => s.loadChats);
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
|
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
|
||||||
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
||||||
|
const [messageResults, setMessageResults] = useState<Message[]>([]);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
|
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
|
||||||
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
|
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
|
||||||
@@ -29,6 +31,7 @@ export function ChatList() {
|
|||||||
const [profileUsername, setProfileUsername] = useState("");
|
const [profileUsername, setProfileUsername] = useState("");
|
||||||
const [profileBio, setProfileBio] = useState("");
|
const [profileBio, setProfileBio] = useState("");
|
||||||
const [profileAvatarUrl, setProfileAvatarUrl] = useState("");
|
const [profileAvatarUrl, setProfileAvatarUrl] = useState("");
|
||||||
|
const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true);
|
||||||
const [profileError, setProfileError] = useState<string | null>(null);
|
const [profileError, setProfileError] = useState<string | null>(null);
|
||||||
const [profileSaving, setProfileSaving] = useState(false);
|
const [profileSaving, setProfileSaving] = useState(false);
|
||||||
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
|
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
|
||||||
@@ -39,17 +42,15 @@ export function ChatList() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
void loadChats();
|
||||||
void loadChats(search.trim() ? search : undefined);
|
}, [loadChats]);
|
||||||
}, 250);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search, loadChats]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const term = search.trim();
|
const term = search.trim();
|
||||||
if (term.replace("@", "").length < 2) {
|
if (term.replace("@", "").length < 2) {
|
||||||
setUserResults([]);
|
setUserResults([]);
|
||||||
setDiscoverResults([]);
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -57,16 +58,18 @@ export function ChatList() {
|
|||||||
setSearchLoading(true);
|
setSearchLoading(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [users, publicChats] = await Promise.all([searchUsers(term), discoverChats(term)]);
|
const result = await globalSearch(term);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUserResults(users);
|
setUserResults(result.users);
|
||||||
setDiscoverResults(publicChats);
|
setDiscoverResults(result.chats);
|
||||||
|
setMessageResults(result.messages);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setUserResults([]);
|
setUserResults([]);
|
||||||
setDiscoverResults([]);
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -101,6 +104,7 @@ export function ChatList() {
|
|||||||
setProfileUsername(me.username || "");
|
setProfileUsername(me.username || "");
|
||||||
setProfileBio(me.bio || "");
|
setProfileBio(me.bio || "");
|
||||||
setProfileAvatarUrl(me.avatar_url || "");
|
setProfileAvatarUrl(me.avatar_url || "");
|
||||||
|
setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
|
||||||
}, [me]);
|
}, [me]);
|
||||||
|
|
||||||
const filteredChats = chats.filter((chat) => {
|
const filteredChats = chats.filter((chat) => {
|
||||||
@@ -154,7 +158,7 @@ export function ChatList() {
|
|||||||
{search.trim().replace("@", "").length >= 2 ? (
|
{search.trim().replace("@", "").length >= 2 ? (
|
||||||
<div className="mt-3 rounded-xl border border-slate-700/70 bg-slate-900/70 p-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 ? <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>
|
<p className="px-2 py-1 text-xs text-slate-400">Nothing found</p>
|
||||||
) : null}
|
) : null}
|
||||||
{userResults.length > 0 ? (
|
{userResults.length > 0 ? (
|
||||||
@@ -171,6 +175,7 @@ export function ChatList() {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setUserResults([]);
|
setUserResults([]);
|
||||||
setDiscoverResults([]);
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="truncate text-xs font-semibold">{user.name}</p>
|
<p className="truncate text-xs font-semibold">{user.name}</p>
|
||||||
@@ -195,6 +200,7 @@ export function ChatList() {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setUserResults([]);
|
setUserResults([]);
|
||||||
setDiscoverResults([]);
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -206,6 +212,28 @@ export function ChatList() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -297,7 +325,7 @@ export function ChatList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
||||||
await loadChats(search.trim() ? search : undefined);
|
await loadChats();
|
||||||
if (activeChatId === deleteModalChatId) {
|
if (activeChatId === deleteModalChatId) {
|
||||||
setActiveChatId(null);
|
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="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="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)} />
|
<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>
|
</div>
|
||||||
{profileError ? <p className="mt-2 text-xs text-red-400">{profileError}</p> : null}
|
{profileError ? <p className="mt-2 text-xs text-red-400">{profileError}</p> : null}
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
@@ -337,10 +373,11 @@ export function ChatList() {
|
|||||||
name: profileName.trim() || undefined,
|
name: profileName.trim() || undefined,
|
||||||
username: profileUsername.trim() || undefined,
|
username: profileUsername.trim() || undefined,
|
||||||
bio: profileBio.trim() || null,
|
bio: profileBio.trim() || null,
|
||||||
avatar_url: profileAvatarUrl.trim() || null
|
avatar_url: profileAvatarUrl.trim() || null,
|
||||||
|
allow_private_messages: profileAllowPrivateMessages
|
||||||
});
|
});
|
||||||
useAuthStore.setState({ me: updated });
|
useAuthStore.setState({ me: updated });
|
||||||
await loadChats(search.trim() ? search : undefined);
|
await loadChats();
|
||||||
setProfileOpen(false);
|
setProfileOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
setProfileError("Failed to update profile");
|
setProfileError("Failed to update profile");
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ export function MessageList() {
|
|||||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||||
const messagesByChat = useChatStore((s) => s.messagesByChat);
|
const messagesByChat = useChatStore((s) => s.messagesByChat);
|
||||||
const typingByChat = useChatStore((s) => s.typingByChat);
|
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 unreadBoundaryByChat = useChatStore((s) => s.unreadBoundaryByChat);
|
||||||
|
const focusedMessageIdByChat = useChatStore((s) => s.focusedMessageIdByChat);
|
||||||
|
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
||||||
const chats = useChatStore((s) => s.chats);
|
const chats = useChatStore((s) => s.chats);
|
||||||
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
||||||
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
||||||
@@ -61,6 +66,9 @@ export function MessageList() {
|
|||||||
}, [chats, forwardQuery]);
|
}, [chats, forwardQuery]);
|
||||||
const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0;
|
const unreadBoundaryCount = activeChatId ? (unreadBoundaryByChat[activeChatId] ?? 0) : 0;
|
||||||
const unreadBoundaryIndex = unreadBoundaryCount > 0 ? Math.max(0, messages.length - unreadBoundaryCount) : -1;
|
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(
|
const selectedMessages = useMemo(
|
||||||
() => messages.filter((m) => selectedIds.has(m.id)),
|
() => messages.filter((m) => selectedIds.has(m.id)),
|
||||||
[messages, selectedIds]
|
[messages, selectedIds]
|
||||||
@@ -99,6 +107,19 @@ export function MessageList() {
|
|||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [pendingDelete]);
|
}, [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) {
|
if (!activeChatId) {
|
||||||
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
|
<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) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const own = message.sender_id === me?.id;
|
const own = message.sender_id === me?.id;
|
||||||
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
||||||
@@ -271,9 +303,10 @@ export function MessageList() {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
|
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
|
||||||
<div
|
<div
|
||||||
|
id={`message-${message.id}`}
|
||||||
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
if (selectedIds.size > 0) {
|
if (selectedIds.size > 0) {
|
||||||
toggleSelected(message.id);
|
toggleSelected(message.id);
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ export function useRealtime() {
|
|||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
void useChatStore.getState().loadChats();
|
const store = useChatStore.getState();
|
||||||
|
void store.loadChats();
|
||||||
|
if (store.activeChatId) {
|
||||||
|
void store.loadMessages(store.activeChatId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (messageEvent) => {
|
ws.onmessage = (messageEvent) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ChatList } from "../components/ChatList";
|
|||||||
import { ChatInfoPanel } from "../components/ChatInfoPanel";
|
import { ChatInfoPanel } from "../components/ChatInfoPanel";
|
||||||
import { MessageComposer } from "../components/MessageComposer";
|
import { MessageComposer } from "../components/MessageComposer";
|
||||||
import { MessageList } from "../components/MessageList";
|
import { MessageList } from "../components/MessageList";
|
||||||
|
import { getNotifications, type NotificationItem } from "../api/notifications";
|
||||||
import { searchMessages } from "../api/chats";
|
import { searchMessages } from "../api/chats";
|
||||||
import type { Message } from "../chat/types";
|
import type { Message } from "../chat/types";
|
||||||
import { useRealtime } from "../hooks/useRealtime";
|
import { useRealtime } from "../hooks/useRealtime";
|
||||||
@@ -17,6 +18,7 @@ export function ChatsPage() {
|
|||||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||||
const chats = useChatStore((s) => s.chats);
|
const chats = useChatStore((s) => s.chats);
|
||||||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||||||
|
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
||||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||||
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
||||||
const isReadOnlyChannel = Boolean(activeChat && activeChat.type === "channel" && activeChat.my_role === "member");
|
const isReadOnlyChannel = Boolean(activeChat && activeChat.type === "channel" && activeChat.my_role === "member");
|
||||||
@@ -25,6 +27,9 @@ export function ChatsPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||||
|
const [loadingNotifications, setLoadingNotifications] = useState(false);
|
||||||
|
|
||||||
useRealtime();
|
useRealtime();
|
||||||
|
|
||||||
@@ -71,6 +76,33 @@ export function ChatsPage() {
|
|||||||
};
|
};
|
||||||
}, [searchOpen, searchQuery, activeChatId]);
|
}, [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 (
|
return (
|
||||||
<main className="h-screen w-full p-2 text-text md:p-4">
|
<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">
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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)}>
|
<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
|
Search
|
||||||
</button>
|
</button>
|
||||||
@@ -147,6 +188,7 @@ export function ChatsPage() {
|
|||||||
key={`search-msg-${message.id}`}
|
key={`search-msg-${message.id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveChatId(message.chat_id);
|
setActiveChatId(message.chat_id);
|
||||||
|
setFocusedMessage(message.chat_id, message.id);
|
||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -159,6 +201,26 @@ export function ChatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,57 @@ import { create } from "zustand";
|
|||||||
import { getChats, getMessages, updateMessageStatus } from "../api/chats";
|
import { getChats, getMessages, updateMessageStatus } from "../api/chats";
|
||||||
import type { Chat, DeliveryStatus, Message, MessageType } from "../chat/types";
|
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 {
|
interface ChatState {
|
||||||
chats: Chat[];
|
chats: Chat[];
|
||||||
activeChatId: number | null;
|
activeChatId: number | null;
|
||||||
messagesByChat: Record<number, Message[]>;
|
messagesByChat: Record<number, Message[]>;
|
||||||
draftsByChat: Record<number, string>;
|
draftsByChat: Record<number, string>;
|
||||||
|
hasMoreByChat: Record<number, boolean>;
|
||||||
|
loadingMoreByChat: Record<number, boolean>;
|
||||||
typingByChat: Record<number, number[]>;
|
typingByChat: Record<number, number[]>;
|
||||||
replyToByChat: Record<number, Message | null>;
|
replyToByChat: Record<number, Message | null>;
|
||||||
unreadBoundaryByChat: Record<number, number>;
|
unreadBoundaryByChat: Record<number, number>;
|
||||||
|
focusedMessageIdByChat: Record<number, number | null>;
|
||||||
loadChats: (query?: string) => Promise<void>;
|
loadChats: (query?: string) => Promise<void>;
|
||||||
setActiveChatId: (chatId: number | null) => void;
|
setActiveChatId: (chatId: number | null) => void;
|
||||||
loadMessages: (chatId: number) => Promise<void>;
|
loadMessages: (chatId: number) => Promise<void>;
|
||||||
|
loadMoreMessages: (chatId: number) => Promise<void>;
|
||||||
prependMessage: (chatId: number, message: Message) => boolean;
|
prependMessage: (chatId: number, message: Message) => boolean;
|
||||||
addOptimisticMessage: (params: {
|
addOptimisticMessage: (params: {
|
||||||
chatId: number;
|
chatId: number;
|
||||||
@@ -41,16 +81,20 @@ interface ChatState {
|
|||||||
applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void;
|
applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void;
|
||||||
setDraft: (chatId: number, text: string) => void;
|
setDraft: (chatId: number, text: string) => void;
|
||||||
clearDraft: (chatId: number) => void;
|
clearDraft: (chatId: number) => void;
|
||||||
|
setFocusedMessage: (chatId: number, messageId: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatStore = create<ChatState>((set, get) => ({
|
export const useChatStore = create<ChatState>((set, get) => ({
|
||||||
chats: [],
|
chats: [],
|
||||||
activeChatId: null,
|
activeChatId: null,
|
||||||
messagesByChat: {},
|
messagesByChat: {},
|
||||||
draftsByChat: {},
|
draftsByChat: loadDraftsFromStorage(),
|
||||||
|
hasMoreByChat: {},
|
||||||
|
loadingMoreByChat: {},
|
||||||
typingByChat: {},
|
typingByChat: {},
|
||||||
replyToByChat: {},
|
replyToByChat: {},
|
||||||
unreadBoundaryByChat: {},
|
unreadBoundaryByChat: {},
|
||||||
|
focusedMessageIdByChat: {},
|
||||||
loadChats: async (query) => {
|
loadChats: async (query) => {
|
||||||
const chats = await getChats(query);
|
const chats = await getChats(query);
|
||||||
const currentActive = get().activeChatId;
|
const currentActive = get().activeChatId;
|
||||||
@@ -71,6 +115,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
...state.unreadBoundaryByChat,
|
...state.unreadBoundaryByChat,
|
||||||
[chatId]: unreadCount
|
[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))
|
chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, unread_count: 0 } : chat))
|
||||||
}));
|
}));
|
||||||
const lastMessage = sorted[sorted.length - 1];
|
const lastMessage = sorted[sorted.length - 1];
|
||||||
@@ -78,6 +130,43 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
void updateMessageStatus(chatId, lastMessage.id, "message_read");
|
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) => {
|
prependMessage: (chatId, message) => {
|
||||||
const old = get().messagesByChat[chatId] ?? [];
|
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))) {
|
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) =>
|
setDraft: (chatId, text) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
draftsByChat: {
|
const nextDrafts = {
|
||||||
...state.draftsByChat,
|
...state.draftsByChat,
|
||||||
[chatId]: text
|
[chatId]: text
|
||||||
}
|
};
|
||||||
})),
|
saveDraftsToStorage(nextDrafts);
|
||||||
|
return { draftsByChat: nextDrafts };
|
||||||
|
}),
|
||||||
clearDraft: (chatId) =>
|
clearDraft: (chatId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!(chatId in state.draftsByChat)) {
|
if (!(chatId in state.draftsByChat)) {
|
||||||
@@ -291,6 +382,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
const next = { ...state.draftsByChat };
|
const next = { ...state.draftsByChat };
|
||||||
delete next[chatId];
|
delete next[chatId];
|
||||||
|
saveDraftsToStorage(next);
|
||||||
return { draftsByChat: 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