diff --git a/VERSION b/VERSION
index a551051..04a373e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.15.0
+0.16.0
diff --git a/backend/alembic/versions/0026_messages.py b/backend/alembic/versions/0026_messages.py
new file mode 100644
index 0000000..0ebb22b
--- /dev/null
+++ b/backend/alembic/versions/0026_messages.py
@@ -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")
diff --git a/backend/alembic/versions/0027_message_e2ee.py b/backend/alembic/versions/0027_message_e2ee.py
new file mode 100644
index 0000000..2d72fa0
--- /dev/null
+++ b/backend/alembic/versions/0027_message_e2ee.py
@@ -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")
diff --git a/backend/app/main.py b/backend/app/main.py
index 4fc3803..f530847 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/models.py b/backend/app/models.py
index 623da7b..86226b3 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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()
+ )
diff --git a/backend/app/realtime.py b/backend/app/realtime.py
new file mode 100644
index 0000000..4899cfc
--- /dev/null
+++ b/backend/app/realtime.py
@@ -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()
diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py
new file mode 100644
index 0000000..59998a4
--- /dev/null
+++ b/backend/app/routes/messages.py
@@ -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 client→server 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)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d76b819..20c5dd3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
) : page === "notifications" ? (
+ ) : page === "messages" && !meQuery.data!.is_demo ? (
+
+
+
) : page === "settings" ? (
+ {meQuery.data && !meQuery.data.is_demo && }
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
setWizardOpen(false)} />
)}
diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx
new file mode 100644
index 0000000..87dbb0d
--- /dev/null
+++ b/frontend/src/components/ChatDock.tsx
@@ -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 (
+
+ {chats.map((c) => (
+
+ ))}
+
+ );
+}
+
+function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
+ const { t } = useTranslation();
+ return (
+
+ {/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
+ {chat.flash > 0 && (
+
+ )}
+
+
+
+
+
+ {!chat.minimized && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx
new file mode 100644
index 0000000..138eddb
--- /dev/null
+++ b/frontend/src/components/ChatThread.tsx
@@ -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>({});
+ const bottomRef = useRef(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 = {};
+ 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 (
+
+ );
+ }
+
+ return (
+ <>
+
+ {items.length === 0 ? (
+
{t("messages.threadEmpty")}
+ ) : (
+ items.map((m) => {
+ const mine = m.sender_id === meId;
+ const text = m.kind === "system" ? m.body ?? "" : plain[m.id] ?? "…";
+ return (
+
+
+ {text}
+
+ {relativeTime(m.created_at)}
+
+
+
+ );
+ })
+ )}
+
+
+
+ {isSystem ? (
+ {t("messages.systemReadOnly")}
+ ) : (
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index b0f1b4b..a7a1c26 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -79,7 +79,11 @@ export default function Header({
? t("header.configuration")
: page === "users"
? t("header.users")
- : t("header.channelManager")}
+ : page === "notifications"
+ ? t("inbox.navLabel")
+ : page === "messages"
+ ? t("messages.navLabel")
+ : t("header.channelManager")}
)}
diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx
new file mode 100644
index 0000000..6e22301
--- /dev/null
+++ b/frontend/src/components/KeyGate.tsx
@@ -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(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 {t("common.loading")}
;
+
+ return (
+
+
+
+
{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}
+
+ {!compact &&
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
}
+
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 && (
+
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 &&
{err}
}
+
+ {!configured && !compact &&
{t("messages.setupWarn")}
}
+
+ );
+}
diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx
new file mode 100644
index 0000000..231d672
--- /dev/null
+++ b/frontend/src/components/Messages.tsx
@@ -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("list");
+
+ if (typeof view === "object") {
+ return (
+ setView("list")}
+ />
+ );
+ }
+ if (view === "new") {
+ return setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />;
+ }
+ return (
+ 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>({});
+
+ useEffect(() => {
+ if (!ready) return;
+ let cancelled = false;
+ (async () => {
+ const out: Record = {};
+ 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 (
+
+
+
{t("messages.conversations")}
+ {ready && (
+
+ )}
+
+ {(keyState === "setup" || keyState === "unlock") &&
}
+ {q.isLoading && !q.data ? (
+
{t("common.loading")}
+ ) : items.length === 0 ? (
+
{t("messages.empty")}
+ ) : (
+
+ {items.map((c) => {
+ const isSystem = c.partner.id === SYSTEM_ID;
+ return (
+
+
+ {!isSystem && ready && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
{t("messages.newTitle")}
+
+
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 ? (
+
{t("common.loading")}
+ ) : users.length === 0 ? (
+
{t("messages.noPeople")}
+ ) : (
+
+ {users.map((u) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
+
{name}
+ {!isSystem &&
}
+
+ {!isSystem && ready && (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx
index 5e6251f..1822a3e 100644
--- a/frontend/src/components/NavSidebar.tsx
+++ b/frontend/src/components/NavSidebar.tsx
@@ -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") },
];
diff --git a/frontend/src/i18n/locales/de/inbox.json b/frontend/src/i18n/locales/de/inbox.json
index e0bbcb3..00df770 100644
--- a/frontend/src/i18n/locales/de/inbox.json
+++ b/frontend/src/i18n/locales/de/inbox.json
@@ -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",
diff --git a/frontend/src/i18n/locales/de/messages.json b/frontend/src/i18n/locales/de/messages.json
new file mode 100644
index 0000000..e80de55
--- /dev/null
+++ b/frontend/src/i18n/locales/de/messages.json
@@ -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."
+}
diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json
index c4f1ceb..975fe63 100644
--- a/frontend/src/i18n/locales/en/inbox.json
+++ b/frontend/src/i18n/locales/en/inbox.json
@@ -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",
diff --git a/frontend/src/i18n/locales/en/messages.json b/frontend/src/i18n/locales/en/messages.json
new file mode 100644
index 0000000..12857b3
--- /dev/null
+++ b/frontend/src/i18n/locales/en/messages.json
@@ -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."
+}
diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json
index 32d46f3..4422254 100644
--- a/frontend/src/i18n/locales/hu/inbox.json
+++ b/frontend/src/i18n/locales/hu/inbox.json
@@ -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",
diff --git a/frontend/src/i18n/locales/hu/messages.json b/frontend/src/i18n/locales/hu/messages.json
new file mode 100644
index 0000000..a7260cd
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/messages.json
@@ -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."
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 59a35fa..6a03dfe 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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 ===== */
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index e5f130c..bf6d2e6 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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 => 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 => 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 =>
+ 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) }),
diff --git a/frontend/src/lib/chatDock.ts b/frontend/src/lib/chatDock.ts
new file mode 100644
index 0000000..e1e8269
--- /dev/null
+++ b/frontend/src/lib/chatDock.ts
@@ -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[]).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
+ );
+}
diff --git a/frontend/src/lib/e2ee.ts b/frontend/src/lib/e2ee.ts
new file mode 100644
index 0000000..763858a
--- /dev/null
+++ b/frontend/src/lib/e2ee.ts
@@ -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(); // 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 {
+ 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 {
+ 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 {
+ const db = await idb();
+ await new Promise((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 {
+ const db = await idb();
+ await new Promise((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 {
+ 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 {
+ // 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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);
+}
diff --git a/frontend/src/lib/messagesSocket.ts b/frontend/src/lib/messagesSocket.ts
new file mode 100644
index 0000000..cafe909
--- /dev/null
+++ b/frontend/src/lib/messagesSocket.ts
@@ -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();
+let ws: WebSocket | null = null;
+let started = false;
+let backoff = 1000;
+let reconnectTimer: ReturnType | 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;
+ }
+ };
+}
diff --git a/frontend/src/lib/messaging.ts b/frontend/src/lib/messaging.ts
new file mode 100644
index 0000000..42e9bc7
--- /dev/null
+++ b/frontend/src/lib/messaging.ts
@@ -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();
+export async function partnerPub(partnerId: number): Promise {
+ 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";
+}
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index aedf947..3dd62a7 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,16 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.16.0",
+ date: "2026-06-26",
+ summary: "Private messaging: chat with other members in real time, end-to-end encrypted.",
+ features: [
+ "A new Messages module: send direct messages to other members of your instance. Messages are end-to-end encrypted — only you and the person you're chatting with can read them, not even an admin. The first time you open it, you set a message passphrase that protects your key; it never leaves your device.",
+ "Real-time delivery: messages arrive instantly, with an unread badge on the Messages icon. A new message pops up (or flashes) a small Messenger-style chat window in the bottom-right corner that you can keep using while you browse — minimise, close, or open several at once.",
+ "A friendly Siftlode welcome message greets you in your inbox on first arrival.",
+ ],
+ },
{
version: "0.15.0",
date: "2026-06-25",
diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts
index c80d343..85c0282 100644
--- a/frontend/src/lib/urlState.ts
+++ b/frontend/src/lib/urlState.ts
@@ -91,6 +91,7 @@ export const PAGES = [
"config",
"users",
"notifications",
+ "messages",
] as const;
export type Page = (typeof PAGES)[number];