Merge feature/user-messaging — E2EE real-time direct messaging (epic 8 P2)

This commit is contained in:
npeter83 2026-06-26 00:01:17 +02:00
commit da37eb6344
26 changed files with 1834 additions and 1 deletions

View file

@ -0,0 +1,56 @@
"""user-to-user messages
Revision ID: 0026_messages
Revises: 0025_install_wizard
Create Date: 2026-06-25
Adds the `messages` table for direct user-to-user messaging (notification module phase 2).
A conversation is the set of rows between two users; both FKs cascade so a GDPR account
erasure removes that user's messages.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0026_messages"
down_revision: Union[str, None] = "0025_install_wizard"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"messages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"sender_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"recipient_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_messages_sender_id", "messages", ["sender_id"])
op.create_index("ix_messages_recipient_id", "messages", ["recipient_id"])
op.create_index("ix_messages_created_at", "messages", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_messages_created_at", table_name="messages")
op.drop_index("ix_messages_recipient_id", table_name="messages")
op.drop_index("ix_messages_sender_id", table_name="messages")
op.drop_table("messages")

View file

@ -0,0 +1,68 @@
"""end-to-end encrypted messages + system messages
Revision ID: 0027_message_e2ee
Revises: 0026_messages
Create Date: 2026-06-25
Reworks direct messaging for end-to-end encryption. The `messages` table now stores ciphertext
(+ iv) for private user-to-user messages instead of plaintext `body`; `body` is kept (nullable)
for server-authored `kind="system"` messages (welcome / announcements). Adds `message_keys` to
hold each user's public key + passphrase-wrapped private key (the server can never decrypt).
Any pre-existing rows were plaintext with no keys, so they're dropped (the feature is unshipped;
prod has none).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0027_message_e2ee"
down_revision: Union[str, None] = "0026_messages"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Old rows were plaintext with no E2EE keys — can't be migrated; drop them.
op.execute("DELETE FROM messages")
op.add_column(
"messages",
sa.Column("kind", sa.String(length=16), nullable=False, server_default="user"),
)
op.add_column("messages", sa.Column("ciphertext", sa.Text(), nullable=True))
op.add_column("messages", sa.Column("iv", sa.String(length=32), nullable=True))
# System messages have no human sender and carry plaintext `body`.
op.alter_column("messages", "sender_id", existing_type=sa.Integer(), nullable=True)
op.alter_column("messages", "body", existing_type=sa.Text(), nullable=True)
op.create_table(
"message_keys",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("public_key", sa.Text(), nullable=False),
sa.Column("wrapped_private_key", sa.Text(), nullable=False),
sa.Column("salt", sa.String(length=64), nullable=False),
sa.Column("wrap_iv", sa.String(length=32), nullable=False),
sa.Column("key_check", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("message_keys")
op.execute("DELETE FROM messages")
op.alter_column("messages", "body", existing_type=sa.Text(), nullable=False)
op.alter_column("messages", "sender_id", existing_type=sa.Integer(), nullable=False)
op.drop_column("messages", "iv")
op.drop_column("messages", "ciphertext")
op.drop_column("messages", "kind")

View file

@ -34,6 +34,7 @@ from app.routes import (
feed,
health,
me,
messages,
notifications,
playlists,
quota,
@ -114,6 +115,7 @@ app.include_router(tags.router)
app.include_router(feed.router)
app.include_router(me.router)
app.include_router(notifications.router)
app.include_router(messages.router)
app.include_router(channels.router)
app.include_router(playlists.router)
app.include_router(admin.router)

View file

@ -518,3 +518,69 @@ class Notification(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
class Message(Base):
"""A message in the notification module (phase 2).
Two kinds share this table:
- `kind="user"`: a private, END-TO-END ENCRYPTED direct message between two users. The
server only ever stores `ciphertext` + `iv` (base64); it never holds the plaintext or
the keys, so not even an admin can read it. A conversation is just the set of messages
between two users no separate thread entity; queries group by the {sender, recipient}
pair.
- `kind="system"`: a server-authored plaintext message (e.g. the welcome, or future admin
announcements). `sender_id` is NULL and `body` holds the text. These are non-private
boilerplate, readable without any E2EE key setup, and surface as a "Siftlode" conversation.
`read_at` is stamped when the recipient opens the thread (NULL = unread, feeds the nav
badge). The recipient FK cascades (and sender, for user messages) so a GDPR account erasure
removes that user's messages outright."""
__tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True)
kind: Mapped[str] = mapped_column(
String(16), default="user", server_default="user"
)
# NULL for system messages (no human sender).
sender_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
recipient_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
# System messages carry plaintext `body`; user messages carry `ciphertext`+`iv` (base64).
body: Mapped[str | None] = mapped_column(Text)
ciphertext: Mapped[str | None] = mapped_column(Text)
iv: Mapped[str | None] = mapped_column(String(32))
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
class MessageKey(Base):
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
derive the pairwise conversation key. `wrapped_private_key` is the user's private key
encrypted IN THE BROWSER with a key derived (PBKDF2) from a message passphrase the server
never sees so the server stores only an opaque blob it cannot unwrap. `salt`/`wrap_iv`
parameterise that derivation; `key_check` is a small ciphertext the client decrypts to
verify a passphrase on unlock. The server is purely a key directory + blob store; it can
never read any message."""
__tablename__ = "message_keys"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
public_key: Mapped[str] = mapped_column(Text)
wrapped_private_key: Mapped[str] = mapped_column(Text)
salt: Mapped[str] = mapped_column(String(64))
wrap_iv: Mapped[str] = mapped_column(String(32))
key_check: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

60
backend/app/realtime.py Normal file
View file

@ -0,0 +1,60 @@
"""In-process WebSocket connection registry for live server→client push.
Generic groundwork (notification module phase 2 is the first user): connections are kept in
this process's memory, keyed by user_id, so a request handler can push an event to all of a
user's open tabs/devices. Single-uvicorn-process assumption — if we ever run multiple workers,
replace the in-memory map with a Redis pub/sub fan-out behind the same `push()` interface (same
note as app.ratelimit).
`push()` is intentionally SYNC-callable: ordinary (sync) route handlers run in a threadpool, so
they schedule the actual send onto the WebSocket event loop via run_coroutine_threadsafe. The
connect/disconnect/_push coroutines all run on that one loop, so the connection map is only ever
mutated from a single thread.
"""
import asyncio
import logging
from collections import defaultdict
from starlette.websockets import WebSocket
log = logging.getLogger("siftlode.realtime")
class ConnectionManager:
def __init__(self) -> None:
self._conns: dict[int, set[WebSocket]] = defaultdict(set)
self._loop: asyncio.AbstractEventLoop | None = None
async def connect(self, user_id: int, ws: WebSocket) -> None:
self._loop = asyncio.get_running_loop()
await ws.accept()
self._conns[user_id].add(ws)
async def disconnect(self, user_id: int, ws: WebSocket) -> None:
conns = self._conns.get(user_id)
if conns:
conns.discard(ws)
if not conns:
self._conns.pop(user_id, None)
async def _push(self, user_id: int, payload: dict) -> None:
for ws in list(self._conns.get(user_id, ())):
try:
await ws.send_json(payload)
except Exception:
# Stale socket; it'll be cleaned up by its own disconnect handler.
pass
def push(self, user_id: int, payload: dict) -> None:
"""Schedule a push onto the WebSocket event loop. Safe to call from a sync route
handler (threadpool). No-op if no WebSocket has ever connected (no loop captured)."""
loop = self._loop
if loop is None:
return
try:
asyncio.run_coroutine_threadsafe(self._push(user_id, payload), loop)
except Exception:
log.debug("ws push failed", exc_info=True)
manager = ConnectionManager()

View file

@ -0,0 +1,380 @@
"""Direct messaging (notification module, phase 2): end-to-end encrypted user-to-user chat
plus server-authored system messages, with live WebSocket delivery.
E2EE model: every private message is encrypted IN THE BROWSER under a key both parties derive
from ECDH(their private key, the other's public key). The server only ever stores ciphertext +
iv and acts as a key directory (public keys) and an opaque blob store (each user's private key,
wrapped client-side with a passphrase the server never sees). So not even an admin can read a
user-to-user conversation. `kind="system"` messages (the welcome; future announcements) are
plain server boilerplate non-private, readable without any key setup, shown as a "Siftlode"
conversation.
Every endpoint is gated by `require_human` (the shared demo account can't message). Metadata
(who messaged whom, when, read state) is NOT encrypted standard for E2EE and needed for
routing and unread counts.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app.auth import require_human
from app.db import SessionLocal, get_db
from app.models import Message, MessageKey, User
from app.ratelimit import RateLimiter
from app.realtime import manager
router = APIRouter(prefix="/api/messages", tags=["messages"])
MAX_CIPHERTEXT = 8000 # base64 of a generous plaintext + AES-GCM overhead
SYSTEM_PARTNER_ID = 0 # synthetic conversation partner for system messages
SYSTEM_NAME = "Siftlode"
_send_limiter = RateLimiter(max_events=30, window_seconds=60)
# The welcome is rendered server-side at creation time, so it carries its own translations
# (one per supported UI language). Kept short and non-private (every new user gets the same text).
WELCOME = {
"en": (
"Welcome to Siftlode! 👋\n\n"
"This is your private message inbox. Conversations with other members are end-to-end "
"encrypted — only you and the person you're chatting with can read them, not even an "
"admin. To start messaging someone, set up secure messaging with a passphrase first.\n\n"
"Enjoy your feed!"
),
"hu": (
"Üdv a Siftlode-on! 👋\n\n"
"Ez a privát üzenet-postafiókod. A többi taggal folytatott beszélgetések végpontig "
"titkosítottak — csak te és a beszélgetőpartnered olvashatja őket, még az admin sem. "
"Ha üzenni szeretnél valakinek, előbb állítsd be a biztonságos üzenetküldést egy "
"jelszóval.\n\n"
"Jó böngészést!"
),
"de": (
"Willkommen bei Siftlode! 👋\n\n"
"Das ist dein privater Nachrichten-Posteingang. Unterhaltungen mit anderen Mitgliedern "
"sind Ende-zu-Ende-verschlüsselt — nur du und dein Gegenüber könnt sie lesen, nicht "
"einmal ein Admin. Um jemandem zu schreiben, richte zuerst die sichere "
"Nachrichtenübermittlung mit einem Passwort ein.\n\n"
"Viel Spaß mit deinem Feed!"
),
}
class KeySetupIn(BaseModel):
public_key: str
wrapped_private_key: str
salt: str
wrap_iv: str
key_check: str
class SendIn(BaseModel):
recipient_id: int
ciphertext: str
iv: str
def _user_label(u: User) -> str:
return u.display_name or u.email.split("@")[0]
def _serialize_user(u: User) -> dict:
return {"id": u.id, "name": _user_label(u), "avatar_url": u.avatar_url}
def _system_partner() -> dict:
return {"id": SYSTEM_PARTNER_ID, "name": SYSTEM_NAME, "avatar_url": None}
def _serialize_msg(m: Message) -> dict:
return {
"id": m.id,
"kind": m.kind,
"sender_id": m.sender_id,
"recipient_id": m.recipient_id,
"body": m.body, # only set for system messages
"ciphertext": m.ciphertext,
"iv": m.iv,
"read_at": m.read_at.isoformat() if m.read_at else None,
"created_at": m.created_at.isoformat() if m.created_at else None,
}
def _messageable() -> list:
return [
User.is_demo.is_(False),
User.is_active.is_(True),
User.is_suspended.is_(False),
]
def ensure_welcome(db: Session, user: User) -> None:
"""Create the one-time system welcome message for a (human) user the first time it's needed.
Idempotent: guarded on whether any system message already exists for them."""
if user.is_demo:
return
exists = db.scalar(
select(Message.id)
.where(Message.recipient_id == user.id, Message.kind == "system")
.limit(1)
)
if exists:
return
lang = (user.preferences or {}).get("language")
body = WELCOME.get(lang, WELCOME["en"])
m = Message(kind="system", sender_id=None, recipient_id=user.id, body=body)
db.add(m)
db.commit()
db.refresh(m)
manager.push(user.id, {"type": "message", "message": _serialize_msg(m)})
# --- E2EE key directory / blob store -----------------------------------------
@router.get("/keys/me")
def my_key(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
"""My own key bundle. `configured` says whether secure messaging is set up; if so the
wrapped blob + params are returned so this device can unlock with the passphrase."""
k = db.get(MessageKey, user.id)
if k is None:
return {"configured": False}
return {
"configured": True,
"public_key": k.public_key,
"wrapped_private_key": k.wrapped_private_key,
"salt": k.salt,
"wrap_iv": k.wrap_iv,
"key_check": k.key_check,
}
@router.post("/keys")
def setup_keys(
payload: KeySetupIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Store this user's public key + passphrase-wrapped private key. Set-once: changing the
key would orphan every existing conversation key, so re-setup is rejected here."""
if db.get(MessageKey, user.id) is not None:
raise HTTPException(status_code=409, detail="Secure messaging is already set up.")
db.add(
MessageKey(
user_id=user.id,
public_key=payload.public_key,
wrapped_private_key=payload.wrapped_private_key,
salt=payload.salt,
wrap_iv=payload.wrap_iv,
key_check=payload.key_check,
)
)
db.commit()
return {"configured": True}
@router.get("/keys/{user_id}")
def public_key(
user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""A member's public key, for deriving the pairwise conversation key."""
k = db.get(MessageKey, user_id)
if k is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
return {"user_id": user_id, "public_key": k.public_key}
# --- directory + conversations -----------------------------------------------
@router.get("/users")
def list_recipients(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
"""Member directory for the recipient picker: messageable members (except me) who have set
up secure messaging (so a conversation key can be derived)."""
rows = (
db.execute(
select(User)
.join(MessageKey, MessageKey.user_id == User.id)
.where(User.id != user.id, *_messageable())
.order_by(func.lower(func.coalesce(User.display_name, User.email)))
)
.scalars()
.all()
)
return [_serialize_user(u) for u in rows]
@router.get("/conversations")
def list_conversations(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""One entry per conversation: each user partner (E2EE) plus the system "Siftlode" thread,
with the latest message + my unread count, newest first. Lazily creates the welcome on the
first open. User-message previews are ciphertext the client decrypts them."""
ensure_welcome(db, user)
msgs = (
db.execute(
select(Message)
.where(or_(Message.sender_id == user.id, Message.recipient_id == user.id))
.order_by(Message.created_at.desc())
)
.scalars()
.all()
)
convs: dict[int, dict] = {}
for m in msgs:
if m.kind == "system":
pid = SYSTEM_PARTNER_ID
else:
pid = m.recipient_id if m.sender_id == user.id else m.sender_id
c = convs.setdefault(pid, {"last": m, "unread": 0})
if m.recipient_id == user.id and m.read_at is None:
c["unread"] += 1
user_ids = [pid for pid in convs if pid != SYSTEM_PARTNER_ID]
partners = {}
if user_ids:
partners = {
u.id: u
for u in db.execute(select(User).where(User.id.in_(user_ids))).scalars().all()
}
items = []
for pid, c in convs.items():
if pid == SYSTEM_PARTNER_ID:
partner = _system_partner()
elif pid in partners:
partner = _serialize_user(partners[pid])
else:
continue
items.append(
{"partner": partner, "last_message": _serialize_msg(c["last"]), "unread": c["unread"]}
)
items.sort(key=lambda x: x["last_message"]["created_at"] or "", reverse=True)
return {"items": items}
@router.get("/conversations/{partner_id}")
def get_thread(
partner_id: int,
mark_read: bool = True,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Full thread with a partner (or the system thread when partner_id == 0), oldest first.
Stamps unread incoming messages read unless mark_read=false."""
if partner_id == SYSTEM_PARTNER_ID:
ensure_welcome(db, user)
partner = _system_partner()
cond = and_(Message.recipient_id == user.id, Message.kind == "system")
else:
p = db.get(User, partner_id)
if p is None:
raise HTTPException(status_code=404, detail="User not found")
partner = _serialize_user(p)
cond = and_(
Message.kind == "user",
or_(
and_(Message.sender_id == user.id, Message.recipient_id == partner_id),
and_(Message.sender_id == partner_id, Message.recipient_id == user.id),
),
)
msgs = (
db.execute(select(Message).where(cond).order_by(Message.created_at.asc()))
.scalars()
.all()
)
if mark_read:
now = datetime.now(timezone.utc)
changed = False
for m in msgs:
if m.recipient_id == user.id and m.read_at is None:
m.read_at = now
changed = True
if changed:
db.commit()
return {"partner": partner, "items": [_serialize_msg(m) for m in msgs]}
@router.post("")
def send_message(
payload: SendIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Send an end-to-end-encrypted message. The server stores only the ciphertext the client
produced, then pushes it live to the recipient (and the sender's other devices)."""
if not _send_limiter.allow(f"msg:{user.id}"):
raise HTTPException(status_code=429, detail="Too many messages — slow down a moment.")
ct = (payload.ciphertext or "").strip()
iv = (payload.iv or "").strip()
if not ct or not iv:
raise HTTPException(status_code=400, detail="Message is empty.")
if len(ct) > MAX_CIPHERTEXT:
raise HTTPException(status_code=400, detail="Message is too long.")
if payload.recipient_id == user.id:
raise HTTPException(status_code=400, detail="You can't message yourself.")
recipient = db.get(User, payload.recipient_id)
if recipient is None or recipient.is_demo or not recipient.is_active or recipient.is_suspended:
raise HTTPException(status_code=404, detail="Recipient not available.")
if db.get(MessageKey, recipient.id) is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
m = Message(
kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv
)
db.add(m)
db.commit()
db.refresh(m)
# Carry both parties so each recipient can open/flash the right dock window (the partner is
# whichever of from/to isn't them).
event = {
"type": "message",
"message": _serialize_msg(m),
"from": _serialize_user(user),
"to": _serialize_user(recipient),
}
manager.push(recipient.id, event)
manager.push(user.id, event) # the sender's other open devices
return _serialize_msg(m)
@router.get("/unread_count")
def unread_count(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
n = db.scalar(
select(func.count())
.select_from(Message)
.where(Message.recipient_id == user.id, Message.read_at.is_(None))
)
return {"count": n or 0}
# --- live delivery -----------------------------------------------------------
@router.websocket("/ws")
async def messages_ws(ws: WebSocket) -> None:
"""Live push channel: authenticated by the session cookie, registers the connection so
sent messages reach this user's open tabs instantly. We don't consume clientserver frames
(sending goes through POST /api/messages); the receive loop only detects disconnect."""
uid = ws.session.get("user_id") if "session" in ws.scope else None
if not uid:
await ws.close(code=1008)
return
db = SessionLocal()
try:
u = db.get(User, uid)
ok = bool(u and not u.is_demo and not u.is_suspended and u.is_active)
finally:
db.close()
if not ok:
await ws.close(code=1008)
return
await manager.connect(uid, ws)
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
pass
finally:
await manager.disconnect(uid, ws)

View file

@ -35,6 +35,8 @@ import ConfigPanel from "./components/ConfigPanel";
import AdminUsers from "./components/AdminUsers";
import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel";
import Messages from "./components/Messages";
import ChatDock from "./components/ChatDock";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster";
@ -546,6 +548,10 @@ export default function App() {
<Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? (
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
) : page === "messages" && !meQuery.data!.is_demo ? (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={meQuery.data!.id} />
</div>
) : page === "settings" ? (
<SettingsPanel
me={meQuery.data!}
@ -569,6 +575,7 @@ export default function App() {
(collapsed or expanded) without tracking its width. */}
<Toaster />
</div>
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}

View file

@ -0,0 +1,91 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock";
import { onMessage } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import ChatThread from "./ChatThread";
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
// the app-wide live-message subscription and restores this device's unlock state on load.
export default function ChatDock({ meId }: { meId: number }) {
const qc = useQueryClient();
const chats = useDockChats();
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
useEffect(() => {
initDock(meId);
e2ee.loadFromDevice(meId);
}, [meId]);
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and
// for an INCOMING message, open or flash that partner's dock window.
useEffect(
() =>
onMessage(({ message: m, from }) => {
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["message-unread"] });
const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id;
qc.invalidateQueries({ queryKey: ["thread", partnerId] });
// Auto-open/flash only for messages from someone else (not my own echo) and only for
// real user conversations.
if (m.kind === "user" && m.sender_id !== meId && from) {
notifyIncoming({ id: from.id, name: from.name, avatar_url: from.avatar_url });
}
}),
[qc, meId]
);
if (chats.length === 0) return null;
return (
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
{chats.map((c) => (
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
))}
</div>
);
}
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
const { t } = useTranslation();
return (
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden">
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
{chat.flash > 0 && (
<div key={chat.flash} className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-10" />
)}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button
onClick={() => toggleMinimize(chat.partnerId)}
className="flex items-center gap-2 min-w-0 flex-1 text-left"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
<Avatar src={chat.avatar} fallback={chat.name} className="w-7 h-7 rounded-full shrink-0 text-xs" />
<span className="font-semibold text-sm truncate">{chat.name}</span>
</button>
<button
onClick={() => toggleMinimize(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={() => closeChat(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={t("messages.close")}
>
<X className="w-4 h-4" />
</button>
</div>
{!chat.minimized && (
<div className="flex flex-col h-96">
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { partnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
// chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts
// setup/unlock (server-truth) for a user conversation until messaging is ready.
export default function ChatThread({
meId,
partnerId,
isSystem,
}: {
meId: number;
partnerId: number;
isSystem: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [draft, setDraft] = useState("");
const [plain, setPlain] = useState<Record<number, string>>({});
const bottomRef = useRef<HTMLDivElement>(null);
const ready = useKeyState() === "ready";
const needsKey = !isSystem && !ready;
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
["thread", partnerId],
() => api.thread(partnerId),
{ intervalMs: POLL_MS, enabled: !needsKey }
);
const items = q.data?.items ?? [];
useEffect(() => {
if (isSystem || !ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const m of items) {
if (m.ciphertext && m.iv) {
try {
const pub = await partnerPub(partnerId);
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
} catch {
out[m.id] = "⚠ " + t("messages.cantDecrypt");
}
}
}
if (!cancelled) setPlain(out);
})();
return () => {
cancelled = true;
};
}, [items, ready, isSystem, partnerId, t]);
// Opening / refreshing the thread marks incoming messages read, so clear the badges.
useEffect(() => {
if (q.dataUpdatedAt) {
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["message-unread"] });
}
}, [q.dataUpdatedAt, qc]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [items.length, plain]);
const send = useMutation({
mutationFn: async (text: string) => {
const pub = await partnerPub(partnerId);
const { ciphertext, iv } = await e2ee.encryptFor(partnerId, pub, text);
return api.sendMessage(partnerId, ciphertext, iv);
},
onSuccess: () => {
setDraft("");
qc.invalidateQueries({ queryKey: ["thread", partnerId] });
qc.invalidateQueries({ queryKey: ["conversations"] });
},
});
const submit = () => {
const text = draft.trim();
if (text && !send.isPending) send.mutate(text);
};
if (needsKey) {
return (
<div className="flex-1 grid place-items-center p-3 overflow-y-auto">
<div className="w-full max-w-sm">
<KeyGate meId={meId} compact />
</div>
</div>
);
}
return (
<>
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
{items.length === 0 ? (
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
) : (
items.map((m) => {
const mine = m.sender_id === meId;
const text = m.kind === "system" ? m.body ?? "" : plain[m.id] ?? "…";
return (
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words ${
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
}`}
>
{text}
<div className={`text-[10px] mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}>
{relativeTime(m.created_at)}
</div>
</div>
</div>
);
})
)}
<div ref={bottomRef} />
</div>
{isSystem ? (
<div className="p-2 border-t border-border text-center text-xs text-muted">{t("messages.systemReadOnly")}</div>
) : (
<div className="flex items-end gap-2 p-2 border-t border-border">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="shrink-0 p-2.5 rounded-xl bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
aria-label={t("messages.send")}
title={t("messages.send")}
>
<Send className="w-4 h-4" />
</button>
</div>
)}
</>
);
}

View file

@ -79,6 +79,10 @@ export default function Header({
? t("header.configuration")
: page === "users"
? t("header.users")
: page === "notifications"
? t("inbox.navLabel")
: page === "messages"
? t("messages.navLabel")
: t("header.channelManager")}
</div>
)}

View file

@ -0,0 +1,80 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, ShieldCheck } from "lucide-react";
import { api } from "../lib/api";
import * as e2ee from "../lib/e2ee";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
// (the Messages page, a dock window) swaps out of the gate automatically.
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const keyQ = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 });
const configured = keyQ.data?.configured ?? false;
const [pass, setPass] = useState("");
const [confirm, setConfirm] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit() {
setErr(null);
if (pass.length < 6) return setErr(t("messages.passTooShort"));
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
setBusy(true);
try {
if (configured) {
await e2ee.unlock(meId, pass, keyQ.data!);
} else {
const payload = await e2ee.setup(meId, pass);
await api.setupMessageKey(payload);
}
qc.invalidateQueries({ queryKey: ["message-key"] });
} catch {
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
} finally {
setBusy(false);
}
}
if (keyQ.isLoading) return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
<div className="font-semibold text-sm">{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}</div>
</div>
{!compact && <p className="text-sm text-muted">{configured ? t("messages.unlockBody") : t("messages.setupBody")}</p>}
<input
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
placeholder={t("messages.passphrase")}
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
{!configured && (
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("messages.passphraseConfirm")}
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
<button
onClick={submit}
disabled={busy || !pass}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</button>
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
);
}

View file

@ -0,0 +1,236 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import KeyGate from "./KeyGate";
import ChatThread from "./ChatThread";
const POLL_MS = 20000;
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
export default function Messages({ meId }: { meId: number }) {
const [view, setView] = useState<View>("list");
if (typeof view === "object") {
return (
<PageThread
meId={meId}
partnerId={view.partnerId}
isSystem={!!view.system}
seedName={view.name}
seedAvatar={view.avatar}
onBack={() => setView("list")}
/>
);
}
if (view === "new") {
return <Directory onPick={(u) => setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />;
}
return (
<ConversationList
meId={meId}
onOpen={(c) => setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })}
onNew={() => setView("new")}
/>
);
}
function ConversationList({
meId,
onOpen,
onNew,
}: {
meId: number;
onOpen: (c: Conversation) => void;
onNew: () => void;
}) {
const { t } = useTranslation();
const keyState = useKeyState();
const ready = keyState === "ready";
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS });
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const c of items) {
const m = c.last_message;
if (m.kind === "user" && m.ciphertext && m.iv) {
try {
const pub = await partnerPub(c.partner.id);
out[c.partner.id] = await e2ee.decryptFrom(c.partner.id, pub, m.ciphertext, m.iv);
} catch {
out[c.partner.id] = "⚠";
}
}
}
if (!cancelled) setPreviews(out);
})();
return () => {
cancelled = true;
};
}, [items, ready]);
function previewText(c: Conversation): string {
const m = c.last_message;
if (m.kind === "system") return m.body ?? "";
if (!ready) return "🔒 " + t("messages.encrypted");
return previews[c.partner.id] ?? "…";
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.conversations")}</div>
{ready && (
<button
onClick={onNew}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-accent text-accent-fg hover:opacity-90 transition"
>
<SquarePen className="w-4 h-4" />
{t("messages.new")}
</button>
)}
</div>
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
{q.isLoading && !q.data ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
) : (
<div className="flex flex-col gap-1.5">
{items.map((c) => {
const isSystem = c.partner.id === SYSTEM_ID;
return (
<div key={c.partner.id} className="group flex items-center gap-1 rounded-xl hover:bg-card transition">
<button onClick={() => onOpen(c)} className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1">
<Avatar src={c.partner.avatar_url} fallback={c.partner.name} className="w-10 h-10 rounded-full shrink-0 text-sm" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}>{c.partner.name}</span>
<span className="ml-auto text-[11px] text-muted shrink-0">{relativeTime(c.last_message.created_at)}</span>
</div>
<div className="flex items-center gap-2">
<span className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}>{previewText(c)}</span>
{c.unread > 0 && (
<span className="ml-auto shrink-0 min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-[11px] font-semibold">
{c.unread}
</span>
)}
</div>
</div>
</button>
{!isSystem && ready && (
<button
onClick={() => openChat(c.partner)}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
const { t } = useTranslation();
const [filter, setFilter] = useState("");
const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers });
const users = (q.data ?? []).filter((u) => u.name.toLowerCase().includes(filter.trim().toLowerCase()));
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
</div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("messages.searchPeople")}
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
{q.isLoading ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : users.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.noPeople")}</div>
) : (
<div className="flex flex-col gap-1">
{users.map((u) => (
<button
key={u.id}
onClick={() => onPick(u)}
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
>
<Avatar src={u.avatar_url} fallback={u.name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
<span className="truncate font-medium">{u.name}</span>
</button>
))}
</div>
)}
</div>
);
}
function PageThread({
meId,
partnerId,
isSystem,
seedName,
seedAvatar,
onBack,
}: {
meId: number;
partnerId: number;
isSystem: boolean;
seedName?: string;
seedAvatar?: string | null;
onBack: () => void;
}) {
const { t } = useTranslation();
const ready = useKeyState() === "ready";
const name = seedName ?? "";
return (
<div className="flex flex-col h-[65vh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
<ArrowLeft className="w-4 h-4" />
</button>
<Avatar src={seedAvatar} fallback={name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
<span className="font-semibold truncate">{name}</span>
{!isSystem && <ShieldCheck className="w-4 h-4 text-muted shrink-0" aria-label={t("messages.encryptedHint")} />}
<div className="flex-1" />
{!isSystem && ready && (
<button
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
</div>
);
}

View file

@ -12,6 +12,7 @@ import {
Info,
ListVideo,
LogOut,
MessageSquare,
Settings,
Shield,
SlidersHorizontal,
@ -127,6 +128,13 @@ export default function NavSidebar({
// events (the former separate bell is now folded into the inbox page).
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
// Direct messages are their own module with their own unread badge (demo can't message).
const msgUnreadQuery = useLiveQuery(
["message-unread"],
api.messageUnreadCount,
{ intervalMs: 30000, enabled: !me.is_demo }
);
const msgUnread = msgUnreadQuery.data?.count ?? 0;
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
@ -135,6 +143,10 @@ export default function NavSidebar({
{ page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
// Direct messaging — its own module; hidden for the shared demo account.
...(me.is_demo
? []
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
];

View file

@ -2,6 +2,7 @@
"navLabel": "Benachrichtigungen",
"title": "Benachrichtigungen",
"subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.",
"tabInbox": "Benachrichtigungen",
"empty": "Alles erledigt.",
"markAllRead": "Alle als gelesen markieren",
"clearAll": "Alle löschen",

View file

@ -0,0 +1,34 @@
{
"tab": "Nachrichten",
"navLabel": "Nachrichten",
"popOut": "In Pop-out öffnen",
"expand": "Aufklappen",
"minimize": "Minimieren",
"close": "Schließen",
"conversations": "Unterhaltungen",
"new": "Neue Nachricht",
"newTitle": "Neue Nachricht",
"empty": "Noch keine Unterhaltungen. Starte eine über die Schaltfläche „Neue Nachricht“.",
"searchPeople": "Personen suchen…",
"noPeople": "Noch niemand zum Schreiben.",
"threadEmpty": "Noch keine Nachrichten — sag Hallo.",
"writePlaceholder": "Nachricht schreiben…",
"send": "Senden",
"encrypted": "Verschlüsselte Nachricht",
"encryptedHint": "Ende-zu-Ende-verschlüsselt",
"cantDecrypt": "Kann nicht entschlüsselt werden",
"systemReadOnly": "Dies ist eine automatische Nachricht.",
"setupTitle": "Sichere Nachrichten einrichten",
"setupBody": "Nachrichten sind Ende-zu-Ende-verschlüsselt. Wähle ein Passwort zum Schutz deines Schlüssels — er verlässt nie dein Gerät, sodass niemand (nicht einmal ein Admin) deine Unterhaltungen lesen kann.",
"setupWarn": "Wenn du dieses Passwort vergisst, kann dein Nachrichtenverlauf nicht wiederhergestellt werden.",
"setUp": "Einrichten",
"unlockTitle": "Nachrichten entsperren",
"unlockBody": "Gib dein Nachrichten-Passwort ein, um deine Unterhaltungen auf diesem Gerät zu entsperren.",
"unlock": "Entsperren",
"passphrase": "Passwort",
"passphraseConfirm": "Passwort bestätigen",
"passTooShort": "Mindestens 6 Zeichen verwenden.",
"passMismatch": "Die Passwörter stimmen nicht überein.",
"wrongPass": "Falsches Passwort.",
"setupFailed": "Sichere Nachrichten konnten nicht eingerichtet werden. Bitte erneut versuchen."
}

View file

@ -2,6 +2,7 @@
"navLabel": "Notifications",
"title": "Notifications",
"subtitle": "Updates from your library and the system.",
"tabInbox": "Notifications",
"empty": "You're all caught up.",
"markAllRead": "Mark all read",
"clearAll": "Clear all",

View file

@ -0,0 +1,34 @@
{
"tab": "Messages",
"navLabel": "Messages",
"popOut": "Open in pop-out",
"expand": "Expand",
"minimize": "Minimize",
"close": "Close",
"conversations": "Conversations",
"new": "New message",
"newTitle": "New message",
"empty": "No conversations yet. Start one with the New message button.",
"searchPeople": "Search people…",
"noPeople": "No one to message yet.",
"threadEmpty": "No messages yet — say hello.",
"writePlaceholder": "Write a message…",
"send": "Send",
"encrypted": "Encrypted message",
"encryptedHint": "End-to-end encrypted",
"cantDecrypt": "Can't decrypt",
"systemReadOnly": "This is an automated message.",
"setupTitle": "Set up secure messaging",
"setupBody": "Messages are end-to-end encrypted. Choose a passphrase to protect your key — it never leaves your device, so no one (not even an admin) can read your conversations.",
"setupWarn": "If you forget this passphrase, your message history can't be recovered.",
"setUp": "Set up",
"unlockTitle": "Unlock messaging",
"unlockBody": "Enter your message passphrase to unlock your conversations on this device.",
"unlock": "Unlock",
"passphrase": "Passphrase",
"passphraseConfirm": "Confirm passphrase",
"passTooShort": "Use at least 6 characters.",
"passMismatch": "The passphrases don't match.",
"wrongPass": "Wrong passphrase.",
"setupFailed": "Couldn't set up secure messaging. Please try again."
}

View file

@ -2,6 +2,7 @@
"navLabel": "Értesítések",
"title": "Értesítések",
"subtitle": "Frissítések a könyvtáradból és a rendszertől.",
"tabInbox": "Értesítések",
"empty": "Nincs új értesítés.",
"markAllRead": "Összes olvasott",
"clearAll": "Összes törlése",

View file

@ -0,0 +1,34 @@
{
"tab": "Üzenetek",
"navLabel": "Üzenetek",
"popOut": "Megnyitás felugró ablakban",
"expand": "Kinyitás",
"minimize": "Összecsukás",
"close": "Bezárás",
"conversations": "Beszélgetések",
"new": "Új üzenet",
"newTitle": "Új üzenet",
"empty": "Még nincs beszélgetés. Indíts egyet az Új üzenet gombbal.",
"searchPeople": "Emberek keresése…",
"noPeople": "Még nincs kinek üzenni.",
"threadEmpty": "Még nincs üzenet — köszönj be.",
"writePlaceholder": "Írj egy üzenetet…",
"send": "Küldés",
"encrypted": "Titkosított üzenet",
"encryptedHint": "Végpontig titkosítva",
"cantDecrypt": "Nem fejthető vissza",
"systemReadOnly": "Ez egy automatikus üzenet.",
"setupTitle": "Biztonságos üzenetküldés beállítása",
"setupBody": "Az üzenetek végpontig titkosítottak. Válassz egy jelszót a kulcsod védelméhez — sosem hagyja el az eszközödet, így senki (még az admin sem) olvashatja a beszélgetéseidet.",
"setupWarn": "Ha elfelejted ezt a jelszót, az üzenet-előzményeid nem állíthatók helyre.",
"setUp": "Beállítás",
"unlockTitle": "Üzenetküldés feloldása",
"unlockBody": "Add meg az üzenet-jelszavadat a beszélgetéseid feloldásához ezen az eszközön.",
"unlock": "Feloldás",
"passphrase": "Jelszó",
"passphraseConfirm": "Jelszó megerősítése",
"passTooShort": "Legalább 6 karakter legyen.",
"passMismatch": "A jelszavak nem egyeznek.",
"wrongPass": "Hibás jelszó.",
"setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra."
}

View file

@ -151,6 +151,16 @@ html[data-perf="1"] body {
from { opacity: 0; transform: translateY(-6px) scale(0.97); }
to { opacity: 1; transform: none; }
}
/* One-shot attention flash for a chat dock window on a new message. Inset glow so the
window's own overflow-hidden doesn't clip it; pulses twice then fades. */
@keyframes chatFlash {
0% { box-shadow: inset 0 0 0 0 transparent; }
18% { box-shadow: inset 0 0 0 3px var(--accent), inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent); }
40% { box-shadow: inset 0 0 0 1px transparent; }
62% { box-shadow: inset 0 0 0 3px var(--accent), inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: inset 0 0 0 0 transparent; }
}
.chat-flash { animation: chatFlash 1.1s ease-out; }
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */

View file

@ -469,6 +469,48 @@ export interface AppNotification {
created_at: string | null;
}
export interface MessageUser {
id: number;
name: string;
avatar_url: string | null;
}
export interface Message {
id: number;
kind: "user" | "system";
sender_id: number | null;
recipient_id: number;
body: string | null; // plaintext, system messages only
ciphertext: string | null; // base64, E2EE user messages only
iv: string | null;
read_at: string | null;
created_at: string | null;
}
export interface Conversation {
partner: MessageUser;
last_message: Message;
unread: number;
}
// My own E2EE key bundle. When configured, the wrapped blob + params let a device unlock.
export interface MyKeyBundle {
configured: boolean;
public_key?: string;
wrapped_private_key?: string;
salt?: string;
wrap_iv?: string;
key_check?: string;
}
export interface KeySetup {
public_key: string;
wrapped_private_key: string;
salt: string;
wrap_iv: string;
key_check: string;
}
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
export interface ConfigItem {
key: string;
@ -712,6 +754,23 @@ export const api = {
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),
// --- direct messages (end-to-end encrypted) ---
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
req(`/api/messages/keys/${userId}`),
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
conversations: (): Promise<{ items: Conversation[] }> => req("/api/messages/conversations"),
thread: (partnerId: number): Promise<{ partner: MessageUser; items: Message[] }> =>
req(`/api/messages/conversations/${partnerId}`),
sendMessage: (recipientId: number, ciphertext: string, iv: string): Promise<Message> =>
req("/api/messages", {
method: "POST",
body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }),
}),
messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"),
// --- user tags ---
createTag: (t: { name: string; color?: string; category?: string }) =>
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),

View file

@ -0,0 +1,109 @@
// App-wide store of open floating chat windows (the Messenger-style dock, bottom-right).
// A window can be opened from anywhere (a conversation's "pop out" button, or automatically
// when a message arrives) and stays open across page navigation. The dock state (which chats
// are open + their minimised state) is persisted per user so it survives a reload.
import { useSyncExternalStore } from "react";
export interface DockChat {
partnerId: number;
name: string;
avatar: string | null;
minimized: boolean;
flash: number; // bumped to trigger a one-shot attention flash on the window
}
type PartnerLike = { id: number; name: string; avatar_url: string | null };
const MAX_OPEN = 3; // keep the dock from overflowing the corner
let chats: DockChat[] = [];
let userId: number | null = null;
const subs = new Set<() => void>();
function storageKey(): string | null {
return userId != null ? `siftlode.chatDock.${userId}` : null;
}
function persist(): void {
const k = storageKey();
if (!k) return;
try {
// Don't persist the transient flash counter.
localStorage.setItem(k, JSON.stringify(chats.map(({ flash, ...c }) => c)));
} catch {
/* ignore quota/serialization errors */
}
}
function emit(next: DockChat[]): void {
chats = next;
persist();
for (const s of subs) s();
}
// Bind the dock to the signed-in user and restore their persisted windows (once per user).
export function initDock(uid: number): void {
if (userId === uid) return;
userId = uid;
let restored: DockChat[] = [];
try {
const raw = localStorage.getItem(storageKey()!);
if (raw) restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
} catch {
restored = [];
}
chats = restored.slice(0, MAX_OPEN);
for (const s of subs) s();
}
export function openChat(partner: PartnerLike): void {
const existing = chats.find((c) => c.partnerId === partner.id);
if (existing) {
// Re-opening focuses it: ensure expanded and move to the front.
emit([
{ ...existing, minimized: false },
...chats.filter((c) => c.partnerId !== partner.id),
]);
return;
}
emit(
[
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 0 },
...chats,
].slice(0, MAX_OPEN)
);
}
// An incoming message arrived from `partner`: open the window if it's closed, otherwise just
// flash it (whether expanded or minimised — don't disturb the minimised state).
export function notifyIncoming(partner: PartnerLike): void {
const existing = chats.find((c) => c.partnerId === partner.id);
if (!existing) {
emit(
[
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 1 },
...chats,
].slice(0, MAX_OPEN)
);
return;
}
emit(chats.map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c)));
}
export function closeChat(partnerId: number): void {
emit(chats.filter((c) => c.partnerId !== partnerId));
}
export function toggleMinimize(partnerId: number): void {
emit(chats.map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c)));
}
export function useDockChats(): DockChat[] {
return useSyncExternalStore(
(cb) => {
subs.add(cb);
return () => subs.delete(cb);
},
() => chats,
() => chats
);
}

204
frontend/src/lib/e2ee.ts Normal file
View file

@ -0,0 +1,204 @@
// End-to-end encryption for direct messages (notification module phase 2).
//
// Contract (validated against the backend): each user has an ECDH P-256 keypair. The private
// key is wrapped in the browser with an AES-GCM key derived (PBKDF2) from a message passphrase
// the server never sees; only the wrapped blob is uploaded. A conversation key is derived from
// ECDH(my private, partner public) → HKDF → AES-GCM, and each message is AES-GCM encrypted with
// a random IV. The server stores only ciphertext, so not even an admin can read a conversation.
//
// The unlocked private key is persisted per-device as a NON-EXTRACTABLE CryptoKey in IndexedDB,
// so the passphrase is needed once per device, then survives reloads (cleared with browser data).
import type { KeySetup, MyKeyBundle } from "./api";
const subtle = crypto.subtle;
const enc = new TextEncoder();
const dec = new TextDecoder();
const PBKDF2_ITERS = 600_000;
const HKDF_INFO = "siftlode-dm-v1";
const b64 = (buf: ArrayBuffer | Uint8Array): string => {
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
};
const ub64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
// WebCrypto byte inputs: the DOM lib types want BufferSource backed by a plain ArrayBuffer;
// our Uint8Arrays/ArrayBuffers are functionally that, so coerce at the call sites.
const bsrc = (x: Uint8Array | ArrayBuffer): BufferSource => x as BufferSource;
// --- module state ---
let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key
const convKeys = new Map<number, CryptoKey>(); // partnerId → derived AES key
// Unlock state is shared app-wide (the Messages page and the floating chat dock both observe
// it), so expose a subscription for useSyncExternalStore.
const unlockSubs = new Set<() => void>();
function emitUnlock(): void {
for (const cb of unlockSubs) cb();
}
export function subscribeUnlock(cb: () => void): () => void {
unlockSubs.add(cb);
return () => unlockSubs.delete(cb);
}
export function isUnlocked(): boolean {
return !!privKey;
}
export function lock(): void {
privKey = null;
convKeys.clear();
emitUnlock();
}
// --- IndexedDB (per-device persistence of the unlocked private key) ---
const DB_NAME = "siftlode-e2ee";
const STORE = "privkeys";
function idb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function idbGet(userId: number): Promise<CryptoKey | null> {
const db = await idb();
return new Promise((resolve) => {
const r = db.transaction(STORE, "readonly").objectStore(STORE).get(userId);
r.onsuccess = () => resolve((r.result as CryptoKey) ?? null);
r.onerror = () => resolve(null);
});
}
async function idbPut(userId: number, key: CryptoKey): Promise<void> {
const db = await idb();
await new Promise<void>((resolve) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(key, userId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}
export async function clearDevice(userId: number): Promise<void> {
const db = await idb();
await new Promise<void>((resolve) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(userId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}
// --- key derivation ---
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, ["deriveKey"]);
return subtle.deriveKey(
{ name: "PBKDF2", salt: bsrc(salt), iterations: PBKDF2_ITERS, hash: "SHA-256" },
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
async function importPrivate(pkcs8: ArrayBuffer): Promise<CryptoKey> {
// Non-extractable: usable for deriveBits but can never be exported again.
return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
}
// Try to restore the unlocked key from this device's storage. Returns true if unlocked.
export async function loadFromDevice(userId: number): Promise<boolean> {
const stored = await idbGet(userId);
if (stored) {
privKey = stored;
emitUnlock();
return true;
}
return false;
}
// First-run setup: generate a keypair, wrap the private key with the passphrase, persist a
// non-extractable copy locally, and return the bundle to upload to the server.
export async function setup(userId: number, passphrase: string): Promise<KeySetup> {
const kp = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const salt = crypto.getRandomValues(new Uint8Array(16));
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
const wrapKey = await wrapKeyFrom(passphrase, salt);
const pkcs8 = await subtle.exportKey("pkcs8", kp.privateKey);
const wrapped = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(pkcs8));
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(enc.encode("ok")));
const spki = await subtle.exportKey("spki", kp.publicKey);
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
return {
public_key: b64(spki),
wrapped_private_key: b64(wrapped),
salt: b64(salt),
wrap_iv: b64(wrapIv),
key_check: b64(keyCheck),
};
}
// Unlock on a device from the server blob; throws if the passphrase is wrong.
export async function unlock(userId: number, passphrase: string, bundle: MyKeyBundle): Promise<void> {
if (!bundle.wrapped_private_key || !bundle.salt || !bundle.wrap_iv || !bundle.key_check) {
throw new Error("incomplete key bundle");
}
const wrapKey = await wrapKeyFrom(passphrase, ub64(bundle.salt));
// Wrong passphrase → AES-GCM auth tag fails here.
await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) }, wrapKey, bsrc(ub64(bundle.key_check)));
const pkcs8 = await subtle.decrypt(
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
wrapKey,
bsrc(ub64(bundle.wrapped_private_key))
);
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
}
// --- per-conversation encryption ---
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
const cached = convKeys.get(partnerId);
if (cached) return cached;
if (!privKey) throw new Error("locked");
const pub = await subtle.importKey("spki", bsrc(ub64(partnerPubB64)), { name: "ECDH", namedCurve: "P-256" }, false, []);
const bits = await subtle.deriveBits({ name: "ECDH", public: pub }, privKey, 256);
const hk = await subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
const key = await subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: bsrc(new Uint8Array(0)), info: bsrc(enc.encode(HKDF_INFO)) },
hk,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
convKeys.set(partnerId, key);
return key;
}
export async function encryptFor(
partnerId: number,
partnerPubB64: string,
plaintext: string
): Promise<{ ciphertext: string; iv: string }> {
const key = await convKeyFor(partnerId, partnerPubB64);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(iv) }, key, bsrc(enc.encode(plaintext)));
return { ciphertext: b64(ct), iv: b64(iv) };
}
export async function decryptFrom(
partnerId: number,
partnerPubB64: string,
ciphertext: string,
iv: string
): Promise<string> {
const key = await convKeyFor(partnerId, partnerPubB64);
const pt = await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(iv)) }, key, bsrc(ub64(ciphertext)));
return dec.decode(pt);
}

View file

@ -0,0 +1,85 @@
// Live message delivery over a WebSocket. The server pushes a {type:"message", message} frame
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down.
import type { Message, MessageUser } from "./api";
// A pushed message plus both parties, so the dock can open/flash the right window.
export interface IncomingMessage {
message: Message;
from?: MessageUser;
to?: MessageUser;
}
type Handler = (e: IncomingMessage) => void;
const handlers = new Set<Handler>();
let ws: WebSocket | null = null;
let started = false;
let backoff = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}/api/messages/ws`;
}
function open() {
try {
ws = new WebSocket(url());
} catch {
schedule();
return;
}
ws.onopen = () => {
backoff = 1000;
};
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data?.type === "message" && data.message) {
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
for (const h of handlers) h(e);
}
} catch {
/* ignore malformed frames */
}
};
ws.onclose = () => {
ws = null;
if (started) schedule();
};
ws.onerror = () => {
ws?.close();
};
}
function schedule() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (started) open();
}, backoff);
backoff = Math.min(backoff * 2, 30000);
}
// Subscribe to live messages. Opens the socket on the first subscriber and closes it when the
// last one leaves.
export function onMessage(h: Handler): () => void {
handlers.add(h);
if (!started) {
started = true;
open();
}
return () => {
handlers.delete(h);
if (handlers.size === 0) {
started = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ws?.close();
ws = null;
}
};
}

View file

@ -0,0 +1,36 @@
// Shared messaging helpers used by both the Messages page and the floating chat dock.
import { useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "./api";
import { isUnlocked, subscribeUnlock } from "./e2ee";
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
// Set-once cache of partner public keys (the server is the directory; keys never change).
const pubCache = new Map<number, string>();
export async function partnerPub(partnerId: number): Promise<string> {
const cached = pubCache.get(partnerId);
if (cached) return cached;
const r = await api.messagePublicKey(partnerId);
pubCache.set(partnerId, r.public_key);
return r.public_key;
}
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
export function useUnlocked(): boolean {
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
}
// The SERVER is the source of truth for whether messaging is set up. Combining it with the
// local unlock state gives the real gate: "setup" if the server has no key (even if a stale
// private key lingers in this browser — setup overwrites it), "unlock" if the server has a key
// but this device hasn't unlocked it, else "ready".
export type KeyState = "loading" | "setup" | "unlock" | "ready";
export function useKeyState(): KeyState {
const unlocked = useUnlocked();
const q = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 });
if (q.isLoading && !q.data) return "loading";
if (!(q.data?.configured ?? false)) return "setup";
if (!unlocked) return "unlock";
return "ready";
}

View file

@ -91,6 +91,7 @@ export const PAGES = [
"config",
"users",
"notifications",
"messages",
] as const;
export type Page = (typeof PAGES)[number];