forked from retoor/devplacepy
Updpdate
This commit is contained in:
@@ -14,6 +14,7 @@ from devplacepy.cli.containers import register_containers
|
||||
from devplacepy.cli.migrate import register_migrate
|
||||
from devplacepy.cli.game import register_game
|
||||
from devplacepy.cli.gateway import register_gateway
|
||||
from devplacepy.cli.messaging import register_messaging
|
||||
|
||||
|
||||
def build_parser():
|
||||
@@ -32,6 +33,7 @@ def build_parser():
|
||||
register_migrate(sub)
|
||||
register_game(sub)
|
||||
register_gateway(sub)
|
||||
register_messaging(sub)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_messaging_prune_tickets(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.database import get_table
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
tickets = get_table("ws_tickets")
|
||||
expired = list(tickets.find(expires_at={"<": now}))
|
||||
for ticket in expired:
|
||||
tickets.delete(uid=ticket["uid"])
|
||||
_audit_cli(
|
||||
"cli.messaging.prune_tickets",
|
||||
f"CLI pruned {len(expired)} expired WS tickets",
|
||||
metadata={"count": len(expired)},
|
||||
)
|
||||
print(f"Pruned {len(expired)} expired WS ticket(s)")
|
||||
|
||||
|
||||
def register_messaging(subparsers):
|
||||
messaging = subparsers.add_parser("messaging", help="Messaging WS ticket management")
|
||||
messaging_sub = messaging.add_subparsers(title="action", dest="action")
|
||||
messaging_prune_tickets = messaging_sub.add_parser(
|
||||
"prune-tickets", help="Delete expired WebSocket auth tickets"
|
||||
)
|
||||
messaging_prune_tickets.set_defaults(func=cmd_messaging_prune_tickets)
|
||||
@@ -57,9 +57,9 @@ four ways to sign requests.
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
False,
|
||||
"Hello there.",
|
||||
"Body, 1-2000 characters.",
|
||||
"Body, 0-2000 characters. May be empty when at least one attachment is provided.",
|
||||
),
|
||||
field(
|
||||
"receiver_uid",
|
||||
@@ -71,5 +71,38 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="messages-conversations",
|
||||
method="GET",
|
||||
path="/messages/conversations",
|
||||
title="List conversations",
|
||||
summary="Return the signed-in user's conversation list as JSON, for live refresh without a full page reload.",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
sample_response={
|
||||
"conversations": [
|
||||
{
|
||||
"other_user": {"uid": "8f14e45f-...", "username": "alice_test"},
|
||||
"last_message": "Hello there.",
|
||||
"last_message_at": "2026-07-21T10:00:00+00:00",
|
||||
"unread": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="messages-ws-ticket",
|
||||
method="POST",
|
||||
path="/messages/ws-ticket",
|
||||
title="Issue a WebSocket ticket",
|
||||
summary="Exchange the caller's session/API-key auth for a short-lived, single-use ticket that a browser WebSocket handshake can carry as a query parameter (a native WebSocket cannot set custom auth headers).",
|
||||
auth="user",
|
||||
encoding="none",
|
||||
interactive=False,
|
||||
notes=[
|
||||
"The ticket is valid for 30 seconds and can be redeemed exactly once, as `wss://.../messages/ws?ticket=<ticket>`.",
|
||||
],
|
||||
sample_response={"ticket": "3f9c2a...", "expires_in": 30},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -386,9 +386,10 @@ class ContainerScheduleForm(BaseModel):
|
||||
|
||||
|
||||
class MessageForm(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=2000)
|
||||
content: str = Field(min_length=0, max_length=2000)
|
||||
receiver_uid: str = Field(min_length=1, max_length=36)
|
||||
attachment_uids: list[str] = []
|
||||
client_id: Optional[str] = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class ProfileForm(BaseModel):
|
||||
|
||||
@@ -15,7 +15,7 @@ Prefixes are wired in `main.py`:
|
||||
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
|
||||
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
|
||||
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
|
||||
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, read receipts, and in-process presence on top of the existing `messages` table; both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live) and broadcasts the FINAL corrected/modified content (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
|
||||
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, same `get_conversations` helper as the page), `POST /send` (no-JS fallback, also accepts an attachment-only empty-content message), `POST /ws-ticket` (issues a 30s single-use WS auth ticket so a browser WebSocket can authenticate cross-context, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, and read receipts on top of the existing `messages` table - presence is NOT part of this WS (it is the generic `PresenceManager`/pub/sub mechanism, see `devplacepy/services/CLAUDE.md`). Both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live), broadcasts the FINAL corrected/modified content with an additive `ai_processed` frame flag (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
|
||||
| `/notifications` | notifications.py |
|
||||
| `/votes` | votes.py |
|
||||
| `/reactions` | reactions.py - emoji reaction toggle on any target: `POST /reactions/{target_type}/{target_uid}` (optimistic `ReactionBar`) |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
from fastapi import Depends, APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from devplacepy.models import MessageForm
|
||||
@@ -25,16 +26,18 @@ from devplacepy.utils import (
|
||||
)
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import MessagesOut
|
||||
from devplacepy.schemas import ConversationOut, MessagesOut
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.services.messaging import (
|
||||
issue_ticket,
|
||||
message_frame,
|
||||
message_hub,
|
||||
message_relay,
|
||||
persist_message,
|
||||
redeem_ticket,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -44,6 +47,18 @@ MAX_WS_ATTACHMENTS = 5
|
||||
|
||||
CONVERSATION_MESSAGE_LIMIT = 500
|
||||
|
||||
MESSAGE_GROUP_GAP_SECONDS = 300
|
||||
|
||||
def _grouped_with_previous(sender_uid, created_at, previous_sender_uid, previous_created_at) -> bool:
|
||||
if previous_sender_uid is None or sender_uid != previous_sender_uid:
|
||||
return False
|
||||
try:
|
||||
current_dt = datetime.fromisoformat(created_at)
|
||||
previous_dt = datetime.fromisoformat(previous_created_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return (current_dt - previous_dt).total_seconds() <= MESSAGE_GROUP_GAP_SECONDS
|
||||
|
||||
def mark_conversation_read(user_uid: str, other_uid: str) -> None:
|
||||
if "messages" not in db.tables:
|
||||
return
|
||||
@@ -123,6 +138,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
result = []
|
||||
msg_uids = [m["uid"] for m in msgs]
|
||||
attachments_map = get_attachments_batch("message", msg_uids) if msg_uids else {}
|
||||
previous_sender_uid = None
|
||||
previous_created_at = None
|
||||
for m in msgs:
|
||||
result.append(
|
||||
{
|
||||
@@ -131,8 +148,13 @@ def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
"is_mine": m["sender_uid"] == user_uid,
|
||||
"time_ago": time_ago(m["created_at"]),
|
||||
"attachments": attachments_map.get(m["uid"], []),
|
||||
"grouped": _grouped_with_previous(
|
||||
m["sender_uid"], m["created_at"], previous_sender_uid, previous_created_at
|
||||
),
|
||||
}
|
||||
)
|
||||
previous_sender_uid = m["sender_uid"]
|
||||
previous_created_at = m["created_at"]
|
||||
return result, other_user
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@@ -207,6 +229,19 @@ async def search_users(request: Request, q: str = ""):
|
||||
results = search_users_by_username(q, exclude_uid=user["uid"])
|
||||
return JSONResponse({"results": results})
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(request: Request):
|
||||
user = require_user(request)
|
||||
conversations = get_conversations(user["uid"])
|
||||
payload = [ConversationOut.model_validate(c).model_dump() for c in conversations]
|
||||
return JSONResponse({"conversations": payload})
|
||||
|
||||
@router.post("/ws-ticket")
|
||||
async def create_ws_ticket(request: Request):
|
||||
user = require_user(request)
|
||||
token = issue_ticket(user["uid"])
|
||||
return JSONResponse({"ticket": token, "expires_in": 30})
|
||||
|
||||
@router.post("/send")
|
||||
async def send_message(request: Request, data: Annotated[MessageForm, Depends(json_or_form(MessageForm))]):
|
||||
user = require_user(request)
|
||||
@@ -223,37 +258,54 @@ async def send_message(request: Request, data: Annotated[MessageForm, Depends(js
|
||||
if message is None:
|
||||
return action_result(request, "/messages")
|
||||
|
||||
await _finalize_and_broadcast(user, message, request)
|
||||
ai_processed = await _finalize_and_broadcast(
|
||||
user, message, request, client_id=data.client_id
|
||||
)
|
||||
frame = message_frame(
|
||||
message, user.get("username", ""), data.client_id,
|
||||
sender_role=user.get("role"), ai_processed=ai_processed,
|
||||
)
|
||||
return action_result(
|
||||
request, f"/messages?with_uid={receiver_uid}", data={"uid": message["uid"]}
|
||||
request, f"/messages?with_uid={receiver_uid}", data=frame
|
||||
)
|
||||
|
||||
async def broadcast_message(
|
||||
sender: dict, message: dict, client_id: Optional[str] = None
|
||||
sender: dict, message: dict, client_id: Optional[str] = None,
|
||||
ai_processed: bool = False,
|
||||
) -> None:
|
||||
frame = message_frame(message, sender.get("username", ""), client_id, sender_role=sender.get("role"))
|
||||
frame = message_frame(
|
||||
message, sender.get("username", ""), client_id,
|
||||
sender_role=sender.get("role"), ai_processed=ai_processed,
|
||||
)
|
||||
message_hub.mark_delivered(message["uid"])
|
||||
targets = [message["sender_uid"], message["receiver_uid"]]
|
||||
await message_hub.send_to_users(targets, frame)
|
||||
|
||||
async def _finalize_and_broadcast(
|
||||
sender: dict, message: dict, request: object, client_id: Optional[str] = None
|
||||
) -> None:
|
||||
) -> bool:
|
||||
message_hub.mark_delivered(message["uid"])
|
||||
scope = getattr(request, "scope", None)
|
||||
pending = scope.get(PENDING_SCOPE_KEY) if scope is not None else None
|
||||
ai_processed = bool(pending)
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
pending.clear()
|
||||
row = get_table("messages").find_one(uid=message["uid"])
|
||||
if row:
|
||||
message["content"] = row["content"]
|
||||
await broadcast_message(sender, message, client_id)
|
||||
await broadcast_message(sender, message, client_id, ai_processed=ai_processed)
|
||||
return ai_processed
|
||||
|
||||
def _resolve_ws_user(websocket: WebSocket):
|
||||
user = _user_from_session(websocket)
|
||||
if user:
|
||||
return user
|
||||
ticket = websocket.query_params.get("ticket", "").strip()
|
||||
if ticket:
|
||||
user_uid = redeem_ticket(ticket)
|
||||
if user_uid:
|
||||
return get_table("users").find_one(uid=user_uid)
|
||||
key = websocket.headers.get("x-api-key", "").strip()
|
||||
if not key:
|
||||
scheme, _, credentials = websocket.headers.get("authorization", "").partition(
|
||||
|
||||
@@ -70,6 +70,7 @@ class MessageItemOut(_Out):
|
||||
is_mine: bool = False
|
||||
time_ago: Optional[str] = None
|
||||
attachments: list[AttachmentOut] = []
|
||||
grouped: bool = False
|
||||
|
||||
|
||||
class NotificationItemOut(_Out):
|
||||
|
||||
@@ -79,6 +79,10 @@ Beyond the avatar, Devii has a `client`/browser channel using the same request/r
|
||||
|
||||
**Target selection (visibility-aware).** Unlike replies/traces, which `_emit` *broadcasts* to every tab, a browser request is sent to one **target** chosen by `_pick_target()`. The terminal reports each connection's `document` visibility and focus via `{"type":"visibility"}` (on connect, `focus`, `blur`, and `visibilitychange`); the session stores it in `_conn_meta` and ranks connections `(focused, visible, attach_seq)`, so the command runs on the tab the user is actually looking at, falling back to the most recently attached when none reports focus. **Regression to avoid: do NOT route to a single "last-attached primary" socket** - with the `/docs` auto-open tab or any second tab, `reload_page`/`navigate_to` ran on the wrong (hidden) tab while still reporting success, so "the reload did not happen" from the user's view. `run_js` is gated by the `devii_allow_eval` config field (default on), checked in `ClientController` before any round-trip. Overlays (`.devii-hl-box`, `.devii-hl-callout`, `.devii-toast`) attach to `document.body`, not the terminal.
|
||||
|
||||
## Stale API key auto-recovery (401 self-healing)
|
||||
|
||||
A user session captures the owner's platform `api_key` into its frozen `Settings` at build time (both `Settings.ai_key` for the gateway and `Settings.platform_api_key` for REST). Regenerating that key (profile regenerate, Devii tool, `devplace apikey reset` - possibly from another worker or process) would strand every live session with a permanent `401 Unauthorized` on all LLM and platform calls. Recovery is **reactive at the two HTTP chokepoints**, never proactive session invalidation (which is per-process and cannot reach the hub on the lock-owner worker): `hub.get_or_create` builds a `_user_key_resolver(owner_id)` (a fresh `users.api_key` DB read, user owners only - guests get `None` and keep the internal gateway key) and passes it to `LLMClient(settings, key_resolver=)` and through `DeviiSession(key_resolver=)` into `PlatformClient`. On a 401 (`LLMClient._post`) or a 401/redirect-to-login (`PlatformClient.call` via `_auth_failed`), the client calls `_refresh_key()`: resolve the current key, and only when it is non-empty AND differs from the header already sent, rewrite the auth headers and retry the request **once**. An unchanged or unresolvable key skips the retry so a genuinely invalid credential still fails closed with the original error. The resolver read is cross-worker correct because it goes straight to SQLite, not any per-process cache.
|
||||
|
||||
## Shared browser session (auth adoption)
|
||||
|
||||
The terminal and the browser are one session. `/devii/ws` resolves its owner from the browser's `session` cookie (`_resolve_ws_owner`), so the terminal is whoever the browser is logged in as. Agent-initiated auth propagates: the session watches its own trace stream (`_trace`) and, on a successful `login`/`signup`, captures the real session token the `PlatformClient` minted against this instance (`session_cookie()`) and broadcasts `{"type":"auth","action":"adopt"}`; the browser navigates to single-use `GET /devii/adopt` which sets the httpOnly `session` cookie and redirects (reload). On `logout` it broadcasts `{"type":"auth","action":"logout"}` and the browser goes to `/auth/logout`. Browser->terminal: login/logout are navigations that reconnect the WS; a cross-tab change is caught by a focus check against `/devii/session` that reloads. The httpOnly cookie is only ever set/cleared by HTTP endpoints, never JS.
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -19,7 +19,11 @@ LOGIN_PATH = "/auth/login"
|
||||
|
||||
class PlatformClient:
|
||||
def __init__(
|
||||
self, base_url: str, timeout_seconds: float, api_key: str = ""
|
||||
self,
|
||||
base_url: str,
|
||||
timeout_seconds: float,
|
||||
api_key: str = "",
|
||||
key_resolver: Callable[[], str] | None = None,
|
||||
) -> None:
|
||||
headers = {
|
||||
"User-Agent": "devii/0.1",
|
||||
@@ -29,6 +33,7 @@ class PlatformClient:
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-API-Key"] = api_key
|
||||
self._key_resolver = key_resolver
|
||||
self._client = stealth.stealth_async_client(
|
||||
base_url=base_url,
|
||||
timeout=timeout_seconds,
|
||||
@@ -106,6 +111,10 @@ class PlatformClient:
|
||||
response = await self._send(
|
||||
method, path, params=params, data=data, files=files, headers=headers
|
||||
)
|
||||
if self._auth_failed(path, response) and self._refresh_key():
|
||||
response = await self._send(
|
||||
method, path, params=params, data=data, files=files, headers=headers
|
||||
)
|
||||
|
||||
if path != LOGIN_PATH and LOGIN_PATH in str(response.url):
|
||||
self.authenticated = False
|
||||
@@ -132,6 +141,29 @@ class PlatformClient:
|
||||
)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _auth_failed(path: str, response: httpx.Response) -> bool:
|
||||
if response.status_code == 401:
|
||||
return True
|
||||
return path != LOGIN_PATH and LOGIN_PATH in str(response.url)
|
||||
|
||||
def _refresh_key(self) -> bool:
|
||||
if self._key_resolver is None:
|
||||
return False
|
||||
try:
|
||||
key = self._key_resolver()
|
||||
except Exception: # noqa: BLE001 - refresh is best-effort; the original failure still surfaces
|
||||
logger.exception("Credential refresh failed after auth failure")
|
||||
return False
|
||||
if not key or key == self._client.headers.get("X-API-Key"):
|
||||
return False
|
||||
self._client.headers["Authorization"] = f"Bearer {key}"
|
||||
self._client.headers["X-API-Key"] = key
|
||||
self.authenticated = True
|
||||
self.username = self.username or "api-key"
|
||||
logger.info("Refreshed platform credentials after auth failure")
|
||||
return True
|
||||
|
||||
def _open_upload(self, raw_path: str) -> tuple[str, bytes]:
|
||||
candidate = Path(raw_path).expanduser()
|
||||
if not candidate.is_file():
|
||||
|
||||
@@ -22,6 +22,16 @@ logger = logging.getLogger("devii.hub")
|
||||
SettingsBuilder = Callable[[str, str, str, bool], "tuple[Settings, Pricing]"]
|
||||
|
||||
|
||||
def _user_key_resolver(owner_id: str) -> Callable[[], str]:
|
||||
def resolve() -> str:
|
||||
if "users" not in db.tables:
|
||||
return ""
|
||||
row = db["users"].find_one(uid=owner_id, deleted_at=None)
|
||||
return (row or {}).get("api_key") or ""
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
class DeviiHub:
|
||||
def __init__(self, settings_builder: SettingsBuilder) -> None:
|
||||
self._build = settings_builder
|
||||
@@ -52,7 +62,10 @@ class DeviiHub:
|
||||
if session is not None:
|
||||
return session
|
||||
settings, pricing = self._build(api_key, base_url, owner_kind, is_admin)
|
||||
llm = LLMClient(settings)
|
||||
key_resolver = (
|
||||
_user_key_resolver(owner_id) if owner_kind == "user" else None
|
||||
)
|
||||
llm = LLMClient(settings, key_resolver=key_resolver)
|
||||
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
|
||||
# The `docs` channel (Docii) is a self-contained documentation assistant: it gets its own
|
||||
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
|
||||
@@ -82,6 +95,7 @@ class DeviiHub:
|
||||
is_admin=is_admin,
|
||||
is_primary_admin=is_primary_admin,
|
||||
channel=channel,
|
||||
key_resolver=key_resolver,
|
||||
)
|
||||
if owner_kind == "user":
|
||||
saved = self._stores["conversations"].load(owner_kind, owner_id, channel)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -16,8 +16,13 @@ logger = logging.getLogger("devii.llm")
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
key_resolver: Callable[[], str] | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._key_resolver = key_resolver
|
||||
self._client = stealth.stealth_async_client(
|
||||
timeout=settings.timeout_seconds,
|
||||
headers={
|
||||
@@ -29,6 +34,26 @@ class LLMClient:
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def _post(self, payload: dict[str, Any]) -> httpx.Response:
|
||||
response = await self._client.post(self._settings.ai_url, json=payload)
|
||||
if response.status_code == 401 and self._refresh_key():
|
||||
response = await self._client.post(self._settings.ai_url, json=payload)
|
||||
return response
|
||||
|
||||
def _refresh_key(self) -> bool:
|
||||
if self._key_resolver is None:
|
||||
return False
|
||||
try:
|
||||
key = self._key_resolver()
|
||||
except Exception: # noqa: BLE001 - refresh is best-effort; the original 401 still surfaces
|
||||
logger.exception("Credential refresh failed after 401")
|
||||
return False
|
||||
if not key or self._client.headers.get("Authorization") == f"Bearer {key}":
|
||||
return False
|
||||
self._client.headers["Authorization"] = f"Bearer {key}"
|
||||
logger.info("Refreshed model endpoint credentials after 401")
|
||||
return True
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -42,7 +67,7 @@ class LLMClient:
|
||||
}
|
||||
try:
|
||||
logger.debug("LLM request with %d messages", len(messages))
|
||||
response = await self._client.post(self._settings.ai_url, json=payload)
|
||||
response = await self._post(payload)
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
|
||||
|
||||
@@ -82,7 +107,7 @@ class LLMClient:
|
||||
"temperature": temperature,
|
||||
}
|
||||
try:
|
||||
response = await self._client.post(self._settings.ai_url, json=payload)
|
||||
response = await self._post(payload)
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
@@ -111,7 +136,7 @@ class LLMClient:
|
||||
"temperature": 0.0,
|
||||
}
|
||||
try:
|
||||
response = await self._client.post(self._settings.ai_url, json=payload)
|
||||
response = await self._post(payload)
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
|
||||
@@ -75,6 +75,7 @@ class DeviiSession:
|
||||
is_admin: bool = False,
|
||||
is_primary_admin: bool = False,
|
||||
channel: str = "main",
|
||||
key_resolver: Any = None,
|
||||
) -> None:
|
||||
self.owner_kind = owner_kind
|
||||
self.owner_id = owner_id
|
||||
@@ -89,7 +90,10 @@ class DeviiSession:
|
||||
self._llm = llm
|
||||
self._lessons = lessons
|
||||
self.client = PlatformClient(
|
||||
settings.base_url, settings.timeout_seconds, settings.platform_api_key
|
||||
settings.base_url,
|
||||
settings.timeout_seconds,
|
||||
settings.platform_api_key,
|
||||
key_resolver=key_resolver,
|
||||
)
|
||||
self.avatar = AvatarController()
|
||||
self.browser = ClientController(
|
||||
|
||||
@@ -4,46 +4,69 @@ This file documents the real-time direct-message chat subsystem. Claude Code aut
|
||||
|
||||
## Overview
|
||||
|
||||
The `/messages` feature is a live WebSocket chat layered over the SAME `messages` table and audit/notification cores as before; there is no parallel chat store. The WS only adds live delivery, presence, typing, and read receipts on top of the existing persistence.
|
||||
The `/messages` feature is a live WebSocket chat layered over the SAME `messages` table and audit/notification cores as before; there is no parallel chat store. The WS only adds live delivery, typing, and read receipts on top of the existing persistence - presence is NOT part of this WS (see "Presence is not part of the messaging WS" below).
|
||||
|
||||
`messages.py` (the router, mounted at `/messages`) exposes: `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests).
|
||||
`messages.py` (the router, mounted at `/messages`) exposes: `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, for a client-side refresh without a full page reload), `POST /send` (no-JS fallback, now also accepting an attachment-only empty-content message), `POST /ws-ticket` (issues a short-lived WS auth ticket), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests).
|
||||
|
||||
## Bounded page queries (load-bearing performance rule)
|
||||
|
||||
`get_conversations` is ONE window-function query (`ROW_NUMBER() OVER (PARTITION BY other_uid ORDER BY created_at DESC, id DESC)` over `sender_uid = :me OR receiver_uid = :me`, the `get_recent_comments_by_target_uids` precedent) that returns only each partner's latest row - never load the user's full message history into Python to build the sidebar. `get_conversation_messages` loads at most `CONVERSATION_MESSAGE_LIMIT` (500) most-recent rows (`ORDER BY created_at DESC, id DESC LIMIT`, reversed to ascending in Python); older history stays in the DB and is simply not rendered. Both are covered by `idx_messages_conversation`/`_rev` (verified `EXPLAIN QUERY PLAN`, MULTI-INDEX OR, no table scan). `messages` is NOT in `SOFT_DELETE_TABLES`, so neither query filters `deleted_at`. Any new message listing must stay bounded and indexed the same way.
|
||||
`get_conversations` is ONE window-function query (`ROW_NUMBER() OVER (PARTITION BY other_uid ORDER BY created_at DESC, id DESC)` over `sender_uid = :me OR receiver_uid = :me`, the `get_recent_comments_by_target_uids` precedent) that returns only each partner's latest row - never load the user's full message history into Python to build the sidebar. It backs both the server-rendered `GET /messages` page and the JSON `GET /messages/conversations` route (same helper, no duplicated query). `get_conversation_messages` loads at most `CONVERSATION_MESSAGE_LIMIT` (500) most-recent rows (`ORDER BY created_at DESC, id DESC LIMIT`, reversed to ascending in Python); older history stays in the DB and is simply not rendered. Both are covered by `idx_messages_conversation`/`_rev` (verified `EXPLAIN QUERY PLAN`, MULTI-INDEX OR, no table scan). `messages` is NOT in `SOFT_DELETE_TABLES`, so neither query filters `deleted_at`. Any new message listing must stay bounded and indexed the same way.
|
||||
|
||||
## `GET /messages/conversations`
|
||||
|
||||
Additive JSON endpoint (`require_user`), added so a frontend component can refresh the conversation list without a full page reload. It calls the exact same `get_conversations(user_uid)` helper `GET /messages` already uses, then projects each entry through `schemas.ConversationOut` (which nests `other_user` as `UserOut`, stripping `email`/`api_key`/`password_hash`) before returning `{"conversations": [...]}`. Like `GET /messages/search`, this is flat JSON, not content-negotiated through `respond()`.
|
||||
|
||||
## WS endpoint `/messages/ws`
|
||||
|
||||
Lives on the existing messages router (no new router/prefix; already mounted at `/messages`). It is **NOT lock-owner gated** (deliberately - unlike `/devii/ws`): `await websocket.accept()`, resolve the owner via session cookie / api-key (`_resolve_ws_user` reuses `utils._user_from_session`/`_user_from_api_key`), and accept on EVERY worker. Gating on `service_manager.owns_lock()` was the original design and was wrong here: when no worker holds the service lock (services disabled, or the lock not yet acquired) every socket got closed `4013` and the client fast-retried every 200ms forever (a connection storm with no stream, and the send form fell back to a full-page POST reload). Chat must not depend on the background-service lock. Messaging requires an authenticated user - guests are closed with `1008`. The receive loop is wrapped in `try/except WebSocketDisconnect` + a broad `except` (never crash the worker) and ALWAYS unregisters the socket in `finally`.
|
||||
Lives on the existing messages router (no new router/prefix; already mounted at `/messages`). It is **NOT lock-owner gated** (deliberately - unlike `/devii/ws`): `await websocket.accept()`, resolve the owner via `_resolve_ws_user`, and accept on EVERY worker. Gating on `service_manager.owns_lock()` was the original design and was wrong here: when no worker holds the service lock (services disabled, or the lock not yet acquired) every socket got closed `4013` and the client fast-retried every 200ms forever (a connection storm with no stream, and the send form fell back to a full-page POST reload). Chat must not depend on the background-service lock. Messaging requires an authenticated user - guests are closed with `1008`. The receive loop is wrapped in `try/except WebSocketDisconnect` + a broad `except` (never crash the worker) and ALWAYS unregisters the socket in `finally`.
|
||||
|
||||
### `_resolve_ws_user` auth resolution order
|
||||
|
||||
1. **Session cookie** (`_user_from_session`) - the same-origin `page` mode path; browsers send cookies on a same-origin WS upgrade, so this needs no special handling.
|
||||
2. **WS ticket** (`ticket` query param, tried AFTER the session-cookie check and BEFORE the header checks - additive, inserted here specifically so it never changes same-origin `page`-mode behavior): if present, `redeem_ticket(ticket)` looks it up; on success it resolves to the ticket's owner user row. This is the ONLY way a real browser can authenticate a cross-context (`embed` mode / third-party) WebSocket, because the native `WebSocket` constructor cannot set custom request headers - there is no way to attach `X-API-KEY`/`Authorization` to `new WebSocket(url)` in any browser. See "WS auth tickets" below for the full mechanism. If the ticket is missing/expired/already-used, resolution simply falls through to the header checks below (never a hard reject at this point - only the final `if not user` in the WS handler closes the socket).
|
||||
3. **`X-API-KEY` header**, else **`Authorization: Bearer <key>`** - the existing non-browser API-client path (unreachable from a browser's native `WebSocket`, but real and unchanged for programmatic clients that can set headers, e.g. a server-side bot).
|
||||
|
||||
## WS auth tickets (`services/messaging/tickets.py`, table `ws_tickets`)
|
||||
|
||||
Short-lived, single-use tokens that let a browser-based WebSocket authenticate without ever needing to set a request header on the handshake. `POST /messages/ws-ticket` (`require_user`, so it accepts the same HTTP-reachable auth as any other guarded route - session cookie, `X-API-KEY`, or `Authorization: Bearer` - over a normal fetch/XHR, where headers work fine) calls `issue_ticket(user_uid)` and returns `{"ticket": "<opaque hex token>", "expires_in": 30}`. The client then opens `wss://.../messages/ws?ticket=<ticket>` - a query parameter, which a WS handshake CAN carry.
|
||||
|
||||
- `issue_ticket(user_uid) -> str` inserts a `ws_tickets` row (`uid` uuid7, `token` = `secrets.token_hex(24)`, `user_uid`, `created_at`, `expires_at` = now + 30s, `used_at=None`) and returns the token.
|
||||
- `redeem_ticket(token) -> Optional[str]` looks the row up by `token`; returns `None` if missing, already used (`used_at` set), or past `expires_at`; otherwise stamps `used_at` and returns the `user_uid`. Redemption is single-use by construction - a ticket leaked via referrer or browser history cannot be replayed once redeemed, and it self-expires after 30 seconds even if never used.
|
||||
- `ws_tickets` is NOT in `SOFT_DELETE_TABLES` - it is an ephemeral, single-use artifact, garbage-collected hard via `devplace messaging prune-tickets` (deletes rows where `expires_at < now`), the same shape as the zip/fork/backup `prune` CLI commands. `database/schema.py` `init_db()` ensures its columns (`uid`, `token`, `user_uid`, `created_at`, `expires_at`, `used_at`) and a unique index on `token` (the hot lookup path) plus an index on `expires_at` (the prune sweep).
|
||||
- This is strictly additive to `_resolve_ws_user` - the existing session-cookie and header paths are completely unchanged, so `mode="page"` behavior and every existing non-browser API consumer are unaffected.
|
||||
|
||||
## Connection registry
|
||||
|
||||
The `ConnectionManager` singleton `message_hub` (`services/messaging/hub.py`), **one per worker**: `user_uid -> set[WebSocket]`, plus an in-process `last_seen` map and a bounded `delivered` LRU set (`mark_delivered`/`was_delivered`, cap 4000) used to dedupe direct vs relayed delivery. `register`/`unregister` return whether the user just came online / went offline (for presence announces). `send_to_user`/`send_to_users` fan a frame out to all of a user's sockets (multi-tab) and swallow dead-socket errors. Presence and last-seen are in-process per worker (no DB column).
|
||||
The `ConnectionManager` singleton `message_hub` (`services/messaging/hub.py`), **one per worker**: `user_uid -> set[WebSocket]`, plus a bounded (4000-entry) `delivered` LRU dedupe set (`mark_delivered`/`was_delivered`) used to dedupe direct vs relayed delivery. `register`/`unregister` track connections for delivery only - **no presence data lives here** (see below).
|
||||
|
||||
## Cross-worker delivery (the relay)
|
||||
|
||||
Because the WS accepts on every worker, a message persisted on worker A must still reach a recipient whose socket lives on worker B. `services/messaging/relay.py` `message_relay` (a per-worker singleton asyncio loop, started on the first socket connect, self-stops when `message_hub.has_connections()` is false) polls `SELECT * FROM messages WHERE id > :watermark` (uuid7-backed autoincrement `id`) every ~1s and pushes any row whose sender/receiver has a LOCAL socket and that was not already `was_delivered` to those local sockets, advancing the watermark to the max row seen. Same-worker sends are delivered INSTANTLY by `broadcast_message` (which calls `message_hub.mark_delivered` so the relay skips them); the relay only fills the cross-worker gap, so same-worker latency is zero and cross-worker latency is bounded by the poll interval. The relay primes its watermark to the current `MAX(id)` on first start so it never replays history. **Trade-off:** typing/read/presence frames are in-process per worker only - across the two `make prod` workers those ephemeral signals reach only same-worker peers (message delivery is always correct). On a single dev worker everything is instant and complete.
|
||||
Because the WS accepts on every worker, a message persisted on worker A must still reach a recipient whose socket lives on worker B. `services/messaging/relay.py` `message_relay` (a per-worker singleton asyncio loop, started on the first socket connect, self-stops when `message_hub.has_connections()` is false) polls `SELECT * FROM messages WHERE id > :watermark` (uuid7-backed autoincrement `id`) every ~1s and pushes any row whose sender/receiver has a LOCAL socket and that was not already `was_delivered` to those local sockets, advancing the watermark to the max row seen. Same-worker sends are delivered INSTANTLY by `broadcast_message` (which calls `message_hub.mark_delivered` so the relay skips them); the relay only fills the cross-worker gap, so same-worker latency is zero and cross-worker latency is bounded by the poll interval. The relay primes its watermark to the current `MAX(id)` on first start so it never replays history. **Trade-off:** typing/read frames are in-process per worker only - across the two `make prod` workers those ephemeral signals reach only same-worker peers (message delivery is always correct). On a single dev worker everything is instant and complete.
|
||||
|
||||
## DRY persist choke point
|
||||
|
||||
`services/messaging/persist.py` `persist_message(sender, receiver_uid, content, attachment_uids, *, request=None, origin)` is the ONE function that inserts the row, links attachments, fires `create_notification` + `clear_messages_cache` + `create_mention_notifications`, logs, and writes the `message.send` audit event. Both the WS `send` handler and the HTTP `POST /messages/send` handler call it, so audit/notification/mention behavior is byte-identical on both paths (the Devii `send_message` action is `handler="http"` -> `POST /messages/send`, so it also flows through here and broadcasts live). Content is capped at 2000 chars server-side. When `request` is a `Request` it audits via `audit.record`; on the WS path it now passes `request=websocket` (auditing via `audit.record_system` with `actor_kind="user"` + `origin="websocket"`, same `message.send` key/category - no new event invented; presence/typing/read are ephemeral and NOT audited).
|
||||
`services/messaging/persist.py` `persist_message(sender, receiver_uid, content, attachment_uids, *, request=None, origin)` is the ONE function that inserts the row, links attachments, fires `create_notification` + `clear_messages_cache` + `create_mention_notifications`, logs, and writes the `message.send` audit event. Both the WS `send` handler and the HTTP `POST /messages/send` handler call it, so audit/notification/mention behavior is byte-identical on both paths (the Devii `send_message` action is `handler="http"` -> `POST /messages/send`, so it also flows through here and broadcasts live). Content is capped at 2000 chars server-side; `content` itself is allowed to be empty (`MessageForm.content` is `min_length=0`) as long as at least one attachment is present - `persist_message` is the single source of truth for that rule (`if not content and not attachment_uids: return None`), so the HTTP form model deliberately does not duplicate it. Whenever `request` is not `None` it audits via `audit.record(request, ...)` - this covers BOTH the HTTP `Request` and the WS path, since the WS handler passes `request=websocket` and a `WebSocket` object is just as non-`None` as a `Request` (`audit.record` never reads HTTP-specific attributes off it beyond what's already supplied explicitly via `user=sender`). `audit.record_system(..., actor_kind="user", origin=origin)` is the fallback used ONLY when `persist_message` is called with no request/websocket context at all (e.g. a future internal/system-originated send) - same `message.send` key/category either way, no new event invented; typing/read are ephemeral and NOT audited.
|
||||
|
||||
## AI correction and AI modifier apply to direct messages, with LIVE delivery of the final content
|
||||
|
||||
`"messages": ("content",)` is in the `CORRECTABLE_FIELDS` registry and `persist_message` invokes `schedule_correction`/`schedule_modification` (sender = the user), so typing `@ai <instruction>` in a DM runs the AI modifier (default on + sync) and an enabled correction rewrites the content. Both send paths funnel through `routers/messages._finalize_and_broadcast(sender, message, request, client_id=None)`: it awaits any pending SYNC correction/modification futures stashed on `request.scope[PENDING_SCOPE_KEY]` (the same `PENDING_SCOPE_KEY`/`loop.run_in_executor` mechanism the HTTP `await_pending_corrections` middleware uses), RE-READS the message row, and broadcasts the FINAL (corrected/modified) content via `broadcast_message`. The HTTP `POST /send` and the WS `/ws` send path both call it; the WS path passes `request=websocket` to `persist_message` so the modifier stashes its executor future on the websocket scope and the handler awaits it directly (HTTP middleware does not run for websockets). A message with no `@ai` directive and correction off has nothing pending, so the broadcast is immediate with zero added latency. Frontend `static/js/MessagesLayout.js` reconciles the sender's own echo by `client_id` and replaces the optimistic bubble's `.rendered-content` with the server's authoritative `frame.content`, re-rendered - so the sender sees their `@ai` directive resolved in-place (and corrections become visible to the sender too). See "AI content correction" and "AI modifier".
|
||||
`"messages": ("content",)` is in the `CORRECTABLE_FIELDS` registry and `persist_message` invokes `schedule_correction`/`schedule_modification` (sender = the user), so typing `@ai <instruction>` in a DM runs the AI modifier (default on + sync) and an enabled correction rewrites the content. Both send paths funnel through `routers/messages._finalize_and_broadcast(sender, message, request, client_id=None)`: it reads any pending SYNC correction/modification futures stashed on `request.scope[PENDING_SCOPE_KEY]` (the same `PENDING_SCOPE_KEY`/`loop.run_in_executor` mechanism the HTTP `await_pending_corrections` middleware uses) into `ai_processed = bool(pending)` **before** awaiting/clearing them, awaits them, RE-READS the message row, and broadcasts the FINAL (corrected/modified) content via `broadcast_message(sender, message, client_id, ai_processed=ai_processed)`. The HTTP `POST /send` and the WS `/ws` send path both call it; the WS path passes `request=websocket` to `persist_message` so the modifier stashes its executor future on the websocket scope and the handler awaits it directly (HTTP middleware does not run for websockets). A message with no `@ai` directive and correction off has nothing pending, so `ai_processed` is `False` and the broadcast is immediate with zero added latency. `broadcast_message` forwards `ai_processed` straight into `message_frame(..., ai_processed=ai_processed)` - see "WS protocol frames" below for the frame shape. This flag is informational only: the server never retains the pre-AI text anywhere (`table.update` overwrites in place), so it exists purely to drive a client-side "Adjusted by AI" affordance, not any kind of diff/revert capability.
|
||||
|
||||
## WS protocol frames
|
||||
|
||||
Client -> server: `{type:"send", receiver_uid, content, attachment_uids?, client_id}`, `{type:"typing", receiver_uid}` (throttled client-side), `{type:"read", with_uid}`, `{type:"presence", with_uid}` (one-shot presence query). Server -> client: `{type:"ready", user_uid}` (sent first on connect), `{type:"message", uid, sender_uid, sender_username, receiver_uid, content, created_at, time_ago, client_id}` (broadcast to BOTH sender and receiver sockets so multi-tab and the sender's own optimistic bubble reconcile via the echoed `client_id`), `{type:"typing", from_uid}` (to the receiver only), `{type:"read", by_uid}` (read-receipt to the other user), `{type:"presence", user_uid, online, last_seen}` (announced to everyone in an open conversation with the user on connect/disconnect, and as a direct reply to a `presence` query). The WS `read` path reuses `mark_conversation_read` (the same `UPDATE messages SET read=1` SQL the page uses) and `clear_messages_cache`.
|
||||
Client -> server: `{type:"send", receiver_uid, content, attachment_uids?, client_id}`, `{type:"typing", receiver_uid}` (throttled client-side), `{type:"read", with_uid}`. Server -> client: `{type:"ready", user_uid}` (sent first on connect), `{type:"message", uid, sender_uid, sender_username, sender_role, receiver_uid, content, created_at, time_ago, client_id, attachments, ai_processed}` (broadcast to BOTH sender and receiver sockets so multi-tab and the sender's own optimistic bubble reconcile via the echoed `client_id`; `ai_processed` is additive - `true` only when the broadcast content is the result of an awaited pending correction/modifier future, so an unmodified send is `false` with zero extra computation), `{type:"typing", from_uid}` (to the receiver only), `{type:"read", by_uid}` (read-receipt to the other user), `{type:"error", client_id, text}` (sender only, on a dropped send, e.g. the recipient blocked the sender). There is no `presence` frame on this socket - see below.
|
||||
|
||||
## Presence is NOT part of the messaging WS
|
||||
|
||||
Presence is handled entirely by the generic, cross-worker-correct mechanism documented in `devplacepy/services/CLAUDE.md` ("Online presence"): a single `users.last_seen` column, written by the `track_presence` HTTP middleware, read server-side at page load via `presence.is_online(user)`, and kept live client-side by `PresenceManager` (`static/js/PresenceManager.js`) subscribing any `[data-presence-uid]` element to the pub/sub topic `public.presence.{uid}`. The messages page's `#messages-presence` span carries `data-presence-uid`/`data-presence-last-seen` like every other avatar-presence site in the app - it is not messaging-specific code. An earlier design had WS-connect-based presence living in `message_hub` (`is_online`/`last_seen`, an `_announce_presence` helper, and a WS `presence` frame); that design was per-worker only and broke with more than one uvicorn worker, so it was removed outright. `message_hub` today tracks ONLY socket connections for message delivery - it has no presence state, and there is no `presence` frame anywhere in the current WS protocol (client or server). **Never re-implement WS-connect presence on this socket** - reuse `presence.is_online`, the `public.presence.{uid}` topic, and `PresenceManager`, exactly as every other presence surface in the app does.
|
||||
|
||||
## Renderer integration (XSS control)
|
||||
|
||||
Live/echoed message bubbles are NEVER injected as raw HTML. `MessagesLayout.buildBubble` creates a `<div class="rendered-content" data-render>`, sets its `textContent` to the message body, then runs `contentRenderer.applyTo(el)` + marks `img:not(.avatar-img)` with `data-lightbox` + `contentRenderer.highlightAll()` - exactly the `ContentEnhancer.initContentRenderer` sequence (emoji shortcode -> marked -> DOMPurify.sanitize -> highlight.js -> image/YouTube/autolink). This gives Discord-style emoji, image and YouTube embeds, autolinking, and sanitization for free.
|
||||
Live/echoed message bubbles are NEVER injected as raw HTML. `AppChat._buildBubble` (`static/js/components/AppChat.js`) creates a `<dp-content>` element and sets its `textContent` to the message body - never `innerHTML` - so the element's own client-side pipeline (emoji shortcode -> marked -> `DOMPurify.sanitize` -> highlight.js -> image/YouTube/autolink) renders it safely, exactly like every other live-content surface that uses `<dp-content>`. This gives Discord-style emoji, image and YouTube embeds, autolinking, and sanitization for free.
|
||||
|
||||
## Frontend
|
||||
|
||||
`MessagesSocket.js` (mirrors `DeviiSocket.js`: reconnect with backoff, `4013` silent fast-retry, `onReady` fires on the first server frame not raw open) points at `/messages/ws`. `MessagesLayout.js` (instantiated on `app` in `Application.js`) opens the socket on the messages page, sends over WS with an optimistic pending bubble keyed by `client_id`, reconciles on the echoed `message` frame, appends incoming messages live through the renderer, auto-scrolls, throttles + shows the typing indicator, flips the read-receipt double-check, renders the presence dot / last-seen in the header, and live-updates the conversation-list preview + unread dot + ordering. If the socket is not open the send `<form method=POST>` submits normally (no-JS fallback). The template carries `data-self-uid`/`data-other-uid`/`data-other-online`/`data-other-last-seen` on `.messages-layout`. Styling (presence dot, typing dots, read-receipt, unread dot, pending opacity) is in `messages.css` using design tokens only, responsive down to 360px.
|
||||
The messaging frontend is the single self-booting custom element `<dp-chat>` (`static/js/components/AppChat.js`), which replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (all deleted) and `messages.css` (superseded by `static/css/chat.css`) - see `devplacepy/static/js/CLAUDE.md` for the component roster entry. `templates/messages.html` renders `<dp-chat mode="page" self-uid="..." with-uid="..." conversations-url="/messages/conversations" search-url="/messages/search" send-url="/messages/send" ws-url="/messages/ws" ai-indicator="true">` wrapping the SAME server-rendered `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup as before (no-JS/crawler fallback), which the component *adopts* on `connectedCallback` rather than discarding. It owns a `ChatSocket` (`static/js/chat/ChatSocket.js`, modeled on `PubSubClient.js`'s real exponential backoff instead of a flat-delay retry, same `4013` fast-path), sends over WS with an optimistic pending bubble keyed by `client_id` (the FIRST, unconditional branch of the incoming-frame handler matches `sender_uid === selfUid && client_id` against a pending bubble before any other branch - the fix for the old, permanently-disabled `appendOptimistic`), reconciles on the echoed `message` frame (falling to a `.failed`/tap-to-retry state after an 8s timeout with no echo), appends incoming messages live, auto-scrolls, throttles + shows the typing indicator, flips the read-receipt double-check, live-updates the conversation-list preview + unread dot + ordering, and groups consecutive same-sender messages within a 300s gap (`static/js/chat/MessageGrouping.js`, shared between the initial adopted history and the live-append path). If the socket is not open the send `<form method=POST>` submits normally (no-JS fallback). Presence in `mode="page"` needs no component code - the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` already drives the dot/last-seen label; `<dp-chat mode="embed">` (no page-global `PresenceManager`) opens its own scoped `PubSubClient` subscription instead. An opt-in `ai-indicator="true"` attribute shows a brief "Adjusted by AI" caption when a reconciled echo's `ai_processed` flag is true and the content differs from what was locally typed - never a diff/revert UI. Styling is in `static/css/chat.css` using `variables.css` tokens only, responsive down to 360px, with a `theme`/`accent` attribute override mechanism (scoped inline custom properties on the component's own root) for future off-site embedding.
|
||||
|
||||
## Attachments stream live
|
||||
|
||||
The single `message_frame` builder (`services/messaging/persist.py`, used by BOTH `broadcast_message` and the relay) fetches `get_attachments("message", uid)` and includes a slimmed list (`uid`/`url`/`thumbnail_url`/`is_image`/`is_video`/`original_filename`/`file_size`/`mime_type`) on every frame. `MessagesLayout.renderAttachments` mirrors `_attachment_display.html` in the DOM (image -> `img.gallery-thumb` marked `data-lightbox`+`data-full`, video -> `<video>`, else a download link) - no `innerHTML`, so it stays XSS-safe. The sender's optimistic bubble shows an "Uploading attachment..." placeholder until its own echo arrives and swaps in the real gallery. **Attachment-only messages (empty caption) are allowed**: the content input dropped `required`, `sendViaSocket` sends when content OR attachments are present, and `persist_message` accepts empty content when `attachment_uids` is non-empty (`if not content and not attachment_uids: return None`). `dp-upload` (mode `attachment`) uploads each file to `/uploads/upload` and writes the uids into its hidden `attachment_uids` input; `collectAttachments()` reads that before the send, then `dp-upload.clear()` resets it. The chat uploader sets `hide-chips` so only the `(N)` count badge shows (no filename chips) - a generic `dp-upload` attribute. The send button shows a spinner and is disabled while busy: `MessagesLayout.refreshSendButton()` ORs `_uploading` (driven by the new `dp-upload:busy` event) with `_pendingSends` (a set of in-flight send `client_id`s, cleared on the echo or an 8s safety timeout), toggling `.is-sending` + `disabled` on `.messages-send-btn`.
|
||||
The single `message_frame` builder (`services/messaging/persist.py`, used by BOTH `broadcast_message` and the relay) fetches `get_attachments("message", uid)` and includes a slimmed list (`uid`/`url`/`thumbnail_url`/`is_image`/`is_video`/`is_audio`/`original_filename`/`file_size`/`mime_type`) on every frame via `_slim_attachment`. `is_audio` is derived identically to every other attachment surface in the app (`mime_type.startswith("audio/")`, already computed by `attachments._row_to_attachment` and simply forwarded here) - `AppChat._renderAttachments` mirrors `_attachment_display.html`'s type branches (image -> `img.gallery-thumb` marked `data-lightbox`+`data-full`, video -> `<video>`, audio -> `<audio controls>`, else a download link) - no `innerHTML`, so it stays XSS-safe, and a live-delivered audio attachment now renders identically to a page-refreshed one instead of falling back to a generic download link until reload. The sender's optimistic bubble shows an "Uploading attachment(s)..." placeholder until its own echo arrives and swaps in the real gallery. **Attachment-only messages (empty caption) are allowed on both send paths**: the WS `send` handler always allowed it (it reads `content` as a raw string with no Pydantic model), and the HTTP `POST /messages/send` form now does too (`MessageForm.content` is `min_length=0`) - `persist_message` accepts empty content when `attachment_uids` is non-empty (`if not content and not attachment_uids: return None`) and is the single place that rule lives. `dp-upload` (mode `attachment`, default `show-chips` off so only the `(N)` count badge shows) uploads each file to `/uploads/upload` and writes the uids into its hidden `attachment_uids` input; `AppChat._collectAttachments()` reads that before the send, then `dp-upload.clear()` resets it. The send button shows a spinner and is disabled while busy: `AppChat._refreshSendButton()` ORs `_uploading` (driven by the `dp-upload:busy` event) with `_pendingSends` (a map of in-flight send `client_id`s, cleared on the echo or an 8s safety timeout that flips the bubble to `.failed`/tap-to-retry), toggling `.is-sending` + `disabled` on `.messages-send-btn`.
|
||||
|
||||
@@ -3,5 +3,13 @@
|
||||
from devplacepy.services.messaging.hub import message_hub
|
||||
from devplacepy.services.messaging.persist import message_frame, persist_message
|
||||
from devplacepy.services.messaging.relay import message_relay
|
||||
from devplacepy.services.messaging.tickets import issue_ticket, redeem_ticket
|
||||
|
||||
__all__ = ["message_frame", "message_hub", "message_relay", "persist_message"]
|
||||
__all__ = [
|
||||
"issue_ticket",
|
||||
"message_frame",
|
||||
"message_hub",
|
||||
"message_relay",
|
||||
"persist_message",
|
||||
"redeem_ticket",
|
||||
]
|
||||
|
||||
@@ -30,6 +30,7 @@ def _slim_attachment(attachment: dict[str, Any]) -> dict[str, Any]:
|
||||
"thumbnail_url": attachment.get("thumbnail_url"),
|
||||
"is_image": bool(attachment.get("is_image")),
|
||||
"is_video": bool(attachment.get("is_video")),
|
||||
"is_audio": bool(attachment.get("is_audio")),
|
||||
"original_filename": attachment.get("original_filename", ""),
|
||||
"file_size": attachment.get("file_size", 0),
|
||||
"mime_type": attachment.get("mime_type", ""),
|
||||
@@ -38,7 +39,7 @@ def _slim_attachment(attachment: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def message_frame(
|
||||
message: dict[str, Any], sender_username: str, client_id: Optional[str] = None,
|
||||
sender_role: Optional[str] = None,
|
||||
sender_role: Optional[str] = None, ai_processed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
attachments = get_attachments("message", message["uid"])
|
||||
return {
|
||||
@@ -53,6 +54,7 @@ def message_frame(
|
||||
"time_ago": time_ago(message["created_at"]),
|
||||
"client_id": client_id,
|
||||
"attachments": [_slim_attachment(a) for a in attachments],
|
||||
"ai_processed": ai_processed,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
TICKET_KEY_BYTES = 24
|
||||
TICKET_TTL_SECONDS = 30
|
||||
|
||||
|
||||
def issue_ticket(user_uid: str) -> str:
|
||||
tickets = get_table("ws_tickets")
|
||||
token = secrets.token_hex(TICKET_KEY_BYTES)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = (now + timedelta(seconds=TICKET_TTL_SECONDS)).isoformat()
|
||||
tickets.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"token": token,
|
||||
"user_uid": user_uid,
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"used_at": None,
|
||||
}
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def redeem_ticket(token: str) -> Optional[str]:
|
||||
if not token:
|
||||
return None
|
||||
from sqlalchemy import text
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with db:
|
||||
result = db.executable.execute(
|
||||
text(
|
||||
"UPDATE ws_tickets SET used_at = :now"
|
||||
" WHERE token = :token AND used_at IS NULL AND expires_at >= :now"
|
||||
),
|
||||
{"now": now, "token": token},
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
return None
|
||||
tickets = get_table("ws_tickets")
|
||||
row = tickets.find_one(token=token)
|
||||
return row.get("user_uid") if row else None
|
||||
@@ -8,16 +8,37 @@
|
||||
--kb-inset: 0px;
|
||||
}
|
||||
|
||||
dp-chat {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messages-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 320px) 1fr;
|
||||
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
--kb-inset: 0px;
|
||||
}
|
||||
|
||||
dp-chat[mode="embed"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
--kb-inset: 0px;
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
@@ -29,7 +50,7 @@
|
||||
}
|
||||
|
||||
.messages-list-header {
|
||||
padding: 1rem;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
@@ -56,8 +77,8 @@
|
||||
.search-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 1rem;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
@@ -84,8 +105,8 @@
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -125,15 +146,16 @@
|
||||
.messages-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-main-header {
|
||||
padding: 1rem;
|
||||
padding: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -154,7 +176,7 @@
|
||||
}
|
||||
|
||||
.messages-presence.online {
|
||||
color: var(--success, var(--accent));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.messages-presence.online::before {
|
||||
@@ -163,7 +185,7 @@
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success, var(--accent));
|
||||
background: var(--success);
|
||||
margin-right: 0.3125rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -181,78 +203,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-receipt {
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-left: 0.375rem;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.message-receipt[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-bubble.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.attachment-pending {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-card-hover);
|
||||
border-radius: var(--radius-lg);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: typing-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-thread {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding: var(--space-lg);
|
||||
padding-bottom: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: var(--space-md);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -264,6 +222,11 @@
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
overflow-wrap: anywhere;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.message-bubble.grouped {
|
||||
margin-top: calc(var(--space-xs) - var(--space-md));
|
||||
}
|
||||
|
||||
.message-bubble.mine {
|
||||
@@ -280,24 +243,134 @@
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-bubble.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.message-bubble.failed {
|
||||
outline: 1px solid var(--danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retry-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.message-bubble.mine .content-copy-btn,
|
||||
.message-bubble.theirs .content-copy-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.message-bubble:hover .content-copy-btn,
|
||||
.message-bubble:focus-within .content-copy-btn,
|
||||
.message-bubble .content-copy-btn:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.message-bubble.grouped .message-time {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.message-bubble.grouped:hover .message-time,
|
||||
.message-bubble.grouped:focus-within .message-time {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.message-bubble.mine .message-time {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.message-receipt {
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-left: 0.375rem;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.message-receipt[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-adjusted-note {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
opacity: 1;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.ai-adjusted-note.fading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.attachment-pending {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg-card-hover);
|
||||
border-radius: var(--radius-lg);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: chat-typing-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes chat-typing-bounce {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-input-area {
|
||||
padding: 0.75rem 1rem;
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
min-height: 56px;
|
||||
background: var(--bg-card);
|
||||
@@ -311,7 +384,7 @@
|
||||
overflow-y: auto;
|
||||
max-height: 160px;
|
||||
line-height: 1.5;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
border-radius: var(--radius);
|
||||
@@ -321,6 +394,13 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@supports (field-sizing: content) {
|
||||
.messages-input-area textarea {
|
||||
field-sizing: content;
|
||||
min-height: 1lh;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-input-area textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -340,7 +420,7 @@
|
||||
flex: 0 0 100%;
|
||||
order: -1;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.messages-send-btn {
|
||||
@@ -368,7 +448,7 @@
|
||||
border: 2px solid var(--white);
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: messages-send-spin 0.6s linear infinite;
|
||||
animation: chat-send-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.messages-send-btn.is-sending {
|
||||
@@ -388,7 +468,7 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@keyframes messages-send-spin {
|
||||
@keyframes chat-send-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -401,7 +481,7 @@
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.messages-empty p {
|
||||
@@ -414,27 +494,28 @@
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
padding: 0.25rem;
|
||||
padding: var(--space-xs);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
margin-right: 0.25rem;
|
||||
margin-right: var(--space-xs);
|
||||
}
|
||||
|
||||
.messages-back-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Mobile (≤ 768px) ───────────────────────────────────── */
|
||||
/* Switches layout from grid to flex column so a single visible
|
||||
child (conversation list OR chat pane) always fills the viewport.
|
||||
The --kb-inset is applied here so the keyboard never obscures
|
||||
the input bar. */
|
||||
/* ── Mobile (<= 768px) ─────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-messages {
|
||||
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
|
||||
}
|
||||
|
||||
.messages-layout,
|
||||
dp-chat[mode="embed"] {
|
||||
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
|
||||
}
|
||||
|
||||
.messages-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -472,17 +553,33 @@
|
||||
.messages-input-area textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.message-bubble.grouped .message-time {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Small mobile (≤ 360px) ─────────────────────────────── */
|
||||
/* ── Small mobile (<= 480px) ────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.message-bubble {
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
.messages-main-header {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Smallest mobile (<= 360px) ─────────────────────────── */
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.messages-thread {
|
||||
padding: 0.5rem;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.messages-input-area {
|
||||
padding: 0.375rem 0.5rem;
|
||||
padding: 0.375rem var(--space-sm);
|
||||
padding-bottom: calc(0.375rem + env(safe-area-inset-bottom));
|
||||
min-height: 48px;
|
||||
}
|
||||
@@ -493,7 +590,7 @@
|
||||
|
||||
.message-bubble {
|
||||
max-width: 90%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
padding: var(--space-sm) 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@@ -502,11 +599,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Touch devices: larger send target ──────────────────── */
|
||||
/* ── Touch devices: larger tap targets (min 44x44px) ────── */
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.messages-send-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.messages-back-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.message-bubble .content-copy-btn {
|
||||
opacity: 1;
|
||||
min-width: 44px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.message-bubble.grouped .message-time {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { ModalManager } from "./ModalManager.js";
|
||||
import { FormManager } from "./FormManager.js";
|
||||
import { VoteManager } from "./VoteManager.js";
|
||||
import { NotificationManager } from "./NotificationManager.js";
|
||||
import { MessageSearch } from "./MessageSearch.js";
|
||||
import { MessagesLayout } from "./MessagesLayout.js";
|
||||
import { ProfileEditor } from "./ProfileEditor.js";
|
||||
import { MobileNav } from "./MobileNav.js";
|
||||
import { CommentManager } from "./CommentManager.js";
|
||||
@@ -74,8 +72,6 @@ class Application {
|
||||
this.forms = new FormManager();
|
||||
this.votes = new VoteManager();
|
||||
this.notifications = new NotificationManager();
|
||||
this.messageSearch = new MessageSearch();
|
||||
this.messagesLayout = new MessagesLayout();
|
||||
this.profile = new ProfileEditor();
|
||||
this.mobileNav = new MobileNav();
|
||||
this.comments = new CommentManager();
|
||||
|
||||
@@ -14,7 +14,11 @@ This file documents JS module organization, custom web components, and shared fr
|
||||
## Custom web components (`static/js/components/`)
|
||||
|
||||
Self-contained, presentational UI is built as custom elements with the `dp-` prefix:
|
||||
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`.
|
||||
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`, `dp-chat`.
|
||||
|
||||
`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence for `mode="page"` needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it; `dp-chat` opens its own scoped `PubSubClient` subscription only in `mode="embed"`, where no page-global instance exists. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.
|
||||
|
||||
`static/js/chat/ChatSocket.js` and `static/js/chat/MessageGrouping.js` are chat-scoped helpers used only by `AppChat.js` - `ChatSocket` is modeled on `PubSubClient.js`'s real exponential backoff (200ms doubling to a 5000ms cap, reset on a successful connect) rather than the flat-delay retry the deleted `MessagesSocket.js` used, and preserves the same `4013` "wrong worker" fast-retry special case. `MessageGrouping.shouldGroup(previous, current)` is the one pure predicate (<=300s gap, same sender) shared by both the initial-history grouping pass and the live-append path, so the two can never disagree. Neither file is a general-purpose "do not reimplement" utility for the rest of the app (see the out-of-scope note in the design document this shipped from) - they are not added to the do-not-reimplement list below; a future non-chat WebSocket feature should keep hand-rolling its own client (as `DeviiSocket.js`/`DeepsearchProgressSocket.js`/`SeoProgressSocket.js`/`AppDeepsearchChat.js`'s inline socket already do) rather than reaching into `chat/`.
|
||||
|
||||
Conventions:
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { Avatar } from "./Avatar.js";
|
||||
import { DomUtils } from "./DomUtils.js";
|
||||
import { ListNav } from "./ListNav.js";
|
||||
|
||||
export class MessageSearch {
|
||||
constructor() {
|
||||
this.initMessageSearch();
|
||||
}
|
||||
|
||||
initMessageSearch() {
|
||||
const searchInput = document.getElementById("message-search");
|
||||
if (!searchInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = searchInput.parentElement;
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "search-dropdown";
|
||||
dropdown.setAttribute("role", "listbox");
|
||||
wrap.appendChild(dropdown);
|
||||
|
||||
const hide = () => {
|
||||
DomUtils.hide(dropdown);
|
||||
nav.closed();
|
||||
};
|
||||
|
||||
const nav = new ListNav(searchInput, dropdown, {
|
||||
itemSelector: ".search-dropdown-item",
|
||||
activeClass: "active",
|
||||
chooseOnTab: false,
|
||||
isOpen: () => DomUtils.isShown(dropdown),
|
||||
onChoose: (item) => { window.location.href = item.href; },
|
||||
onEscape: hide,
|
||||
});
|
||||
|
||||
let debounceTimer = null;
|
||||
|
||||
searchInput.addEventListener("input", () => {
|
||||
clearTimeout(debounceTimer);
|
||||
const q = searchInput.value.trim();
|
||||
if (q.length < 1) {
|
||||
dropdown.innerHTML = "";
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
dropdown.innerHTML = "";
|
||||
for (const r of results) {
|
||||
const item = document.createElement("a");
|
||||
item.className = "search-dropdown-item";
|
||||
item.href = `/messages?with_uid=${r.uid}`;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = r.username;
|
||||
item.append(Avatar.imgElement(r.username), label);
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
DomUtils.show(dropdown);
|
||||
nav.opened();
|
||||
} catch (e) {
|
||||
hide();
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!wrap.contains(e.target)) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { MessagesSocket } from "./MessagesSocket.js";
|
||||
import { contentRenderer } from "./ContentRenderer.js";
|
||||
|
||||
const TYPING_THROTTLE_MS = 1500;
|
||||
const TYPING_HIDE_MS = 4000;
|
||||
const AUTO_SCROLL_MARGIN_PX = 100;
|
||||
const STABILIZE_MAX_FRAMES = 300;
|
||||
|
||||
export class MessagesLayout {
|
||||
constructor() {
|
||||
this.layout = document.querySelector(".messages-layout");
|
||||
if (!this.layout) {
|
||||
return;
|
||||
}
|
||||
this.thread = document.querySelector(".messages-thread");
|
||||
this.form = document.querySelector(".messages-input-area");
|
||||
this.input = this.form ? this.form.querySelector('input[name="content"]') : null;
|
||||
this.upload = this.form ? this.form.querySelector("dp-upload") : null;
|
||||
this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
|
||||
this._uploading = false;
|
||||
this._pendingSends = new Set();
|
||||
this.typingEl = document.getElementById("typing-indicator");
|
||||
|
||||
this.selfUid = this.layout.dataset.selfUid || "";
|
||||
this.otherUid = this.layout.dataset.otherUid || "";
|
||||
|
||||
this._lastTypingSent = 0;
|
||||
this._typingHideTimer = null;
|
||||
this._socketReady = false;
|
||||
this._userAtBottom = true;
|
||||
this._stabilizeFrames = 0;
|
||||
this._stabilizePending = false;
|
||||
|
||||
// Expose for diagnostics
|
||||
window.__messagesLayout = this;
|
||||
|
||||
this.scrollThreadToEnd();
|
||||
if (this.input) {
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
this.connect();
|
||||
this.bindForm();
|
||||
this.bindTyping();
|
||||
this.bindViewport();
|
||||
this.bindAutoScroll();
|
||||
this._startScrollWatcher();
|
||||
this.markRead();
|
||||
}
|
||||
|
||||
_startScrollWatcher() {
|
||||
if (!this.thread) return;
|
||||
|
||||
const onAnyChange = () => {
|
||||
if (!this._userAtBottom) return;
|
||||
if (this._stabilizePending) return;
|
||||
this._stabilizePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._stabilizePending = false;
|
||||
this._stabilizeScroll();
|
||||
});
|
||||
};
|
||||
|
||||
this._scrollWatcher = new MutationObserver(onAnyChange);
|
||||
this._scrollWatcher.observe(this.thread, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
this.thread.addEventListener("load", (e) => {
|
||||
if (e.target.tagName === "IMG" && this._userAtBottom) {
|
||||
onAnyChange();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Kick initial stabilization
|
||||
onAnyChange();
|
||||
}
|
||||
|
||||
_stabilizeScroll() {
|
||||
if (!this.thread) {
|
||||
this._stabilizeFrames = 0;
|
||||
return;
|
||||
}
|
||||
if (!this._userAtBottom) {
|
||||
this._stabilizeFrames = 0;
|
||||
return;
|
||||
}
|
||||
if (this._stabilizeFrames >= STABILIZE_MAX_FRAMES) {
|
||||
this._stabilizeFrames = 0;
|
||||
return;
|
||||
}
|
||||
this._stabilizeFrames++;
|
||||
this.thread.scrollTop = this.thread.scrollHeight;
|
||||
const atBottom = this.thread.scrollHeight
|
||||
- this.thread.scrollTop
|
||||
- this.thread.clientHeight < 10;
|
||||
if (!atBottom) {
|
||||
this._stabilizePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._stabilizePending = false;
|
||||
this._stabilizeScroll();
|
||||
});
|
||||
} else {
|
||||
this._stabilizeFrames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bindAutoScroll() {
|
||||
if (!this.thread) return;
|
||||
this.thread.addEventListener("scroll", () => {
|
||||
if (this._stabilizePending) return;
|
||||
const atBottom = this.thread.scrollHeight
|
||||
- this.thread.scrollTop
|
||||
- this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
|
||||
this._userAtBottom = atBottom;
|
||||
if (atBottom) {
|
||||
this._stabilizeScroll();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
bindViewport() {
|
||||
const page = document.querySelector(".page-messages");
|
||||
if (!page || !this.input || !this.form) return;
|
||||
|
||||
const viewport = window.visualViewport;
|
||||
|
||||
const ensureInputVisible = () => {
|
||||
const vh = viewport ? viewport.height : window.innerHeight;
|
||||
const formRect = this.form.getBoundingClientRect();
|
||||
const currentInset = parseInt(page.style.getPropertyValue("--kb-inset")) || 0;
|
||||
const delta = formRect.bottom - vh;
|
||||
const newInset = Math.max(0, currentInset + delta);
|
||||
|
||||
if (newInset !== currentInset) {
|
||||
page.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
|
||||
this._userAtBottom = true;
|
||||
this.scrollThreadToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
if (viewport) {
|
||||
let insetTimer = null;
|
||||
const onViewportChange = () => {
|
||||
cancelAnimationFrame(insetTimer);
|
||||
insetTimer = requestAnimationFrame(ensureInputVisible);
|
||||
};
|
||||
viewport.addEventListener("resize", onViewportChange);
|
||||
viewport.addEventListener("scroll", onViewportChange);
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => ensureInputVisible());
|
||||
ro.observe(page);
|
||||
if (this.layout) ro.observe(this.layout);
|
||||
|
||||
if (this.form) {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) ensureInputVisible();
|
||||
}
|
||||
}, { root: null, threshold: [0, 0.5, 1] });
|
||||
io.observe(this.form);
|
||||
this._viewportIO = io;
|
||||
}
|
||||
|
||||
this.input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
this.input.addEventListener("input", () => {
|
||||
this.input.style.height = "auto";
|
||||
this.input.style.height = Math.min(this.input.scrollHeight, 160) + "px";
|
||||
});
|
||||
|
||||
this.input.addEventListener("focus", () => {
|
||||
this._userAtBottom = true;
|
||||
const doScroll = () => {
|
||||
this.scrollThreadToEnd();
|
||||
if (window.innerWidth < 768) {
|
||||
const retry = (delay) => setTimeout(() => {
|
||||
ensureInputVisible();
|
||||
this.scrollThreadToEnd();
|
||||
}, delay);
|
||||
retry(100);
|
||||
retry(350);
|
||||
retry(600);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(doScroll);
|
||||
});
|
||||
|
||||
this.input.addEventListener("blur", () => {
|
||||
page.style.setProperty("--kb-inset", "0px");
|
||||
});
|
||||
|
||||
ensureInputVisible();
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.socket = new MessagesSocket({
|
||||
onReady: () => this.onReady(),
|
||||
onMessage: (frame) => this.onFrame(frame),
|
||||
onClose: () => { this._socketReady = false; },
|
||||
});
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
onReady() {
|
||||
this._socketReady = true;
|
||||
if (this.otherUid) {
|
||||
this.markRead();
|
||||
}
|
||||
}
|
||||
|
||||
onFrame(frame) {
|
||||
switch (frame.type) {
|
||||
case "message":
|
||||
this.handleIncoming(frame);
|
||||
break;
|
||||
case "typing":
|
||||
if (frame.from_uid === this.otherUid) this.showTyping();
|
||||
break;
|
||||
case "read":
|
||||
if (frame.by_uid === this.otherUid) this.markReceipts();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bindForm() {
|
||||
if (!this.form || !this.input) return;
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
if (!this._socketReady || !this.socket.isOpen()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.sendViaSocket();
|
||||
});
|
||||
if (this.upload) {
|
||||
this.upload.addEventListener("dp-upload:busy", (event) => {
|
||||
this._uploading = !!(event.detail && event.detail.busy);
|
||||
this.refreshSendButton();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshSendButton() {
|
||||
if (!this.sendBtn) return;
|
||||
const busy = this._uploading || this._pendingSends.size > 0;
|
||||
this.sendBtn.disabled = busy;
|
||||
this.sendBtn.classList.toggle("is-sending", busy);
|
||||
}
|
||||
|
||||
sendViaSocket() {
|
||||
const content = (this.input.value || "").trim();
|
||||
const attachmentUids = this.collectAttachments();
|
||||
if (!content && attachmentUids.length === 0) return;
|
||||
const clientId = "c" + Date.now() + Math.random().toString(36).slice(2, 8);
|
||||
this.appendOptimistic(content, clientId, attachmentUids.length);
|
||||
const ok = this.socket.send({
|
||||
type: "send",
|
||||
receiver_uid: this.otherUid,
|
||||
content,
|
||||
attachment_uids: attachmentUids,
|
||||
client_id: clientId,
|
||||
});
|
||||
if (!ok) {
|
||||
this.form.submit();
|
||||
return;
|
||||
}
|
||||
this._pendingSends.add(clientId);
|
||||
this.refreshSendButton();
|
||||
window.setTimeout(() => this.clearPendingSend(clientId), 8000);
|
||||
this.input.value = "";
|
||||
if (this.upload && typeof this.upload.clear === "function") {
|
||||
this.upload.clear();
|
||||
}
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
clearPendingSend(clientId) {
|
||||
if (this._pendingSends.delete(clientId)) {
|
||||
this.refreshSendButton();
|
||||
}
|
||||
}
|
||||
|
||||
collectAttachments() {
|
||||
const hidden = this.form.querySelector('input[name="attachment_uids"]');
|
||||
if (!hidden || !hidden.value) return [];
|
||||
return hidden.value.split(",").map((v) => v.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
bindTyping() {
|
||||
if (!this.input) return;
|
||||
this.input.addEventListener("input", () => {
|
||||
if (!this._socketReady || !this.otherUid) return;
|
||||
const now = Date.now();
|
||||
if (now - this._lastTypingSent < TYPING_THROTTLE_MS) return;
|
||||
this._lastTypingSent = now;
|
||||
this.socket.send({ type: "typing", receiver_uid: this.otherUid });
|
||||
});
|
||||
}
|
||||
|
||||
appendOptimistic(content, clientId, attachmentCount) {
|
||||
// Results in double message. Should be fixed in future..
|
||||
/*
|
||||
const bubble = this.buildBubble({
|
||||
content,
|
||||
mine: true,
|
||||
clientId,
|
||||
time: "now",
|
||||
iso: new Date().toISOString(),
|
||||
attachmentCount,
|
||||
});
|
||||
bubble.classList.add("pending");*/
|
||||
//this.insertBubble(bubble);
|
||||
}
|
||||
|
||||
handleIncoming(frame) {
|
||||
if (frame.uid && this.thread &&
|
||||
this.thread.querySelector(`.message-bubble[data-msg-uid="${frame.uid}"]`)) {
|
||||
if (frame.client_id) this.clearPendingSend(frame.client_id);
|
||||
return;
|
||||
}
|
||||
if (frame.sender_uid === this.selfUid && frame.client_id) {
|
||||
this.clearPendingSend(frame.client_id);
|
||||
const pending = this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`);
|
||||
if (pending) {
|
||||
pending.classList.remove("pending");
|
||||
pending.dataset.msgUid = frame.uid;
|
||||
const oldBody = pending.querySelector(".rendered-content");
|
||||
if (oldBody && frame.content !== undefined) {
|
||||
const content = document.createElement("dp-content");
|
||||
content.setAttribute("no-copy", "");
|
||||
if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
|
||||
content.textContent = frame.content || "";
|
||||
oldBody.replaceWith(content);
|
||||
}
|
||||
const time = pending.querySelector(".message-time");
|
||||
if (time && frame.created_at) {
|
||||
time.setAttribute("datetime", frame.created_at);
|
||||
time.dataset.dt = "";
|
||||
time.dataset.dtMode = "ago";
|
||||
if (window.app && window.app.localTime) window.app.localTime.apply(time);
|
||||
else time.textContent = frame.time_ago;
|
||||
} else if (time) {
|
||||
time.textContent = frame.time_ago;
|
||||
}
|
||||
const placeholder = pending.querySelector(".attachment-pending");
|
||||
if (placeholder) placeholder.remove();
|
||||
const gallery = this.renderAttachments(frame.attachments);
|
||||
if (gallery) pending.insertBefore(gallery, time || null);
|
||||
this.bumpConversation(frame, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const partnerUid = frame.sender_uid === this.selfUid ? frame.receiver_uid : frame.sender_uid;
|
||||
const inThisThread = this.otherUid && partnerUid === this.otherUid;
|
||||
|
||||
if (inThisThread) {
|
||||
const mine = frame.sender_uid === this.selfUid;
|
||||
const bubble = this.buildBubble({
|
||||
content: frame.content,
|
||||
mine,
|
||||
time: frame.time_ago,
|
||||
iso: frame.created_at,
|
||||
uid: frame.uid,
|
||||
senderRole: frame.sender_role,
|
||||
attachments: frame.attachments,
|
||||
});
|
||||
this.insertBubble(bubble);
|
||||
if (!mine && this._socketReady) {
|
||||
this.socket.send({ type: "read", with_uid: this.otherUid });
|
||||
}
|
||||
}
|
||||
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
|
||||
}
|
||||
|
||||
buildBubble({ content, mine, time, iso, uid, clientId, senderRole, attachments, attachmentCount }) {
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
|
||||
if (uid) bubble.dataset.msgUid = uid;
|
||||
if (clientId) bubble.dataset.clientId = clientId;
|
||||
|
||||
const el = document.createElement("dp-content");
|
||||
el.setAttribute("no-copy", "");
|
||||
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
|
||||
el.textContent = content || "";
|
||||
bubble.appendChild(el);
|
||||
|
||||
const gallery = this.renderAttachments(attachments);
|
||||
if (gallery) {
|
||||
bubble.appendChild(gallery);
|
||||
} else if (attachmentCount > 0) {
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "attachment-pending";
|
||||
placeholder.textContent = attachmentCount === 1
|
||||
? "Uploading attachment..."
|
||||
: `Uploading ${attachmentCount} attachments...`;
|
||||
bubble.appendChild(placeholder);
|
||||
}
|
||||
|
||||
const timeEl = document.createElement(iso ? "time" : "span");
|
||||
timeEl.className = "message-time";
|
||||
if (iso) {
|
||||
timeEl.setAttribute("datetime", iso);
|
||||
timeEl.dataset.dt = "";
|
||||
timeEl.dataset.dtMode = "ago";
|
||||
if (window.app && window.app.localTime) window.app.localTime.apply(timeEl);
|
||||
else timeEl.textContent = time || "";
|
||||
} else {
|
||||
timeEl.textContent = time || "";
|
||||
}
|
||||
bubble.appendChild(timeEl);
|
||||
|
||||
if (mine) {
|
||||
const receipt = document.createElement("span");
|
||||
receipt.className = "message-receipt";
|
||||
receipt.hidden = true;
|
||||
receipt.innerHTML = "✓✓";
|
||||
bubble.appendChild(receipt);
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
renderAttachments(attachments) {
|
||||
if (!attachments || !attachments.length) return null;
|
||||
const gallery = document.createElement("div");
|
||||
gallery.className = "attachment-gallery";
|
||||
for (const att of attachments) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "attachment-gallery-item";
|
||||
if (att.is_image) {
|
||||
const img = document.createElement("img");
|
||||
img.src = att.thumbnail_url || att.url;
|
||||
img.alt = att.original_filename || "";
|
||||
img.loading = "lazy";
|
||||
img.className = "gallery-thumb";
|
||||
img.dataset.lightbox = "";
|
||||
img.dataset.full = att.url;
|
||||
if (att.mime_type) img.dataset.mime = att.mime_type;
|
||||
item.appendChild(img);
|
||||
} else if (att.is_video) {
|
||||
const video = document.createElement("video");
|
||||
video.src = att.url;
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
video.className = "gallery-video";
|
||||
item.appendChild(video);
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.href = att.url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener";
|
||||
link.className = "non-image";
|
||||
link.download = att.original_filename || "file";
|
||||
link.textContent = att.original_filename || "file";
|
||||
item.appendChild(link);
|
||||
}
|
||||
gallery.appendChild(item);
|
||||
}
|
||||
return gallery;
|
||||
}
|
||||
|
||||
insertBubble(bubble) {
|
||||
if (!this.thread) return;
|
||||
if (this.typingEl && this.typingEl.parentElement === this.thread) {
|
||||
this.thread.insertBefore(bubble, this.typingEl);
|
||||
} else {
|
||||
this.thread.appendChild(bubble);
|
||||
}
|
||||
this.scrollThreadToEnd();
|
||||
}
|
||||
|
||||
markReceipts() {
|
||||
this.thread.querySelectorAll(".message-bubble.mine .message-receipt").forEach((el) => {
|
||||
el.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
markRead() {
|
||||
if (this._socketReady && this.otherUid) {
|
||||
this.socket.send({ type: "read", with_uid: this.otherUid });
|
||||
}
|
||||
}
|
||||
|
||||
showTyping() {
|
||||
if (!this.typingEl) return;
|
||||
this.typingEl.hidden = false;
|
||||
this.scrollThreadToEnd();
|
||||
clearTimeout(this._typingHideTimer);
|
||||
this._typingHideTimer = setTimeout(() => {
|
||||
this.typingEl.hidden = true;
|
||||
}, TYPING_HIDE_MS);
|
||||
}
|
||||
|
||||
bumpConversation(frame, mine) {
|
||||
const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
|
||||
const item = document.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
|
||||
if (!item) return;
|
||||
const preview = item.querySelector(".conversation-preview");
|
||||
if (preview) preview.textContent = contentRenderer.preview(frame.content, 60);
|
||||
const dot = item.querySelector(".conversation-unread-dot");
|
||||
if (dot) {
|
||||
dot.hidden = mine || partnerUid === this.otherUid;
|
||||
}
|
||||
const list = item.parentElement;
|
||||
if (list && list.firstElementChild !== item) {
|
||||
list.insertBefore(item, list.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
scrollThreadToEnd() {
|
||||
if (!this.thread) return;
|
||||
if (!this._userAtBottom) return;
|
||||
this.thread.scrollTop = this.thread.scrollHeight;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const RECONNECT_DELAY_MS = 1500;
|
||||
const WRONG_WORKER_RETRY_MS = 200;
|
||||
const WRONG_WORKER_CODE = 4013;
|
||||
|
||||
export class MessagesSocket {
|
||||
constructor(handlers) {
|
||||
this.handlers = handlers || {};
|
||||
this.ws = null;
|
||||
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/messages/ws";
|
||||
this._shouldRun = false;
|
||||
this._settled = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this._shouldRun = true;
|
||||
this._open();
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
_open() {
|
||||
this._settled = false;
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.addEventListener("open", () => this._emit("onOpen"));
|
||||
this.ws.addEventListener("close", (event) => {
|
||||
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
|
||||
if (!transient) this._emit("onClose");
|
||||
if (this._shouldRun) {
|
||||
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
|
||||
window.setTimeout(() => this._open(), delay);
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener("error", () => this._emit("onError"));
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!this._settled) {
|
||||
this._settled = true;
|
||||
this._emit("onReady", payload);
|
||||
}
|
||||
this._emit("onMessage", payload);
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.isOpen()) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
close() {
|
||||
this._shouldRun = false;
|
||||
if (this.ws) this.ws.close();
|
||||
}
|
||||
|
||||
_emit(name, payload) {
|
||||
const handler = this.handlers[name];
|
||||
if (typeof handler === "function") handler(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const WRONG_WORKER_CODE = 4013;
|
||||
const WRONG_WORKER_RETRY_MS = 200;
|
||||
const INITIAL_BACKOFF_MS = 200;
|
||||
const MAX_BACKOFF_MS = 5000;
|
||||
|
||||
export class ChatSocket {
|
||||
constructor(url, handlers) {
|
||||
this.url = url;
|
||||
this.handlers = handlers || {};
|
||||
this.socket = null;
|
||||
this._shouldRun = false;
|
||||
this._settled = false;
|
||||
this._backoff = INITIAL_BACKOFF_MS;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this._shouldRun = true;
|
||||
this._open();
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !!this.socket && this.socket.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
_open() {
|
||||
this._settled = false;
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!this._settled) {
|
||||
this._settled = true;
|
||||
this._backoff = INITIAL_BACKOFF_MS;
|
||||
this._emit("onReady", payload);
|
||||
}
|
||||
this._emit("onMessage", payload);
|
||||
});
|
||||
|
||||
socket.addEventListener("close", (event) => {
|
||||
const wrongWorker = !this._settled && event.code === WRONG_WORKER_CODE;
|
||||
this._settled = false;
|
||||
if (wrongWorker) {
|
||||
if (this._shouldRun) {
|
||||
window.setTimeout(() => this._open(), WRONG_WORKER_RETRY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._emit("onClose", event);
|
||||
if (this._shouldRun) {
|
||||
const delay = this._backoff;
|
||||
this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
|
||||
window.setTimeout(() => this._open(), delay);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("error", (event) => this._emit("onError", event));
|
||||
}
|
||||
|
||||
send(frame) {
|
||||
if (this.isOpen()) {
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
close() {
|
||||
this._shouldRun = false;
|
||||
if (this.socket) this.socket.close();
|
||||
}
|
||||
|
||||
_emit(name, payload) {
|
||||
const handler = this.handlers[name];
|
||||
if (typeof handler === "function") handler(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export const GROUP_GAP_SECONDS = 300;
|
||||
|
||||
export function toEpochMs(value) {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
export function shouldGroup(previousMessage, currentMessage) {
|
||||
if (!previousMessage || !currentMessage) return false;
|
||||
if (previousMessage.senderUid !== currentMessage.senderUid) return false;
|
||||
const previousMs = toEpochMs(previousMessage.createdAt);
|
||||
const currentMs = toEpochMs(currentMessage.createdAt);
|
||||
if (previousMs === null || currentMs === null) return false;
|
||||
const gapSeconds = (currentMs - previousMs) / 1000;
|
||||
if (gapSeconds > GROUP_GAP_SECONDS) return false;
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,4 +12,5 @@ import "./AppLightbox.js";
|
||||
import "./AppDocsChat.js";
|
||||
import "./AppIsslop.js";
|
||||
import "./AppIsslopRun.js";
|
||||
import "./AppChat.js";
|
||||
import "./ContainerTerminal.js";
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<button type="button" class="comment-action-btn" data-action="reply"{{ guest_disabled(user) }}><span class="icon">💬</span><span class="label"> Reply</span></button>
|
||||
{% if owns(item.comment, user) %}
|
||||
<button type="button" class="comment-action-btn" data-action="edit" data-edit-url="/comments/edit/{{ item.comment['uid'] }}"><span class="icon">✏️</span><span class="label"> Edit</span></button>
|
||||
{% endif %}
|
||||
{% if owns(item.comment, user) or is_admin(user) %}
|
||||
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form comment-delete-form" data-comment-uid="{{ item.comment['uid'] }}">
|
||||
<button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span><span class="label"> Delete</span></button>
|
||||
</form>
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}"><span aria-hidden="true">🔗</span> Share</button>
|
||||
{% endif %}
|
||||
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
|
||||
{% if owns(item.post, user) or is_admin(user) %}
|
||||
<form method="POST" action="/posts/delete/{{ item.post['slug'] or item.post['uid'] }}" class="inline-form">
|
||||
<button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span aria-hidden="true">🗑️</span> Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.recent_comments %}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/messages.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/chat.css') }}">
|
||||
{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="sr-only">Messages</h1>
|
||||
<div class="page-messages">
|
||||
<div class="messages-layout" data-self-uid="{{ user['uid'] }}"{% if current_conversation %} data-other-uid="{{ current_conversation }}"{% endif %}>
|
||||
<dp-chat
|
||||
class="messages-layout"
|
||||
mode="page"
|
||||
self-uid="{{ user['uid'] }}"
|
||||
{% if current_conversation %}with-uid="{{ current_conversation }}"{% endif %}
|
||||
conversations-url="/messages/conversations"
|
||||
search-url="/messages/search"
|
||||
send-url="/messages/send"
|
||||
ws-url="/messages/ws"
|
||||
ai-indicator="true"
|
||||
>
|
||||
<div class="messages-list" role="navigation" aria-label="Conversations">
|
||||
<div class="messages-list-header" role="search">
|
||||
<input type="text" id="message-search" placeholder="Search conversations..." value="{{ search }}" aria-label="Search conversations">
|
||||
@@ -42,14 +52,14 @@
|
||||
|
||||
<div class="messages-thread" role="log" aria-label="Message transcript" aria-live="polite" aria-relevant="additions">
|
||||
{% for msg in messages %}
|
||||
<div class="message-bubble {% if msg.is_mine %}mine{% else %}theirs{% endif %}" data-msg-uid="{{ msg.message['uid'] }}">
|
||||
<dp-content no-copy>{{ msg.message['content'] }}</dp-content>
|
||||
<div class="message-bubble {% if msg.is_mine %}mine{% else %}theirs{% endif %}{% if msg.grouped %} grouped{% endif %}" data-msg-uid="{{ msg.message['uid'] }}" tabindex="0">
|
||||
<dp-content>{{ msg.message['content'] }}</dp-content>
|
||||
{% if msg.attachments %}
|
||||
{% with attachments=msg.attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
<span class="message-time">{{ dt_ago(msg.created_at) if msg.created_at else msg.time_ago }}</span>
|
||||
<span class="message-time">{{ dt_ago(msg.message['created_at']) if msg.message.get('created_at') else msg.time_ago }}</span>
|
||||
{% if msg.is_mine %}<span class="message-receipt"{% if not msg.message['read'] %} hidden{% endif %}>✓✓<span class="sr-only">Read</span></span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -71,7 +81,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</dp-chat>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user