This commit is contained in:
parent
34fa56a836
commit
64c3983c9f
@ -72,7 +72,7 @@ devplacepy/
|
||||
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
|
||||
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
|
||||
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants. An opened conversation loads its 500 most recent messages; older history is retained in the database. The `POST /messages/send` form remains as a no-JavaScript fallback |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
|
||||
| `/votes` | Upvote/downvote on posts, comments, projects |
|
||||
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
|
||||
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
29
devplacepy/cli/messaging.py
Normal file
29
devplacepy/cli/messaging.py
Normal file
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
50
devplacepy/services/messaging/tickets.py
Normal file
50
devplacepy/services/messaging/tickets.py
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
84
devplacepy/static/js/chat/ChatSocket.js
Normal file
84
devplacepy/static/js/chat/ChatSocket.js
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
21
devplacepy/static/js/chat/MessageGrouping.js
Normal file
21
devplacepy/static/js/chat/MessageGrouping.js
Normal file
@ -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;
|
||||
}
|
||||
1090
devplacepy/static/js/components/AppChat.js
Normal file
1090
devplacepy/static/js/components/AppChat.js
Normal file
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 %}
|
||||
|
||||
101
tests/api/messages/conversations.py
Normal file
101
tests/api/messages/conversations.py
Normal file
@ -0,0 +1,101 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table
|
||||
|
||||
JSON = {"Accept": "application/json"}
|
||||
_counter = [0]
|
||||
|
||||
|
||||
def _unique(prefix="mconv"):
|
||||
_counter[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
|
||||
|
||||
|
||||
def _signup():
|
||||
name = _unique()
|
||||
s = requests.Session()
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
|
||||
|
||||
def _uid(name):
|
||||
return get_table("users").find_one(username=name)["uid"]
|
||||
|
||||
|
||||
def test_conversations_requires_auth_401_for_json(app_server):
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/messages/conversations", headers=JSON, allow_redirects=False
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_conversations_requires_auth_redirects_browser(app_server):
|
||||
r = requests.get(f"{BASE_URL}/messages/conversations", allow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
def test_conversations_matches_messages_page_shape(app_server):
|
||||
sender, _ = _signup()
|
||||
_, other_name = _signup()
|
||||
other_uid = _uid(other_name)
|
||||
|
||||
sent = sender.post(
|
||||
f"{BASE_URL}/messages/send",
|
||||
headers=JSON,
|
||||
data={"receiver_uid": other_uid, "content": "hello from parity test"},
|
||||
)
|
||||
assert sent.status_code == 200, sent.text[:300]
|
||||
|
||||
page = sender.get(f"{BASE_URL}/messages", headers=JSON)
|
||||
assert page.status_code == 200, page.text[:300]
|
||||
page_conversations = page.json()["conversations"]
|
||||
|
||||
flat = sender.get(f"{BASE_URL}/messages/conversations")
|
||||
assert flat.status_code == 200, flat.text[:300]
|
||||
flat_conversations = flat.json()["conversations"]
|
||||
|
||||
assert page_conversations == flat_conversations
|
||||
|
||||
match = next(
|
||||
c for c in flat_conversations if c["other_user"]["uid"] == other_uid
|
||||
)
|
||||
assert match["last_message"] == "hello from parity test"
|
||||
assert match["unread"] is False
|
||||
|
||||
|
||||
def test_conversations_excludes_blocked_user(app_server):
|
||||
viewer, _ = _signup()
|
||||
blocked, blocked_name = _signup()
|
||||
blocked_uid = _uid(blocked_name)
|
||||
|
||||
reply = viewer.post(
|
||||
f"{BASE_URL}/messages/send",
|
||||
headers=JSON,
|
||||
data={"receiver_uid": blocked_uid, "content": "before block"},
|
||||
)
|
||||
assert reply.status_code == 200, reply.text[:300]
|
||||
|
||||
before = viewer.get(f"{BASE_URL}/messages/conversations")
|
||||
assert any(
|
||||
c["other_user"]["uid"] == blocked_uid for c in before.json()["conversations"]
|
||||
)
|
||||
|
||||
viewer.post(f"{BASE_URL}/block/{blocked_name}", allow_redirects=False)
|
||||
|
||||
after = viewer.get(f"{BASE_URL}/messages/conversations")
|
||||
assert after.status_code == 200
|
||||
assert not any(
|
||||
c["other_user"]["uid"] == blocked_uid for c in after.json()["conversations"]
|
||||
)
|
||||
@ -1,9 +1,11 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import io
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
import pytest
|
||||
import requests
|
||||
from PIL import Image
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||
from devplacepy.utils import generate_uid, make_combined_slug
|
||||
@ -127,3 +129,40 @@ def test_message_send_recorded(seeded_db):
|
||||
).json()["data"]
|
||||
admin = _admin(seeded_db)
|
||||
assert _find(admin, "message.send", lambda e: e.get("target_uid") == msg["uid"]) is not None
|
||||
|
||||
|
||||
def _png_bytes():
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _upload(session, name="attach.png"):
|
||||
r = session.post(
|
||||
f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes(), "image/png")}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["uid"]
|
||||
|
||||
|
||||
def test_send_attachment_only_empty_content_succeeds(seeded_db):
|
||||
s, _ = _member()
|
||||
receiver = _db_user("bob_test")["uid"]
|
||||
attachment_uid = _upload(s)
|
||||
|
||||
r = s.post(
|
||||
f"{BASE_URL}/messages/send",
|
||||
headers=JSON_audit_log,
|
||||
data={
|
||||
"content": "",
|
||||
"receiver_uid": receiver,
|
||||
"attachment_uids": attachment_uid,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
msg = r.json()["data"]
|
||||
assert msg["uid"]
|
||||
|
||||
refresh_snapshot()
|
||||
row = get_table("messages").find_one(uid=msg["uid"])
|
||||
assert row["content"] == ""
|
||||
|
||||
110
tests/api/messages/wsticket.py
Normal file
110
tests/api/messages/wsticket.py
Normal file
@ -0,0 +1,110 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import websockets
|
||||
|
||||
from tests.conftest import BASE_URL, PORT
|
||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||
|
||||
WS_URL = f"ws://127.0.0.1:{PORT}/messages/ws"
|
||||
JSON = {"Accept": "application/json"}
|
||||
_counter = [0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _wsticket_settings(app_server):
|
||||
for key, value in {
|
||||
"rate_limit_per_minute": "1000000",
|
||||
"rate_limit_window_seconds": "60",
|
||||
"registration_open": "1",
|
||||
"maintenance_mode": "0",
|
||||
}.items():
|
||||
set_setting(key, value)
|
||||
yield
|
||||
|
||||
|
||||
def _unique(prefix="wst"):
|
||||
_counter[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
|
||||
|
||||
|
||||
def _signup():
|
||||
name = _unique()
|
||||
s = requests.Session()
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
|
||||
|
||||
def _uid(name):
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=name)["uid"]
|
||||
|
||||
|
||||
async def _recv_until(ws, frame_type, timeout=5.0):
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
raise AssertionError(f"timed out waiting for {frame_type}")
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
||||
frame = json.loads(raw)
|
||||
if frame.get("type") == frame_type:
|
||||
return frame
|
||||
|
||||
|
||||
def test_ws_ticket_requires_auth_401_for_json(app_server):
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/messages/ws-ticket", headers=JSON, allow_redirects=False
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_ws_ticket_requires_auth_redirects_browser(app_server):
|
||||
r = requests.post(f"{BASE_URL}/messages/ws-ticket", allow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
def test_ws_ticket_issued_for_logged_in_session(app_server):
|
||||
session, _ = _signup()
|
||||
r = session.post(f"{BASE_URL}/messages/ws-ticket", headers=JSON)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
data = r.json()
|
||||
assert data["ticket"]
|
||||
assert data["expires_in"] == 30
|
||||
|
||||
|
||||
def test_ws_ticket_single_use_and_authenticates_owner(app_server):
|
||||
session, name = _signup()
|
||||
owner_uid = _uid(name)
|
||||
|
||||
ticket = session.post(f"{BASE_URL}/messages/ws-ticket", headers=JSON).json()[
|
||||
"ticket"
|
||||
]
|
||||
|
||||
async def first_connect():
|
||||
async with websockets.connect(f"{WS_URL}?ticket={ticket}") as ws:
|
||||
ready = await _recv_until(ws, "ready")
|
||||
assert ready["user_uid"] == owner_uid
|
||||
|
||||
asyncio.run(first_connect())
|
||||
|
||||
async def second_connect():
|
||||
async with websockets.connect(f"{WS_URL}?ticket={ticket}") as ws:
|
||||
with pytest.raises(websockets.exceptions.ConnectionClosed):
|
||||
await asyncio.wait_for(ws.recv(), timeout=3.0)
|
||||
|
||||
asyncio.run(second_connect())
|
||||
@ -176,7 +176,7 @@ def test_send_message_appears_in_thread(alice):
|
||||
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
|
||||
)
|
||||
msg = f"Hello bob {int(time.time() * 1000)}"
|
||||
page.fill("input[name='content']", msg)
|
||||
page.fill("textarea[name='content']", msg)
|
||||
page.locator(".messages-send-btn").click()
|
||||
page.wait_for_url("**/messages**", wait_until="domcontentloaded")
|
||||
page.locator(f".message-bubble:has-text('{msg}')").first.wait_for(state="visible")
|
||||
@ -290,3 +290,56 @@ def test_messages_page_is_noindex(alice):
|
||||
meta = page.locator('meta[name="robots"]')
|
||||
content = meta.get_attribute("content")
|
||||
assert content and "noindex" in content
|
||||
|
||||
|
||||
def test_optimistic_send_pending_then_reconciles_no_double_render(alice, bob):
|
||||
page_a, user_a = alice
|
||||
page_b, user_b = bob
|
||||
uid_a = get_table("users").find_one(username=user_a["username"])["uid"]
|
||||
uid_b = get_table("users").find_one(username=user_b["username"])["uid"]
|
||||
|
||||
page_a.goto(
|
||||
f"{BASE_URL}/messages?with_uid={uid_b}", wait_until="domcontentloaded"
|
||||
)
|
||||
page_b.goto(
|
||||
f"{BASE_URL}/messages?with_uid={uid_a}", wait_until="domcontentloaded"
|
||||
)
|
||||
|
||||
msg = f"Optimistic hello {int(time.time() * 1000)}"
|
||||
textarea = page_a.locator(".messages-input-area textarea[name='content']")
|
||||
textarea.wait_for(state="visible")
|
||||
textarea.fill(msg)
|
||||
page_a.locator(".messages-send-btn").click()
|
||||
|
||||
pending = page_a.locator(f".message-bubble.mine.pending:has-text('{msg}')")
|
||||
pending.wait_for(state="visible", timeout=5000)
|
||||
client_id = pending.get_attribute("data-client-id")
|
||||
assert client_id
|
||||
|
||||
reconciled = page_a.locator(
|
||||
f".message-bubble.mine[data-client-id='{client_id}'][data-msg-uid]:not(.pending)"
|
||||
)
|
||||
reconciled.wait_for(state="visible", timeout=6000)
|
||||
|
||||
received = page_b.locator(f".message-bubble.theirs:has-text('{msg}')")
|
||||
received.wait_for(state="visible", timeout=6000)
|
||||
assert received.count() == 1
|
||||
|
||||
|
||||
def test_mobile_single_pane_back_button(mobile_page):
|
||||
page, user = mobile_page
|
||||
bob = get_table("users").find_one(username="bob_test")
|
||||
page.goto(
|
||||
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
|
||||
)
|
||||
|
||||
thread = page.locator(".messages-main")
|
||||
conv_list = page.locator(".messages-list")
|
||||
thread.wait_for(state="visible")
|
||||
expect(conv_list).to_be_hidden()
|
||||
|
||||
page.locator("#messages-back-btn").click()
|
||||
|
||||
expect(conv_list).to_be_visible()
|
||||
expect(thread).to_be_hidden()
|
||||
assert page.url == f"{BASE_URL}/messages"
|
||||
|
||||
@ -440,7 +440,7 @@ def test_message_notification(app_server, browser, seeded_db):
|
||||
pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded")
|
||||
pb.wait_for_timeout(2000)
|
||||
|
||||
msg_input = pb.locator("input[name='content']").first
|
||||
msg_input = pb.locator("textarea[name='content']").first
|
||||
msg_input.wait_for(state="visible", timeout=10000)
|
||||
msg_input.fill("Hello from bob_test!")
|
||||
pb.locator("button[type='submit']").last.click()
|
||||
@ -753,7 +753,7 @@ def test_message_notification_click_opens_conversation(app_server, browser, seed
|
||||
|
||||
pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded")
|
||||
pb.wait_for_timeout(2000)
|
||||
msg_input = pb.locator("input[name='content']").first
|
||||
msg_input = pb.locator("textarea[name='content']").first
|
||||
msg_input.wait_for(state="visible", timeout=10000)
|
||||
msg_input.fill("Click-through message from bob")
|
||||
pb.locator("button[type='submit']").last.click()
|
||||
|
||||
0
tests/unit/services/messaging/__init__.py
Normal file
0
tests/unit/services/messaging/__init__.py
Normal file
47
tests/unit/services/messaging/tickets.py
Normal file
47
tests/unit/services/messaging/tickets.py
Normal file
@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy.services.messaging.tickets import issue_ticket, redeem_ticket
|
||||
|
||||
|
||||
def _user_uid():
|
||||
uid = generate_uid()
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"tkt_{uid[:8]}",
|
||||
"email": f"{uid[:8]}@tkt.dev",
|
||||
"password_hash": "x",
|
||||
"role": "Member",
|
||||
"is_active": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def test_ticket_redeems_once(local_db):
|
||||
user_uid = _user_uid()
|
||||
token = issue_ticket(user_uid)
|
||||
|
||||
assert redeem_ticket(token) == user_uid
|
||||
assert redeem_ticket(token) is None
|
||||
|
||||
|
||||
def test_expired_ticket_never_used_still_rejected(local_db):
|
||||
user_uid = _user_uid()
|
||||
token = issue_ticket(user_uid)
|
||||
|
||||
expired_at = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
|
||||
tickets = get_table("ws_tickets")
|
||||
row = tickets.find_one(token=token)
|
||||
tickets.update({"uid": row["uid"], "expires_at": expired_at}, ["uid"])
|
||||
|
||||
assert redeem_ticket(token) is None
|
||||
|
||||
|
||||
def test_redeem_unknown_token_returns_none(local_db):
|
||||
assert redeem_ticket("not-a-real-token") is None
|
||||
Loading…
Reference in New Issue
Block a user