Files
devplacepy/devplacepy/static/js/CLAUDE.md
T
retoorandClaude Opus 5 b777a5b9d0 Make the presence roster the single source of truth for online status
Online status had three server-side candidate populations and two
client-side deciders, so the feed roster and the /messages indicators
could legitimately disagree.

PresenceRelayService built its online set from whichever topics happened
to be subscribed on a given tick: the roster candidates on /feed, only
the per-uid dot rows on /messages. Different populations meant a
different hysteresis baseline, so the same user could be online in one
place and offline in the other. On top of that, PresenceManager re-derived
online status client-side from a frozen data-presence-last-seen with a
strict timeout and no hysteresis, re-evaluated every 20s, so any element
whose relay frame was missed drifted grey after the timeout and stayed
there. AppChat carried a third renderer with its own PubSubClient that
only ever wrote online/offline, plus hand-built dot markup duplicating
_presence_dot.html.

The relay now collapses to one set on one topic. Each tick it reads the
online population in a single indexed query (online_candidates, capped by
the new PRESENCE_TRACK_LIMIT), applies hysteresis once, and publishes
{count, online, users} on public.presence.roster only when the uid set
changes. online is the authority for every avatar dot; users is the same
set trimmed to PRESENCE_ONLINE_LIMIT for the feed panel. The per-uid
public.presence.{uid} topics are gone, which also removes one
subscription per distinct author on a page.

is_online(user) is now stays_online(seconds_since(last_seen), False), so
the server-rendered initial state and the live set apply one formula.

PresenceManager makes one subscription and renders every
[data-presence-uid] element as membership of that set, with no clock and
no expiry timer; before the first frame the server-rendered state stands.
Relative "last seen" text is a <time data-dt data-dt-mode="ago"> handled
by the shared LocalTime. AppChat lost its presence code entirely, and the
new Avatar.badgeElement is the JS twin of _presence_dot.html, so dot
markup now lives in exactly two places.

Also fix the awards column ensure-block, which the awards tests exposed.
backfill_api_keys opened with an "if users not in db.tables" guard, but
on a brand-new database that is precisely the state at init_db time, so
the whole users ensure-block was skipped. The first signup then created
users with only the columns of that INSERT, leaving every ensured-but-
unwritten column absent from the server's reflected metadata for the rest
of the process lifetime. That is why the awards tab, the prominent award
banner and the avatar award badge were invisible on a fresh database. It
now calls get_table("users") unconditionally.

test_avatar_badge_on_feed_when_prominent asserted that any online user
carries an award badge while only awarding a user who was never active,
so it passed only by accident. It now makes the awarded user active and
asserts the badge on that user's roster entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:53:43 +02:00

28 KiB

This file documents JS module organization, custom web components, and shared frontend utilities under static/js/. Claude Code auto-loads it whenever a file under this directory is read or edited.

JS module organization (hard rules)

  • All JS in static/js/ is ES6 modules, one class per file, instantiated as app and reachable everywhere. The browser-side application root is Application.js.
  • Javascript files must be in module (ES6) format and imported as module format. It must be as object oriented as possible and a file per class. There must always be a main application class called Application, instantiated as app, accessible everywhere.
  • Never use JavaScript 3rd party frameworks unless specified.
  • CDN scripts referenced from base.html (marked, highlight.js, emoji-picker-element) MUST use defer or type="module" - otherwise wait_until="domcontentloaded" in Playwright tests will time out.
  • Static asset URLs are boot-versioned for cache-busting. Assets are served public, immutable, max-age=31536000 (1 year, for the Lighthouse "efficient cache policy" score) while a restart still busts every browser. Never hardcode a bare /static/... href/src: templates wrap it in the static_url(path) Jinja global (templating.py) and runtime JS that builds an absolute static URL uses assetUrl(path) (static/js/assetVersion.js, reads <meta name="asset-version"> rendered in base.html). Both emit a /static/v<STATIC_VERSION>/... path segment (config.STATIC_VERSION = DEVPLACE_STATIC_VERSION env or int(time.time()) at process start), so a deploy = a restart = a new timestamp = a new URL for every asset. Both helpers no-op for non-/static/ paths and for /static/uploads/, so they are safe to wrap around dynamic values.
    • Path segment, never a query string - do NOT switch this to ?v=. The frontend is unbundled ES6 with ~130 relative imports (./Http.js) and zero absolute ones. A ?v= only versions the entry <script>; its transitive relative imports would resolve to unversioned URLs and stay frozen under immutable, so a deploy would not propagate JS for a year. A path segment is inherited automatically by the entire module graph and by relative CSS url().
    • Serving: main.py mounts a CachedStaticFiles at /static/v{STATIC_VERSION} (immutable 1y; service-worker.js -> no-cache) plus the plain /static mount as the unversioned fallback; nginx mirrors this with location ~ ^/static/v\d+/ (immutable 1y) and a short max-age=3600 on the unversioned /static/. /static/uploads/ (user content, stable DB paths) is never versioned; service-worker.js keeps a stable unversioned URL with no-cache so its registration scope is stable and a new worker always deploys.
    • Multi-worker: the version is captured once at launch and shared via DEVPLACE_STATIC_VERSION (set in make prod = $(date +%s) and the Dockerfile's sh -c CMD) - otherwise two workers could compute timestamps a second apart and serve mismatched versioned mounts (a 404 on the other worker). Unset in dev (--reload) so each reload refreshes it.

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-chat, dp-quiz-player, dp-quiz-builder.

dp-quiz-player (AppQuizPlayer.js) and dp-quiz-builder (AppQuizBuilder.js) both adopt server-rendered markup rather than building it (the dp-chat mode="page" pattern): the player intercepts each question's real <form method="post"> through Http.sendForm, renders the graded result in place, shows a Checking… state while a free_text grade is in flight, ticks the remaining time from Format.duration, and advances to the next slide; the builder toggles the kind-specific field groups from the question-kind picker and submits the add-question form through Http.sendForm. Neither re-renders a prompt (the server already rendered it through render_content), neither opens a WebSocket, and neither implements a fetch wrapper, a poller or a dialog. Removing the JS leaves a fully working no-JS quiz.

Prompt seeding on the shared Devii opener (data-devii-prompt). DeviiTerminal.bindTriggers binds every [data-devii-open] element; it now reads trigger.dataset.deviiPrompt and passes it to DeviiTerminal.open(prompt), which calls this.element.open() and then devii-terminal.prefill(text) (sets this.input.value, moves the caret to the end, focuses). It never auto-sends - the member reads the request and presses Enter, keeping the assistant's first action explicitly user-initiated. This is a platform-wide capability available to any page, not a quiz-only shim: add data-devii-prompt="..." beside data-devii-open and the terminal opens pre-filled.

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 needs no component code at all: the adopted markup keeps its data-presence-uid attributes, so the page-global PresenceManager (below) already drives it, and the conversation list it builds live uses the shared Avatar.badgeElement(user) rather than hand-rolled dot markup. In mode="embed", where no page-global instance may exist, it constructs a root-scoped PresenceManager instead of re-implementing presence - dp-chat owns no presence logic of its own. 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:

  • Each extends Component (static/js/components/Component.js), a thin HTMLElement base with attr(name, fallback), boolAttr(name), intAttr(name, fallback) helpers.
  • Light DOM only (no attachShadow), so the site's global CSS applies - matching the existing devii-* elements. dp-dialog/dp-context-menu reuse the existing .dialog-*/.context-menu* styles in components.css; dp-toast styles are in components.css (.dp-toast-host/.dp-toast).
  • Each file self-registers via customElements.define at the bottom. components/index.js imports them all and is imported by Application.js, so every element is defined on every page (including /docs).
  • The behavioural singletons are created once in Application.js and exposed as app.dialog, app.contextMenu, app.toast. The dp-dialog/dp-context-menu elements keep the exact pre-existing Dialog/ContextMenu APIs (confirm/prompt/alert/open, attach/open/close) so existing callers (ProjectFiles, ApiKeyManager, ModalManager, PushManager, ApiTester) are unchanged. Avatar/CodeBlock/Toast/ContentRenderer remain as static/service modules (widely imported); the matching elements (dp-avatar, dp-code, dp-content) reuse them. dp-content wraps the shared contentRenderer engine that also powers the data-render attribute (driven by ContentEnhancer) - the engine stays a reusable service, the element is its component face. dp-content captures its raw markdown source before contentRenderer.applyTo overwrites textContent, then mounts a hover/focus reveal .content-copy-btn in its top-right corner that copies that original source to the clipboard (mirroring CodeBlock.copyButton); CSS lives beside .code-copy-btn in docs.css.
  • dp-content / dp-title are CLIENT-only now (live contexts). dp-content (AppContent.js) and its inline companion dp-title (AppTitle.js, contentRenderer.renderInline) render markdown/emoji/media (and, for title, inline-only with an inline-tag allowlist) on the client from textContent. They are no longer used for server-rendered content/titles - those moved to the backend render_content/render_title globals (see the root CLAUDE.md "Content rendering pipeline" section) for SEO. The components remain defined and are used only where content is generated client-side after load: dp-content in the DeepSearch chat (AppDeepsearchChat.js) and the planning report (PlanningGenerator.js -> admin_issues_planning.html). Keep them for those live cases and future use; do NOT wrap server-known content/titles in them. Both are now used ONLY for live/client-generated content (chat, planning report, dynamically inserted bubbles); server-rendered content/titles use the backend render_content/render_title globals instead. The components are intentionally kept for those live contexts and future use.
  • What is NOT a component (by design): partial-bound controllers (ReactionBar, VoteManager, BookmarkManager, PollManager, CommentManager, etc.) enhance server-rendered _*.html markup rather than render standalone UI; pure utilities (Http, DomUtils, FormManager, Poller, JobPoller, OptimisticAction) have no UI. These stay plain ES6 modules. The four optimistic engagement controllers extend the shared OptimisticAction base - see "Shared frontend utilities" below. Self-contained presentational widgets become components; partial-bound controllers (votes, reactions, comments) and pure utilities stay plain modules because they enhance server-rendered markup rather than render standalone UI.
  • Documented in the public docs Components section (templates/docs/component-*.html, registered under SECTION_COMPONENTS in the routers/docs package). Each page has a data-render markdown block (with example markup HTML-escaped as &lt;dp-...&gt;, since data-render re-parses textContent and would otherwise instantiate the example) plus a live demo in a separate block whose <script type="module"> imports its own component module (it runs before Application.js).
  • dp-lightbox (AppLightbox.js, app.lightbox, static/css/lightbox.css) is the single image lightbox. It is attribute-wired, not call-wired: the singleton installs ONE delegated document click listener and opens any clicked img[data-lightbox] (showing data-full when present, else the image's own src; alt as caption; ignored when the image sits inside an <a>). To make a thumbnail open it, just add data-lightbox - never attach a click handler. Attachment thumbnails carry data-lightbox+data-full (_attachment_display.html); markdown content images are marked centrally in ContentEnhancer.initContentRenderer (one place covers every data-render surface), so embedded images are always clickable. The old hand-rolled .attachment-lightbox markup/CSS was dead (never wired) and has been removed.
  • dp-upload (the single file-upload button used everywhere) replaced the old AttachmentUploader; see "The upload button (dp-upload)" below.

Shared frontend utilities (do not re-implement these)

A small set of plain ES6 modules under static/js/ own the cross-cutting patterns so feature code stays tiny. Reach for these instead of hand-rolling a loop, a fetch, or a click handler. Http (static/js/Http.js) is the single fetch helper (getJson, sendForm, and send which throws error.message on non-2xx or a 200 {ok:false} body); every live-update loop uses Poller (new Poller(fn, intervalMs, {pauseHidden})); async-job status polls use JobPoller.run(statusUrl, {onDone, onFailed, onTimeout}) (ProjectForker, ZipDownloader); click-to-POST engagement controllers (VoteManager/ReactionBar/BookmarkManager/PollManager) extend OptimisticAction and call this.submit(url, params, errorTarget, render). Floating windows (container terminals and Devii) extend FloatingWindow. Scroll restoration is ScrollMemory (static/js/ScrollMemory.js, app.scrollMemory): per-tab (sessionStorage) positions keyed by exact path+search, restored ONLY on back_forward/reload navigations or a click on a.back-link/[data-scroll-back]/breadcrumb/previous-trail-URL links, applied via a layout-stable rAF loop that aborts on user input; it sets history.scrollRestoration = "manual" site-wide and upgrades query-less back-link hrefs to the exact previous URL - mark any "back to X" anchor with the back-link class and never hand-roll scroll persistence.

Detail on each utility:

  • Http (static/js/Http.js, global window.Http). The single HTTP helper. getJson(url) (GET -> JSON, throws on non-2xx); sendForm(url, params) (POST form-encoded, follows the /auth/login redirect via Http.toLogin(), throws a bare status on failure, returns JSON); send(url, params) (POST form-encoded that throws data.error.message on !ok or a 200 body with ok:false - the manager-style error the container/admin UIs surface in a toast); postJson/postForm/toLogin. Container files (ContainerManager, ContainerList, ContainerInstance, ContainerTerminal), ServiceMonitor, and ProjectFiles all route through it - none re-implement fetch.
  • Poller (static/js/Poller.js). new Poller(fn, intervalMs, { immediate = true, pauseHidden = false }) runs fn on an interval with start()/stop()/tick(); tick() swallows errors so one failed poll never kills the loop, and pauseHidden skips the tick while document.hidden. Used by every live-update loop: CounterManager (30s, pauseHidden), ContainerManager (3s), ContainerList (4s), AiUsageMonitor, ServiceMonitor, and ContainerInstance's detail (4s) + logs (3s). Store the Poller, not a raw interval id.
  • JobPoller (static/js/JobPoller.js). JobPoller.run(statusUrl, { onDone, onFailed, onTimeout, intervalMs = 1500, maxAttempts = 200 }) returns a Promise; it polls Http.getJson(statusUrl), swallows transient fetch errors, and fires the matching callback on status === "done"|"failed" or timeout. This is the one place the async-job status-poll lives - ProjectForker and ZipDownloader both call it with their own navigate/download/toast callbacks.
  • OptimisticAction (static/js/OptimisticAction.js). Base with one method, submit(url, params, errorTarget, render): Http.sendForm -> render(result) on success -> console.error + (when errorTarget is given) Toast.flash(errorTarget, "Error", 1500) on failure. VoteManager/ReactionBar/BookmarkManager/PollManager extend it and call this.submit(...) for their POST, keeping their own event wiring (so ReactionBar's palette toggle and PollManager's multi-action handlers and VoteManager's per-button stopPropagation are untouched). Pass errorTarget only where the old code toasted (VoteManager); the others pass null to keep their console-only behaviour.
  • EmojiPickerElement (static/js/EmojiPickerElement.js). The single wrapper around the vendored emoji-picker-element: EmojiPickerElement.load() lazily imports the vendor module once (shared promise, failures swallowed) and EmojiPickerElement.create(onSelect) returns a configured <emoji-picker> (data source static/vendor/emoji-picker-element/data.json) that calls onSelect(unicode) on emoji-click. Both consumers use it: EmojiPicker (insert at cursor in a textarea) and ReactionBar (react with any emoji). Never build an <emoji-picker>, re-import the vendor module, or repeat the data-source path elsewhere.
  • FloatingWindow / WindowManager (static/js/components/). The draggable window base and shared z-order manager - documented in the Container manager section; both the container terminals and the Devii terminal extend FloatingWindow.
  • PresenceManager (static/js/PresenceManager.js, app.presence) and Avatar (static/js/Avatar.js). The single online-status renderer and the single avatar-markup builder. PresenceManager makes ONE subscription to public.presence.roster and drives EVERY [data-presence-uid] element from the pushed online uid set - there is no client-side clock and no expiry timer, so nothing can disagree with the feed's Online now panel. Avatar.badgeElement(user) builds the .avatar-badge + .presence-dot + award-badge trio (the JS twin of templates/_presence_dot.html) and is used by OnlineUsers and dp-chat. Never re-derive online status from a timestamp in feature code, and never hand-build a presence dot.
  • ScrollMemory (static/js/ScrollMemory.js, app.scrollMemory). Site-wide, per-tab scroll restoration - the fix for the "back to feed jumps to top" class of bugs. It sets history.scrollRestoration = "manual" once (never rely on the browser's auto-restore, which fires before late-loading content settles and never applies to normal link navigations), so ALL scroll restoration flows through this one module. State lives in sessionStorage (per-tab by definition, exactly the required scope): a position map keyed by exact pathname + search (hash ignored; saved by a throttled passive scroll listener plus a final write on pagehide/hidden visibilitychange, pruned to 50 entries / 60 min), a visited-URL trail (capped at 20), and a one-shot click-intent flag (30s validity, consumed on every load). When it restores - only when the situation is genuinely "going back": (1) navigation type back_forward (browser back without bfcache; with bfcache - pageshow persisted - the frozen page already has its scroll, so it only re-syncs the trail and clears the intent flag), (2) reload, (3) a same-origin click on a.back-link / a[data-scroll-back] / a breadcrumb link / any link whose target equals the trail's previous URL, which stamps the intent flag the next load matches. A fresh visit (topnav, address bar, redirect after POST) never restores, and a URL with a #fragment always wins over restoration. How it restores reliably: a requestAnimationFrame loop re-applies the target position (clamped to the current scrollHeight, behavior: "instant" so the global html { scroll-behavior: smooth } never animates it) until the document height has been stable at the target for 10 frames or a 4s deadline passes, and aborts instantly on the first wheel/touchstart/keydown/pointerdown so it never fights the user. It also upgrades bare back-links on load: when the previous trail URL has the same pathname as a query-less a.back-link/[data-scroll-back] href (post page's /feed vs the /feed?tab=recent&before=... the user actually came from), the href is rewritten to the exact previous URL so tab/topic/cursor AND scroll survive the round trip. Give any new "back to X" anchor the back-link class (or data-scroll-back) and it participates automatically - never hand-roll scrollTo persistence per page. Guarded by tests/e2e/feed.py::test_feed_scroll_restored_via_back_link / _via_browser_back / _not_restored_on_fresh_visit.
  • On-screen keyboard reflow (mobile). FloatingWindow listens on window.visualViewport resize/scroll and, while fullscreen/maximized, sets the window inline height/top to the visual viewport (the area NOT covered by the phone keyboard) instead of letting the keyboard push the window off-screen; it also toggles the fw-keyboard-visible class whose CSS drops the fixed bottom so the inline size wins. The Devii terminal mirrors this in its own _setState / _onVisualViewport2 (devii-keyboard-visible) and _ensureInputVisible keeps the focused .devii-input pinned just above the keyboard; ContainerTerminal._onResize re-fits xterm against the shrunken viewport and scrollToBottoms. AppContextMenu.open clamps into the visual-viewport rect (offsetLeft/Top + width/height) so Paste stays on-screen above the keyboard, and the menu no longer closes on a visualViewport-only height change (keyboard appearing) - only a true window.innerWidth change closes it. All paths fall back to window.inner* when visualViewport is absent, so desktop and old browsers are untouched.

Clickable avatars and usernames

Every avatar and username in the UI links to the user's profile page. Use the _avatar_link.html and _user_link.html include components:

{% set _user = item.author %}
{% set _size = 32 %}
{% set _size_class = "sm" %}
{% include "_avatar_link.html" %}

<a href="/profile/{{ user['username'] }}" class="post-author-link">{{ user['username'] }}</a>

The include files expect: _user (dict), _size (pixels), _size_class ("sm"|"md"|"lg").

Affected templates: base.html, feed.html, post.html, profile.html, messages.html, notifications.html.

For clickable avatars/usernames, reuse templates/_avatar_link.html and templates/_user_link.html via {% include %} with _user/_size/_size_class locals.

Image upload

When a user uploads an image during post creation, the markdown ![](/static/uploads/{filename}) is appended to the post content. The ContentRenderer then renders it as an <img>. All URLs are relative.

content += f"\n\n![](/static/uploads/{image_filename})"

File validation: max 5MB, allowed extensions: .png, .jpg, .jpeg, .gif, .webp, .svg.

Attachments and media

attachments.py is the single store for files referenced via attachment_uids (posts, comments, projects, gists, messages, issues). One gate, is_extension_allowed(ext), decides what may be uploaded and is used by both routers/uploads.py and store_attachment() - do not reintroduce a second check. Its semantics: when the admin allowed_file_types setting is non-empty it is the authoritative allowlist (mime auto-detected via mimetypes for extensions outside the built-in ALLOWED_UPLOAD_TYPES map); when empty it falls back to the built-in map, which includes images plus the browser-playable video formats .mp4/.webm/.ogv/.mov/.m4v. (Before this, allowed_file_types could only narrow the hardcoded list, never add to it - which is why adding a video extension there had no effect.)

Ingesting a file from a URL. store_attachment_from_url(url, user_uid, filename=None) (async, in attachments.py) is the remote counterpart to store_attachment: it downloads the URL on the server through fetch_remote_file() - SSRF-guarded (_guard_public_url resolves the host and refuses private/loopback/reserved/multicast addresses, mirroring the Devii fetch guard) and size-capped (streams, aborting once _get_max_upload_bytes() is exceeded) - resolves a filename from the URL path or the response Content-Type (MIME_TO_EXT), then calls store_attachment() so the bytes land in the exact same pipeline (validation, thumbnailing, DB row). It raises RemoteFetchError(message, status) which the route maps to an HTTP status. It is exposed at POST /uploads/upload-url (UploadUrlForm{url, filename?}, require_user_api) and as the Devii catalog action attach_url (handler http, requires_auth=True); both return the same record as /uploads/upload. The returned uid binds to a resource the same way as any upload - via attachment_uids at create/edit time - so attaching a remote image is just attach_url then create_post/create_project/etc. with that uid. Do not re-download remote files in a router; reuse this helper so the guard and size cap stay in one place.

_row_to_attachment() / store_attachment() expose is_image and is_video (derived from the mime prefix). The shared partial templates/_attachment_display.html branches image -> <img>, video -> <video controls preload="metadata" class="gallery-video">, else download link; rendering through this one partial is what makes video work across every feature at once. AttachmentOut (schemas.py) carries both flags - add new display keys there too or JSON drops them.

Media is served inline (not forced-download) for known-safe types only. The set INLINE_MEDIA_EXTENSIONS in main.py (UploadStaticFiles) and the matching map $uri $upload_disposition in nginx/nginx.conf.template must stay in sync: images/video/audio -> inline (so <video> plays and seeks via Range), everything else -> attachment. SVG is deliberately excluded from both (stored-XSS defense). ContentRenderer.js embeds direct video URLs typed into content via videoExtRe, mirroring its image handling.

The upload button (dp-upload)

Every file-upload UI is the one custom element dp-upload (static/js/components/AppUpload.js); it replaced AttachmentUploader.js (deleted). It shows a clean button with a count badge + removable filename chips (no thumbnail grid), and runs in one of three modes:

  • attachment (default, the 7 forms via _attachment_form.html / inline in _comment_form.html and messages.html): uploads each file to /uploads/upload on select and keeps the hidden <input name="attachment_uids"> the routers already link - server contract unchanged. Limits come from max-size/max-files/allowed-types attributes (fed by the Jinja globals).
  • direct (the project file browser, project_files.html): uploads to a custom endpoint with field-name plus a settable extraFields (e.g. {path}) and emits dp-upload:uploaded / dp-upload:done / dp-upload:error. ProjectFiles.uploadTo(dir) sets extraFields then calls widget.open(), and refreshes the tree on done.
  • field (create-post image, feed.html): wraps a real <input type="file" name="image"> that submits with the form - no AJAX, inline-image flow unchanged.

The component validates size/type/count and reports errors via app.toast. CSS is .dp-upload-* in components.css; the old .attachment-upload-* upload-widget styles were removed, but the .attachment-gallery/.attachment-lightbox display styles (for already-saved attachments) remain.