Command panels: slash commands can open a transient, interactive results surface (#497)
* web: command panels — a transient results surface a slash command can open
A command can now answer with a PANEL: a temporary, interactive surface in
the chat you typed in — above the composer by default, or pinned above the
feed with position: "top" — alongside the existing private reply. It's the
/gif-picker shape: run a command, get tappable results in place, tap one and
the panel acts on the composer (insert text, stage attachments) and
dismisses itself. The user can always dismiss it too (its ✕, or Escape), and
opening another replaces it. Panels are client-only chrome: never sent,
never folded, gone when dismissed or when the pane closes.
- apps.js: ctx.panel(spec) / ctx.closePanel() in command + mention handlers,
the injectable host (setCommandPanelHost), and the panel chrome CSS.
ctx.panel returns false when the thread has no chat surface, so commands
can fall back to ctx.reply.
- chat.js: the host — builds the panel in the pane skeleton, routes
open/close per thread, wires Escape into the existing layer-peeling order,
and hands renders a handle: close / refresh / insertText (caret-aware;
forwarded as composerInsert to native shells in embed mode) / attachFiles
(the same staging path the attach button uses).
- apps/emoji.js: the reference app — /emoji [search] opens a picker panel
from the curated shortcode set; tapping an emoji inserts it and closes.
- Docs: authoring guide section + ctx table rows in apps/README.md; the
command-doctrine bullet in CLAUDE.md now names panels as a sanctioned
in-place response.
- Tests: searchEmoji + panel plumbing in test/apps.test.mjs; verified live
in Chromium via the verify-skill recipe (12 assertions: open, grid, tap
inserts + closes, Escape, ✕, no-match reply fallback).
Co-Authored-By: Claude <noreply@anthropic.com>
* mac: command panels — the native composer runs mini-app commands through a per-thread webview
The Mac feed renders chat natively, so it had no JS engine for mini-app
slash commands — /emoji (and future panel commands) just posted as literal
text. This gives every native thread a hidden COMMAND HOST: the same embed
page the app-thread webviews load, seeded with mode:"command", which is the
real web command engine bound to that thread. The shell forwards an
allowlisted slash command to it; a private reply comes back as an inline
notice, and a panel reveals the webview above the composer — the webview IS
the panel, drawn by the same web code the browser uses.
Web (the command-host embed mode):
- shell.js installCommandHost: window.lchatNative.runCommand/dismissPanel;
posts {commandHostReady}, {panel: {title, height} | null}, {panelHeight},
{notice}; height measured via scrollHeight so it works while the webview
is still hidden (0-tall).
- apps.js setCommandPanelObserver/notifyCommandPanel — fired by the host's
controller for every show/dismiss (command, ✕, Escape, app close), the one
reliable visibility signal.
- style.css body.command-host: everything but .cmd-panel collapses; the
panel body scrolls within whatever frame the shell grants.
Mac:
- CommandPanelWebView.swift: CommandHost (webview + "lchat" relay + a boot
queue so the first command never races the page load), CommandHostCache
(LRU, AppWebViewCache's shape), and the NSViewRepresentable panel.
- NativeFeedView: WebPanelCommands-owned drafts forward to the host and
clear; the panel sits in-flow above the banners, sized by the page's
measure (capped 340pt); Escape peels the panel before reply/edit;
{composerInsert} appends to the draft (an /emoji tap); {notice} reuses the
routeNotice inline slot; a "/" draft pre-warms the host. The typeahead now
lists the command-host verbs.
- LChatCore WebPanelCommands: the forwarding allowlist (only commands that
act in the current thread — the embed holds a single thread's key), with
tests.
Verified: the mode:"command" page driven in Chromium with a stubbed webkit
bridge (13 assertions: ready post, collapse-to-panel, panel/height posts,
composerInsert + auto-dismiss on tap, dismissPanel, notice fallback); npm
test + go test green; LChatMac scheme builds; LChatCore 143 tests pass.
iOS follows in a separate change (its chat webview already renders panels;
it needs the composerInsert handler and Escape wiring).
Co-Authored-By: Claude <noreply@anthropic.com>
* web: /gif — Giphy search in a command panel, posted as an encrypted attachment
/gif [search] opens a command panel of Giphy results (in-panel refine box,
trending on a bare /gif); tapping one posts the GIF into the chat as an
ordinary encrypted image message from you — tap = send, like Slack's /giphy,
so the same flow works in the native shells' command-host webview where
there's no web composer to stage into.
The trust model (documented in CLAUDE.md next to unfurl's exception): all
Giphy traffic is CLIENT-side — the lchat server never sees the search, the
pick, or the bytes. The app itself never fetches (invariant #7 holds): the
host provides a scoped doorway, hostBridge.gif { search, bytes, upload } in
shell.js — bytes() accepts only https *.giphy.com URLs, upload() encrypts to
the thread key WITHOUT the image re-encode path so animation survives, and
the CSP widens connect-src to exactly https://*.giphy.com. The Giphy API key
is a public client identifier (an instance setting is a fine follow-up).
pickRendition (downsized → fixed_height → original, size-capped with the
byte fetch re-checking real length) and gifItems are pure + unit-tested.
WebPanelCommands gains /gif so the Mac forwards it and lists it in the
typeahead.
Verified live in Chromium against real Giphy, twice: the browser chat page
(11 assertions — tiles render under the new CSP, refine works, tap posts a
blob:-decrypted image message, panel dismisses, composer stays empty) and
the mode:"command" embed page the Mac webview loads (5 assertions — panel
posts, tap posts the message over the page's own socket, {panel: null}
follows). npm test + go test green.
Co-Authored-By: Claude <noreply@anthropic.com>
* tools: lchat-dev-mac — seed the native Mac app with the dev server's space
The Mac app's only product entry is "Sign in with Eleven" (the home vault),
which can't know about a local dev instance — and the join page's "Open in
the app" deep link (elevenmessenger://add) is deliberately unhandled
(joining lives on the web; a native join flow comes later), silently, with
no logging. So click-testing a branch build against tools/lchat-dev-up.mjs
had no way in.
This script does what the vault would: mints + claims a fresh add-device
token for the seeded "you" account (admin API + the same InviteCrypto
derivation the clients use) and writes a SavedInstance + rail entry into
the app's stores, via InstancesStore's legacy file-read fallback. The file
carries the dev identity's private key in the clear — exactly why the app
never WRITES it — acceptable only for this throwaway localhost identity;
the header says so loudly. Re-run after the app's keychain adopts (and
deletes) the file.
Co-Authored-By: Claude <noreply@anthropic.com>
* mac: fix the panel deadlock in detached webviews; log the command chain
The command-host page is detached from the native view hierarchy until the
shell reveals it — and WebKit suspends requestAnimationFrame in unparented
webviews, so installCommandHost's rAF-deferred {panel} post never fired: the
panel couldn't announce itself, so the shell never attached the webview, so
rendering never resumed. (The Chromium contract drives ran in a visible tab,
which is why they passed.) The measure never needed a frame — scrollHeight
forces layout synchronously — so post immediately; the late re-measure keeps
riding a timer, which detached webviews do run.
Also instrument the whole chain with os.Logger (subsystem
com.elevenmessenger.mac, category "commands"): forward decision, page
load/fail, readiness + queue flush, every post (panel/insert/notice), eval
errors, and the page's own {error} — a silent drop anywhere in this chain is
now visible in `log stream`.
Co-Authored-By: Claude <noreply@anthropic.com>
* dev: ignore the dev server's db + blob store (unswept by tools/lchat-dev-up)
Three encrypted upload blobs from a local dev run slipped into the previous
commit via git add -A — remove them and ignore .lchat-dev.db* and
.lchat-dev-blobs/ alongside the already-ignored dev config.
Co-Authored-By: Claude <noreply@anthropic.com>
* gif: rendition-fallback on post + visible status; surface page errors natively
A native click-test post died silently: the picked rendition's download
failed (or busted the size cap), the catch put its message BELOW the result
grid — out of view in the height-capped panel — and the "Sending…" overlay
was all anyone saw. Server-side forensics showed the click never reached
upload; a headless WKWebView harness against the same seed then posted
fine, pinning the failure to the single-rendition fetch, not the machinery.
- gif.js: posting walks renditionCandidates (downsized → fixed_height →
original, metadata-oversize dropped, deduped) so one bad URL falls back
instead of failing the send; the status line moves ABOVE the grid where a
scrolled panel can't hide it; the failure path logs which step died.
- shell.js: the gif doorway logs each step's failure + gets fetch timeouts;
command-host pages forward window errors, unhandled rejections, and
console.error as {error} posts — CommandHost writes them to the unified
log, so a page-side death is now visible in `log show`.
- Mac: the chain logs move .info → .notice — info is memory-only in the
unified log, which is why the first forensic `log show` came back empty.
Co-Authored-By: Claude <noreply@anthropic.com>
* web+mac: panels ease open/shut; apps opt out with animate: false
A panel now eases up into place on open and rolls down on dismissal — ✕,
Escape, and the app's own panel.close()/ctx.closePanel all take the same
path — honouring the OS reduce-motion setting, with the spec's
`animate: false` choosing instant instead. The command-host embed skips the
in-page ease (the panel IS the page there); the animate flag rides the
{panel} post and the Mac shell eases its frame reveal/collapse natively, so
there's exactly one animation on every surface.
A replace-while-animating starts clean (inline styles reset on show), a
second Escape mid-roll-up is a no-op, and a new panel opening during a
close wins over the stale teardown (captured-spec check).
Co-Authored-By: Claude <noreply@anthropic.com>
* mac: reveal the panel by height, not insertion — kills the fade + size jump
The panel webview now mounts PERMANENTLY at height 0 once the thread's
command host exists, and the reveal animates only the frame height. Three
birds: inserting the view on open was what produced SwiftUI's default
opacity fade (a height ease can't happen across an insertion); a mounted
webview always has the pane's true width, so the page's first height
measure is right and the post-reveal correction disappears; and rendering
callbacks stay live. What correction remains (fonts/images landing) now
eases instead of snapping. Hidden, the frame is 0 with hit-testing and
accessibility off.
docs: the authoring guide's command-panels section now demonstrates the
generic shape — panel buttons dispatching typed events via ctx.emit, the
host-doorway rule for privileged work (gif's pattern), the native
WebPanelCommands step (+ shipping checklist item), and the animate flag.
Co-Authored-By: Claude <noreply@anthropic.com>
* web+mac: a subtle top border + shadow lifts the panel off the feed
The web .cmd-panel keeps its hairline and gains a soft shadow cast toward
the feed (upward for the composer placement, downward for position:"top",
via the theme's --shadow so it follows light/dark). The command-host page
stays chromeless — on the Mac the native frame is the panel's chrome: a
rounded hairline stroke + shadow on the webview, both fading with
visibility so the parked 0-height frame leaves no ghost line above the
composer. iOS inherits the web styling through its embedded feed.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: fix the panel-guards call count the animate assertion shifted
The animate-passthrough open added a third host call; the close assertions
still expected two. Caught by the gif-animation agent running the suite on
this base branch (and missed here by misreading a grep count as clean).
Co-Authored-By: Claude <noreply@anthropic.com>
* web: move the gif app out of the defaults — apps-disabled/ is the install-it-yourself shelf
/gif is no longer a built-in: gif.js moves to web/static/apps-disabled/, a
sibling folder shell.js never imports, kept as the reference for the
INSTALLABLE app paths — paste its source into the Admin console's custom
apps and it runs for every member with full privileges (its "../apps.js"
import resolves identically from /static/apps-custom/, which is why the
folder is a sibling, not a child). The host-side pieces an installed copy
needs — hostBridge.gif and the *.giphy.com CSP entry — stay in the client.
Member-app (sandboxed) uploads deliberately can't run it: no network, no
host doorways, no panel plumbing in the cage. See apps-disabled/README.md.
WebPanelCommands drops /gif (the native typeahead shouldn't advertise a
command that only exists where an admin installed the app — dynamic
forwarding for installed panel commands is an open follow-up), and the
/gif changelog entry goes with it (not a default feature anymore).
Co-Authored-By: Claude <noreply@anthropic.com>
* GIFs animate in the feed; click to pause/resume (web + native Mac) (#506)
* web: click a GIF to pause/resume it in the feed
Animated GIFs already play in the web feed; the click now belongs to a
play/pause toggle instead of the lightbox (the message menu keeps the other
actions). Pausing swaps the img src for a canvas-rendered still of the first
frame; resuming restores the blob URL. prefers-reduced-motion starts GIFs
frozen. Non-GIF images keep the zoom-in lightbox click.
Co-Authored-By: Claude <noreply@anthropic.com>
* mac: animated GIFs play in the native feed; click toggles pause
BlobImageLoader's NSImage(data:) already keeps every GIF frame, so the Mac
feed now hosts image/gif attachments (with >1 frame) in an NSImageView with
animates on, behind a click-through hitTest so the SwiftUI tap toggle,
right-click menu and drag-out keep working. Click = play/pause, matching the
web; stills (and single-frame GIFs) keep the lightbox click. Reduced motion
(NSWorkspace) starts GIFs paused. iOS native-beta feed keeps stills for now —
the default iOS feed is the embedded web page, which the web change covers.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* web: gif back as a default (bring-your-own key); command panels reach member apps
Two halves:
/gif returns to the default set, reworked to ask for the member's OWN free
Giphy API key on first launch instead of shipping a hard-coded one: the
panel shows a key form (link to developers.giphy.com) until a key is saved,
hostBridge.gif stores it in this device's localStorage (a public client
identifier, never sent to the lchat server), search answers "key"/"auth"
reason strings so the form reappears exactly when the key is the problem,
and a footer "API key…" link changes it later. WebPanelCommands lists /gif
again and the changelog entry returns with the new wording.
Member apps get the command-panel plumbing — the declarative form, since a
render closure can't cross the sandbox bridge: a command calls
ctx.panel({ title, data }) and the app declares a top-level
panel(data, el, panelCtx) hook. The host mounts a FRESH /app-sandbox frame
inside the real panel (same opaque-origin, no-network cage; header + ✕ stay
host-drawn), inits it with { panel: { data } }, and the frame runs the hook
with panelCtx = ctx + { close, insertText }. Everything crossing is
validated JSON: sanitizePanelSpec (clamped title, whitelisted
position/animate, 64 KB data cap — field-wise, so unset options don't trip
the whole-object cleaner), emits ride the existing sanitize + rate limit,
inserts and size reports are clamped, panel frames' state reports are
ignored (the app-thread's own frame stays the single writer), and a panel
can only open while one of the app's commands is live (resolved by command
seq — "a response, not an ambush" holds for member apps too). The trusted
world gains the same declarative form (panelSpecFor), so one file runs
identically compiled-in or pasted into App Studio.
apps-disabled/mood.js is that file: /mood opens a five-button feeling panel;
a tap emits mood.set and closes. Verified end-to-end in Chromium: deployed
through App Studio's real path, /mood typed in a chat, the button clicked
INSIDE the sandboxed panel frame, the panel dismissed over the bridge, and
exactly one encrypted event landed in the app-thread. Docs: a Command
panels section in building-member-apps.md, the trust-model addendum in
member-apps.md, and the declarative form in the authoring guide.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
31 files changed,
+2297
−35
.gitignore+2 −0CLAUDE.md+15 −5changelog/2026-07-09-command-panels.md+5 −0changelog/2026-07-09-gif-click-to-pause.md+3 −0changelog/2026-07-09-gif-command.md+8 −0docs/building-member-apps.md+56 −0docs/member-apps.md+19 −0ios/LChatCore/Sources/LChatCore/AnimatedGIFView.swift+68 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+36 −6ios/LChatCore/Sources/LChatCore/Models.swift+1 −0ios/LChatCore/Sources/LChatCore/SlashCommands.swift+24 −0ios/LChatCore/Tests/LChatCoreTests/AnimatedGIFTests.swift+59 −0ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift+22 −0ios/LChatMac/CommandPanelWebView.swift+222 −0ios/LChatMac/NativeChatView.swift+1 −0ios/LChatMac/NativeFeedView.swift+127 −7main.go+5 −1test/apps.test.mjs+169 −0test/sandbox.test.mjs+34 −1tools/lchat-dev-mac.mjs+101 −0web/static/apps-disabled/README.md+24 −0web/static/apps-disabled/mood.js+91 −0web/static/apps.js+95 −1web/static/apps/README.md+125 −0web/static/apps/chat.js+221 −3web/static/apps/emoji.js+84 −0web/static/apps/gif.js+300 −0web/static/sandbox-frame.js+66 −0web/static/sandbox.js+133 −11web/static/shell.js+170 −0web/static/style.css+11 −0
diff --git a/.gitignore b/.gitignore| index 9289367..5c585ea 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -13,6 +13,8 @@ |
| # the dev bot's keys/device token. (The db they point at is covered by *.db.) |
| .lchat-dev.json |
| .lchat-dev-bot.json |
| +.lchat-dev.db* |
| +.lchat-dev-blobs/ |
| # macOS |
| .DS_Store |
diff --git a/CLAUDE.md b/CLAUDE.md| index c8b6f85..8328961 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -29,10 +29,16 @@ nothing."** Every change must preserve that: |
| and MIME type ride *inside* the encrypted message payload, never as server |
| metadata. |
| -The single deliberate exception: **link previews** (`unfurl.go`) — the server |
| -fetches a posted URL to build an OpenGraph card, so it briefly sees URLs (not |
| -message text). This was an explicit, accepted trade-off and is easy to gate |
| -behind a setting later. |
| +Two deliberate exceptions, both easy to gate behind a setting later: |
| + |
| +- **Link previews** (`unfurl.go`) — the *server* fetches a posted URL to build |
| + an OpenGraph card, so it briefly sees URLs (not message text). |
| +- **GIF search** (`apps/gif.js` + `hostBridge.gif` in `shell.js`) — the |
| + *client* queries Giphy directly (CSP pins `connect-src` to `*.giphy.com`), |
| + so Giphy sees the member's search terms and IP; the lchat server sees |
| + nothing — the chosen GIF is downloaded client-side, encrypted to the thread |
| + key, and posted as an ordinary opaque attachment. The Giphy API key in |
| + `shell.js` is a public client identifier, not a secret. |
| Instance **branding** is plaintext by design and doesn't count against the rule — |
| it's admin-set, never user content. Three things: the **group name** (`config` |
| @@ -157,7 +163,11 @@ mode is being too clever. |
| needs to move you or change the UI, offer that as an **action chip** on the |
| response, so leaving the current context is always the user's tap, never the |
| command's side effect. (Personal commands that navigate in the app are a |
| - possible *later* addition — never the default.) |
| + possible *later* addition — never the default.) A response may also be a |
| + **command panel** — a temporary, interactive results surface in the same chat |
| + (above the composer, or pinned above the feed) that the user or the app |
| + dismisses: still in place, still transient, never a navigation (`ctx.panel`; |
| + see `web/static/apps/README.md § Command panels`). |
| - **Apps can live *alongside* the chat — but never must.** A mini-app may present |
| as a second view onto the same thread (a Chat conversation *and* an Interface, |
| toggled in the header), or it may take over the thread's full surface. The |
diff --git a/changelog/2026-07-09-command-panels.md b/changelog/2026-07-09-command-panels.md| new file mode 100644 |
| index 0000000..b323bf7 |
| --- /dev/null |
| +++ b/changelog/2026-07-09-command-panels.md |
| @@ -0,0 +1,5 @@ |
| +🤖 Slash commands can now answer with a panel — a temporary surface that |
| +appears right above the message box with something to tap, then gets out of |
| +your way. First up: type `/emoji` (or `/emoji cat` to search) and a picker |
| +opens above the composer; tap an emoji and it drops straight into your |
| +message. Dismiss a panel any time with its ✕ or the Escape key. |
diff --git a/changelog/2026-07-09-gif-click-to-pause.md b/changelog/2026-07-09-gif-click-to-pause.md| new file mode 100644 |
| index 0000000..438e5b5 |
| --- /dev/null |
| +++ b/changelog/2026-07-09-gif-click-to-pause.md |
| @@ -0,0 +1,3 @@ |
| +🤖 GIFs in the chat now come with a pause button — sort of. Click (or tap) an |
| +animated GIF and it freezes in place; click again and it plays on. And if your |
| +device is set to reduce motion, GIFs arrive paused and only move when you ask. |
diff --git a/changelog/2026-07-09-gif-command.md b/changelog/2026-07-09-gif-command.md| new file mode 100644 |
| index 0000000..103c462 |
| --- /dev/null |
| +++ b/changelog/2026-07-09-gif-command.md |
| @@ -0,0 +1,8 @@ |
| +🤖 Type `/gif` in any chat to search Giphy without leaving the conversation — |
| +results appear in a panel above the message box, and tapping one sends it |
| +right into the chat as a picture from you. Add a search right in the command |
| +(`/gif high five`) or refine in the panel. The first time you use it, it asks |
| +for your own free Giphy API key (grab one at developers.giphy.com) — the key |
| +stays on your device, and your searches go to Giphy directly; our server |
| +never sees them. Your message and the GIF itself stay end-to-end encrypted |
| +like everything else. |
diff --git a/docs/building-member-apps.md b/docs/building-member-apps.md| index 1c26eb3..4fe42be 100644 |
| --- a/docs/building-member-apps.md |
| +++ b/docs/building-member-apps.md |
| @@ -268,6 +268,62 @@ view runs that command — so "buy milk" adds an item, exactly like typing into |
| To-do. It only applies in the app's own thread; when a real bot is in the thread, |
| plain text stays an ordinary message. |
| +## Command panels — a temporary surface where the member typed |
| + |
| +A command can answer with more than a reply: `ctx.panel(...)` opens a |
| +**panel** — a transient, interactive surface in the chat the member typed in, |
| +just above the composer, with a host-drawn header and ✕ (Escape works too). |
| +In the sandbox the form is **declarative** — a render closure can't cross the |
| +bridge — so it's two pieces: |
| + |
| +1. The command handler calls `ctx.panel({ title, data })`. `data` is plain |
| + JSON handed to your hook (results, the typed args, whatever the panel |
| + needs). Optional: `position: "top"` (pin above the feed), |
| + `animate: false` (no open/close ease). Returns a Promise<boolean> — false |
| + means no panel could open (fall back to `ctx.reply`). |
| +2. The app declares a top-level **`panel(data, el, panelCtx)`** hook — it runs |
| + in a fresh sandboxed frame mounted inside the real panel, and draws into |
| + `el` exactly like `render` does (same DOM rules, same theme variables, your |
| + `styles` apply). |
| + |
| +`panelCtx` is the app `ctx` (emit / schedule / id / me / getState) plus: |
| + |
| +- `panelCtx.close()` — dismiss the panel (e.g. after a tap did its job). |
| +- `panelCtx.insertText(t)` — insert text at the composer's caret (clamped; |
| + use it in response to a tap, never spontaneously). |
| + |
| +The whole shape, from `web/static/apps-disabled/mood.js` (the working |
| +example — paste it into App Studio to try): |
| + |
| +```js |
| +commands: { |
| + "/mood": { |
| + desc: "log how you're feeling", |
| + run(args, ctx) { |
| + ctx.panel({ title: "How are you feeling?", data: { note: args.trim() } }); |
| + }, |
| + }, |
| +}, |
| +panel(data, el, ctx) { |
| + for (const mood of ["😄", "🙂", "😐"]) { |
| + const b = document.createElement("button"); |
| + b.textContent = mood; |
| + b.onclick = async () => { |
| + await ctx.emit({ type: "mood.set", id: ctx.id(), mood, note: data.note }); |
| + ctx.close(); |
| + }; |
| + el.appendChild(b); |
| + } |
| +}, |
| +``` |
| + |
| +Rules: a panel opens only from a command the member just ran (the host |
| +enforces it); one panel per app (opening another replaces it); the panel frame |
| +is the same no-network, no-keys cage as the app's own surface — `data` and |
| +everything you send back cross the bridge as validated, size-capped JSON. The |
| +declarative form also works for compiled-in apps, so a file written this way |
| +runs identically in both worlds. |
| + |
| ## Companion chat — the thread reads as a conversation |
| By default a member app **takes over** its thread (the surface is the whole |
diff --git a/docs/member-apps.md b/docs/member-apps.md| index bebcad7..554b50f 100644 |
| --- a/docs/member-apps.md |
| +++ b/docs/member-apps.md |
| @@ -239,3 +239,22 @@ never enter the frame. |
| admin can delete any via the API today). |
| - In-place update of a running app without a reload (updates currently reload |
| to swap code). |
| + |
| +## 🤖 Command panels (member apps) |
| + |
| +A member app's command may open a command panel (`ctx.panel` — the transient |
| +surface above the composer). The trust posture is unchanged: |
| + |
| +- The panel BODY is a fresh `/app-sandbox` frame — the same opaque-origin, |
| + no-network, no-keys cage as the app's own surface. The host never runs the |
| + app's panel code in the page. |
| +- Everything crosses the bridge as validated JSON: the request |
| + (`sanitizePanelSpec` — clamped title, 64 KB data cap), emits from the panel |
| + frame (the standard sanitize + rate limit), composer inserts (length-capped), |
| + and content-height reports (clamped). |
| +- A panel opens only while one of the app's commands is actually running (the |
| + host resolves the opener by the command's sequence number), so a frame can't |
| + pop panels spontaneously. |
| +- The panel HEADER and ✕ are host-drawn, outside the frame — dismissal can't |
| + be trapped. A panel frame's `state` reports are ignored; the app-thread's |
| + own frame stays the single writer. |
diff --git a/ios/LChatCore/Sources/LChatCore/AnimatedGIFView.swift b/ios/LChatCore/Sources/LChatCore/AnimatedGIFView.swift| new file mode 100644 |
| index 0000000..bb456b6 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/AnimatedGIFView.swift |
| @@ -0,0 +1,68 @@ |
| +import SwiftUI |
| + |
| +// Animated-GIF playback for the native Mac feed. BlobImageLoader decodes an |
| +// attachment's decrypted bytes with NSImage(data:), which keeps every GIF |
| +// frame in its bitmap rep — so the cached image animates as-is inside an |
| +// NSImageView (`animates = true`), no second decode and no second cache. |
| +// SwiftUI's Image would flatten it to the first frame, which is exactly what |
| +// the paused state shows (matching the web's canvas-drawn still). |
| + |
| +/// Whether the system asks for reduced motion — animated GIFs then start |
| +/// paused (a click still plays them). The web twin is chat.js honouring |
| +/// prefers-reduced-motion in wireGifToggle. |
| +func gifsStartPaused() -> Bool { |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + return NSWorkspace.shared.accessibilityDisplayShouldReduceMotion |
| + #else |
| + // iOS renders GIFs as stills in the native beta feed (the default feed is |
| + // the web page, which handles reduced motion itself), so nothing to gate. |
| + return false |
| + #endif |
| +} |
| + |
| +#if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| +import AppKit |
| + |
| +extension NSImage { |
| + /// Frames in the image's bitmap rep — >1 means it will actually animate. |
| + /// A still that merely claims image/gif (single-frame GIF) reports 1 and |
| + /// keeps the ordinary photo behaviour (lightbox on click). |
| + var gifFrameCount: Int { |
| + guard let rep = representations.first as? NSBitmapImageRep, |
| + let n = rep.value(forProperty: .frameCount) as? Int else { return 1 } |
| + return n |
| + } |
| +} |
| + |
| +/// NSImageView host for an animated GIF; `playing` pauses/resumes (paused |
| +/// shows the first frame). Events pass straight through the AppKit view, so |
| +/// the SwiftUI container keeps the tap toggle, the right-click menu, and |
| +/// drag-out exactly as for still photos. |
| +struct AnimatedGIFView: NSViewRepresentable { |
| + let image: NSImage |
| + let playing: Bool |
| + |
| + func makeNSView(context: Context) -> ClickThroughImageView { |
| + let v = ClickThroughImageView() |
| + v.imageScaling = .scaleProportionallyUpOrDown |
| + v.image = image |
| + v.animates = playing |
| + // The SwiftUI .frame decides the size — the GIF's intrinsic size must |
| + // not push the bubble wider than the reserved w/h box. |
| + v.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) |
| + v.setContentCompressionResistancePriority(.defaultLow, for: .vertical) |
| + return v |
| + } |
| + |
| + func updateNSView(_ v: ClickThroughImageView, context: Context) { |
| + if v.image !== image { v.image = image } |
| + v.animates = playing |
| + } |
| + |
| + // NSImageView is an NSControl, so left it would swallow the clicks the |
| + // bubble needs; returning nil routes every event to the SwiftUI layer. |
| + final class ClickThroughImageView: NSImageView { |
| + override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| + } |
| +} |
| +#endif |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index f376616..274db5f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -776,6 +776,8 @@ public struct MessageBubble: View { |
| @State private var image: PlatformImage? |
| // The thumbnail's live window frame — the lightbox zooms out from here. |
| @State private var globalFrame = CGRect.zero |
| + // Animated GIFs only: reduced-motion users get them paused at first. |
| + @State private var playing = !gifsStartPaused() |
| var body: some View { |
| let box = displaySize() |
| @@ -783,11 +785,17 @@ public struct MessageBubble: View { |
| RoundedRectangle(cornerRadius: 18, style: .continuous) |
| .fill(theme.bubbleThem.resolved(scheme)) |
| if let image { |
| - Image(platform: image) |
| - .resizable() |
| - .scaledToFill() |
| - .frame(width: box.width, height: box.height) |
| - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + if isAnimatedGIF(image) { |
| + AnimatedGIFView(image: image, playing: playing) |
| + .frame(width: box.width, height: box.height) |
| + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| + } else { |
| + still(image, box: box) |
| + } |
| + #else |
| + still(image, box: box) |
| + #endif |
| } |
| } |
| .frame(width: box.width, height: box.height) |
| @@ -798,7 +806,11 @@ public struct MessageBubble: View { |
| }) |
| .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| .onTapGesture { |
| - if let image { onTap?(attachment, image, globalFrame) } // the web's zoom-in click |
| + guard let image else { return } |
| + // An animated GIF claims the click for play/pause (as on the |
| + // web); everything else keeps the zoom-in lightbox click. |
| + if isAnimatedGIF(image) { playing.toggle() } |
| + else { onTap?(attachment, image, globalFrame) } |
| } |
| .task(id: attachment.id) { |
| guard image == nil, let source else { return } |
| @@ -811,6 +823,24 @@ public struct MessageBubble: View { |
| .help(attachment.name ?? "photo") |
| } |
| + private func still(_ image: PlatformImage, box: CGSize) -> some View { |
| + Image(platform: image) |
| + .resizable() |
| + .scaledToFill() |
| + .frame(width: box.width, height: box.height) |
| + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| + } |
| + |
| + // A GIF that actually has frames to play — a single-frame "GIF" is |
| + // just a photo and keeps photo behaviour (SwiftUI still + lightbox). |
| + private func isAnimatedGIF(_ image: PlatformImage) -> Bool { |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + return attachment.isGIF && image.gifFrameCount > 1 |
| + #else |
| + return false // iOS native beta renders GIFs as stills for now |
| + #endif |
| + } |
| + |
| // chat.js: scale = min(260/w, 320/h, 1). Unknown dimensions (very old |
| // payloads) get a modest square placeholder rather than a layout jump. |
| private func displaySize() -> CGSize { |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 3552ac9..717de0f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -177,6 +177,7 @@ public struct Attachment: Codable, Hashable, Sendable { |
| public var dur: Double? // video duration, seconds |
| public var isImage: Bool { type?.hasPrefix("image/") ?? false } |
| public var isVideo: Bool { type?.hasPrefix("video/") ?? false } |
| + public var isGIF: Bool { type == "image/gif" } // what /gif posts (gif.js) |
| } |
| // A group's encrypted name payload (threads.name_blob decrypts to this). |
diff --git a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift| index 3189312..afa83db 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift |
| @@ -58,3 +58,27 @@ public enum SlashCommand: Equatable { |
| return ChatTheme.presets.first { $0.id == a }?.id |
| } |
| } |
| + |
| +// 🤖 Commands the native shell forwards to its per-thread COMMAND-HOST webview |
| +// (the web command engine — shell.js installCommandHost) instead of handling |
| +// natively or sending as a message. These are the web's panel-opening commands; |
| +// only ones that act in the CURRENT thread (or need no thread at all) belong |
| +// here — the embed page holds a single thread's key, so a command that routes |
| +// to another thread (/todo) can't resolve there. Keep name + desc matching the |
| +// web app's declaration (apps/emoji.js), like the botCommands mirror in |
| +// ComposerTypeahead.swift. |
| +public enum WebPanelCommands { |
| + public static let catalog: [(name: String, desc: String)] = [ |
| + ("/emoji", "browse emoji — tap one to add it to your message"), |
| + ("/gif", "find a GIF — tap one to send it here"), |
| + ] |
| + |
| + /// Whether composer text names a command the command host answers — |
| + /// matched on the first token, case-insensitively, like SlashCommand.parse. |
| + public static func owns(_ text: String) -> Bool { |
| + let t = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard t.first == "/" else { return false } |
| + let name = t.split(separator: " ", maxSplits: 1).first.map(String.init) ?? t |
| + return catalog.contains { $0.name == name.lowercased() } |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AnimatedGIFTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AnimatedGIFTests.swift| new file mode 100644 |
| index 0000000..af52fe3 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/AnimatedGIFTests.swift |
| @@ -0,0 +1,59 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// The GIF gate MessageBubble's AttachmentImage animates behind: the payload |
| +// type says "GIF" (what /gif posts — see gif.js) and the decoded image really |
| +// has frames to play. Single-frame GIFs and mislabelled stills stay photos. |
| +final class AnimatedGIFTests: XCTestCase { |
| + func testIsGIFMatchesExactType() { |
| + XCTAssertTrue(Attachment(id: "a", type: "image/gif").isGIF) |
| + XCTAssertFalse(Attachment(id: "a", type: "image/jpeg").isGIF) |
| + XCTAssertFalse(Attachment(id: "a", type: "image/gif+xml").isGIF) |
| + XCTAssertFalse(Attachment(id: "a", type: nil).isGIF) |
| + } |
| +} |
| + |
| +#if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| +import AppKit |
| +import ImageIO |
| +import UniformTypeIdentifiers |
| + |
| +final class GIFFrameCountTests: XCTestCase { |
| + func testAnimatedGIFReportsItsFrames() throws { |
| + let img = try XCTUnwrap(NSImage(data: makeImage(frames: 3, as: .gif))) |
| + XCTAssertEqual(img.gifFrameCount, 3) |
| + } |
| + |
| + func testSingleFrameGIFReportsOne() throws { |
| + let img = try XCTUnwrap(NSImage(data: makeImage(frames: 1, as: .gif))) |
| + XCTAssertEqual(img.gifFrameCount, 1) |
| + } |
| + |
| + func testStillPNGReportsOne() throws { |
| + let img = try XCTUnwrap(NSImage(data: makeImage(frames: 1, as: .png))) |
| + XCTAssertEqual(img.gifFrameCount, 1) |
| + } |
| + |
| + // A tiny solid-colour image encoded with `frames` frames, via ImageIO — |
| + // the same decoder family NSImage(data:) reads back through. |
| + private func makeImage(frames: Int, as type: UTType) throws -> Data { |
| + let data = NSMutableData() |
| + let dest = try XCTUnwrap(CGImageDestinationCreateWithData( |
| + data, type.identifier as CFString, frames, nil)) |
| + let frameProps = [kCGImagePropertyGIFDictionary: |
| + [kCGImagePropertyGIFDelayTime: 0.1]] as CFDictionary |
| + for i in 0..<frames { |
| + let shade = CGFloat(i) / CGFloat(max(frames - 1, 1)) |
| + let ctx = try XCTUnwrap(CGContext( |
| + data: nil, width: 4, height: 4, bitsPerComponent: 8, bytesPerRow: 0, |
| + space: CGColorSpaceCreateDeviceRGB(), |
| + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) |
| + ctx.setFillColor(CGColor(red: shade, green: 0, blue: 1 - shade, alpha: 1)) |
| + ctx.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) |
| + CGImageDestinationAddImage(dest, try XCTUnwrap(ctx.makeImage()), frameProps) |
| + } |
| + XCTAssertTrue(CGImageDestinationFinalize(dest)) |
| + return data as Data |
| + } |
| +} |
| +#endif |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift| index eb43c59..ed75489 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift |
| @@ -25,3 +25,25 @@ final class SlashCommandsTests: XCTestCase { |
| XCTAssertEqual(SlashCommand.parse("/ hey", meName: "P"), .sendAsMessage) |
| } |
| } |
| + |
| +// 🤖 WebPanelCommands — the commands the native shell forwards to its |
| +// per-thread command-host webview (the web command engine) instead of |
| +// handling natively or sending as a message. |
| +final class WebPanelCommandsTests: XCTestCase { |
| + func testOwnsMatchesTheFirstToken() { |
| + XCTAssertTrue(WebPanelCommands.owns("/emoji")) |
| + XCTAssertTrue(WebPanelCommands.owns("/emoji cat")) |
| + XCTAssertTrue(WebPanelCommands.owns(" /EMOJI heart ")) // trimmed + case-insensitive |
| + XCTAssertTrue(WebPanelCommands.owns("/gif office party")) |
| + } |
| + func testOwnsRejectsEverythingElse() { |
| + XCTAssertFalse(WebPanelCommands.owns("/emojis cat")) // whole-token match only |
| + XCTAssertFalse(WebPanelCommands.owns("/me waves")) // native-handled |
| + XCTAssertFalse(WebPanelCommands.owns("/todo buy milk"))// routes to another thread — never forwarded |
| + XCTAssertFalse(WebPanelCommands.owns("emoji")) // no slash → plain text |
| + XCTAssertFalse(WebPanelCommands.owns("")) |
| + } |
| + func testCatalogFeedsTheTypeahead() { |
| + XCTAssertTrue(WebPanelCommands.catalog.contains { $0.name == "/emoji" && !$0.desc.isEmpty }) |
| + } |
| +} |
diff --git a/ios/LChatMac/CommandPanelWebView.swift b/ios/LChatMac/CommandPanelWebView.swift| new file mode 100644 |
| index 0000000..737a0eb |
| --- /dev/null |
| +++ b/ios/LChatMac/CommandPanelWebView.swift |
| @@ -0,0 +1,222 @@ |
| +import LChatCore |
| +import SwiftUI |
| +import WebKit |
| +import os |
| + |
| +// Diagnostics for the whole command→panel chain (page load, readiness, every |
| +// post, run/dismiss evals). Watch with: |
| +// log stream --predicate 'subsystem == "com.elevenmessenger.mac" && category == "commands"' |
| +let cmdLog = Logger(subsystem: "com.elevenmessenger.mac", category: "commands") |
| + |
| +// 🤖 The command engine + panel surface for a NATIVE chat thread. The Mac feed |
| +// renders chat natively, so it has no JS to run mini-app slash commands — this |
| +// per-thread webview is that engine: the same embed page AppThreadWebView uses, |
| +// seeded with mode:"command" (shell.js installCommandHost), which collapses the |
| +// page to just a command's PANEL. The shell keeps it hidden, forwards a typed |
| +// command to window.lchatNative.runCommand, and reveals the webview above the |
| +// composer only while a panel is showing — the webview IS the panel. Posts back |
| +// on the "lchat" handler: {commandHostReady}, {panel: {title, height} | null}, |
| +// {panelHeight}, {composerInsert} (a panel inserting into the native composer), |
| +// and {notice} (a command's private "only visible to you" reply). |
| + |
| +// What the command-host page tells the shell. |
| +enum CommandHostEvent { |
| + case panel(title: String, height: CGFloat, animate: Bool) // reveal (or retitle) the panel |
| + case panelClosed // collapse it (✕, Escape, app close) |
| + case panelHeight(CGFloat) // late layout settled — resize |
| + case composerInsert(String) // insert into the native composer |
| + case notice(from: String, text: String) // a private reply — render inline |
| +} |
| + |
| +// One thread's command host: the webview, its readiness, and the queue of |
| +// commands typed before the page finished booting. Owned by CommandHostCache |
| +// (which outlives any one SwiftUI view); the feed view on screen points `sink` |
| +// at itself, exactly like the model-cache re-point pattern. |
| +@MainActor |
| +final class CommandHost: NSObject, WKScriptMessageHandler { |
| + let web: WKWebView |
| + var sink: ((CommandHostEvent) -> Void)? |
| + private var ready = false |
| + private var queued: [String] = [] |
| + |
| + init(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?) { |
| + var seed: [String: Any] = [ |
| + "token": instance.deviceToken, |
| + "name": instance.screenName, |
| + "myUserId": myUserId, |
| + "threadId": thread.id, |
| + "threadKey": thread.threadKeyB64, |
| + "mode": "command", |
| + ] |
| + if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| + let cfg = WKWebViewConfiguration() |
| + cfg.websiteDataStore = .default() |
| + if let data = try? JSONSerialization.data(withJSONObject: seed), |
| + let json = String(data: data, encoding: .utf8) { |
| + cfg.userContentController.addUserScript( |
| + WKUserScript(source: "window.__lchatEmbed = \(json);", |
| + injectionTime: .atDocumentStart, forMainFrameOnly: true)) |
| + } |
| + web = WKWebView(frame: .zero, configuration: cfg) |
| + super.init() |
| + // A weak proxy breaks the controller→handler→self→web retain cycle, so |
| + // an evicted host actually deallocates (teardown also unregisters). |
| + cfg.userContentController.add(WeakMessageHandler(self), name: "lchat") |
| + web.navigationDelegate = self |
| + web.setValue(false, forKey: "drawsBackground") // blend with the composer chrome |
| + if let url = instance.baseURL { |
| + cmdLog.notice("command host loading \(url.absoluteString, privacy: .public) for thread \(thread.id, privacy: .public)") |
| + web.load(URLRequest(url: url)) |
| + } else { |
| + cmdLog.error("command host has no base URL (origin \(instance.origin, privacy: .public))") |
| + } |
| + } |
| + |
| + // Run a slash command through the page's engine. Booting queues the text — |
| + // the page flushes on {commandHostReady} — so the first command typed in a |
| + // thread never races the load. |
| + func run(_ text: String) { |
| + guard ready else { |
| + cmdLog.notice("command queued until the page is ready: \(text, privacy: .public)") |
| + queued.append(text) |
| + return |
| + } |
| + guard let data = try? JSONSerialization.data(withJSONObject: [text]), |
| + let arg = String(data: data, encoding: .utf8) else { return } |
| + web.evaluateJavaScript( |
| + "window.lchatNative && window.lchatNative.runCommand ? window.lchatNative.runCommand(\(arg)[0]) : false" |
| + ) { result, error in |
| + if let error { |
| + cmdLog.error("runCommand eval failed: \(error.localizedDescription, privacy: .public)") |
| + } else { |
| + cmdLog.notice("runCommand handled=\((result as? Bool) ?? false) for \(text, privacy: .public)") |
| + } |
| + } |
| + } |
| + |
| + // Ask the page to dismiss its panel (Escape in the native composer). The |
| + // actual collapse follows on the {panel: null} post, so the page's onClose |
| + // hook always runs — never hide the frame directly. |
| + func dismissPanel() { |
| + web.evaluateJavaScript( |
| + "window.lchatNative && window.lchatNative.dismissPanel && window.lchatNative.dismissPanel()", |
| + completionHandler: nil) |
| + } |
| + |
| + func teardown() { |
| + web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat") |
| + web.stopLoading() |
| + sink = nil |
| + } |
| + |
| + // MARK: WKScriptMessageHandler |
| + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| + guard let dict = message.body as? [String: Any] else { return } |
| + if dict["commandHostReady"] as? Bool == true { |
| + cmdLog.notice("command host ready (\(self.queued.count) queued)") |
| + ready = true |
| + let pending = queued |
| + queued = [] |
| + for text in pending { run(text) } |
| + return |
| + } |
| + if dict.keys.contains("panel") { |
| + if let p = dict["panel"] as? [String: Any] { |
| + let h = (p["height"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 240 |
| + cmdLog.notice("panel open: \(p["title"] as? String ?? "", privacy: .public) height \(h)") |
| + sink?(.panel(title: p["title"] as? String ?? "", height: h, |
| + animate: (p["animate"] as? Bool) ?? true)) |
| + } else { |
| + cmdLog.notice("panel closed") |
| + sink?(.panelClosed) |
| + } |
| + return |
| + } |
| + if let h = dict["panelHeight"] as? NSNumber { |
| + sink?(.panelHeight(CGFloat(truncating: h))) |
| + return |
| + } |
| + if let t = dict["composerInsert"] as? String { |
| + cmdLog.notice("composerInsert (\(t.count) chars)") |
| + sink?(.composerInsert(t)) |
| + return |
| + } |
| + if let n = dict["notice"] as? [String: Any] { |
| + cmdLog.notice("notice from \(n["from"] as? String ?? "", privacy: .public)") |
| + sink?(.notice(from: n["from"] as? String ?? "", text: n["text"] as? String ?? "")) |
| + return |
| + } |
| + // The page reporting a boot problem ({error} — bootEmbed's catch). |
| + if let e = dict["error"] as? String { |
| + cmdLog.error("command host page error: \(e, privacy: .public)") |
| + } |
| + } |
| +} |
| + |
| +// Load diagnostics: a page that never reaches didFinish explains a silent |
| +// panel better than anything else can. |
| +extension CommandHost: WKNavigationDelegate { |
| + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { |
| + cmdLog.notice("command host page loaded") |
| + } |
| + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { |
| + cmdLog.error("command host page failed: \(error.localizedDescription, privacy: .public)") |
| + } |
| + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { |
| + cmdLog.error("command host page failed to start: \(error.localizedDescription, privacy: .public)") |
| + } |
| +} |
| + |
| +// WKUserContentController retains its handlers strongly; this proxy keeps the |
| +// host owning the controller without a cycle (the standard WKWebView pattern). |
| +private final class WeakMessageHandler: NSObject, WKScriptMessageHandler { |
| + weak var target: WKScriptMessageHandler? |
| + init(_ target: WKScriptMessageHandler) { self.target = target } |
| + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| + target?.userContentController(controller, didReceive: message) |
| + } |
| +} |
| + |
| +// Keeps each thread's command host alive across thread switches, so a second |
| +// slash command in a revisited chat is instant. Same LRU shape as |
| +// AppWebViewCache; hosts are cheap to rebuild, so the cap stays small. |
| +@MainActor |
| +final class CommandHostCache { |
| + static let shared = CommandHostCache() |
| + |
| + private struct Entry { |
| + let host: CommandHost |
| + let seedKey: String |
| + var lastUsed = Date() |
| + } |
| + |
| + private var entries: [String: Entry] = [:] // "instanceID|threadID" |
| + private let maxEntries = 4 |
| + |
| + func host(for instance: SavedInstance, thread: ChatThread, |
| + myUserId: String, instanceKeyB64: String?) -> CommandHost { |
| + let key = instance.id + "|" + thread.id |
| + if var e = entries[key], e.seedKey == thread.threadKeyB64 { |
| + e.lastUsed = Date() |
| + entries[key] = e |
| + return e.host |
| + } |
| + entries.removeValue(forKey: key)?.host.teardown() |
| + let host = CommandHost(instance: instance, thread: thread, |
| + myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| + entries[key] = Entry(host: host, seedKey: thread.threadKeyB64) |
| + while entries.count > maxEntries, |
| + let oldest = entries.filter({ $0.value.host.web.superview == nil }) |
| + .min(by: { $0.value.lastUsed < $1.value.lastUsed }) { |
| + entries.removeValue(forKey: oldest.key)?.host.teardown() |
| + } |
| + return host |
| + } |
| +} |
| + |
| +// The visible panel: just the host's webview, framed by the composer stack. |
| +struct CommandPanelWebView: NSViewRepresentable { |
| + let host: CommandHost |
| + func makeNSView(context: Context) -> WKWebView { host.web } |
| + func updateNSView(_ web: WKWebView, context: Context) {} |
| +} |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 4a384d8..4a71803 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -461,6 +461,7 @@ struct NativeChatView: View { |
| isGroup: thread.kind == "group" || thread.members.count > 2, |
| isActive: isActive, |
| roster: store.roster, |
| + instance: instance, instanceKeyB64: store.instanceKey(), |
| mentionAvatar: { [weak store] uid in |
| store.flatMap { s in |
| s.roster.first { $0.user_id == uid }.flatMap(s.avatarSource(for:)) |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 4e5c9d6..3a953e8 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -46,6 +46,7 @@ struct NativeFeedView: View { |
| init(model: ChatFeedModel, thread: ChatThread, myUserId: String, isGroup: Bool, |
| isActive: Bool = true, roster: [RosterEntry], |
| + instance: SavedInstance? = nil, instanceKeyB64: String? = nil, |
| mentionAvatar: @escaping (String) -> AvatarSource?) { |
| // The model arrives PARKED (NativeFeedModelCache) — folded messages and |
| // a live socket survive leaving the chat, so re-opening never flashes. |
| @@ -61,16 +62,25 @@ struct NativeFeedView: View { |
| // The app-thread's default command (To-do: plain text adds a to-do) — |
| // the web's defaultCommandFor, so every composer routes identically. |
| self.defaultCommand = AppDefaultCommand.spec(appID: thread.appId, hasBot: self.hasBot) |
| - // The thread's app-local verbs for the "/" typeahead (/add in To-do) — |
| - // shown bot or no bot, like the web's commandCatalog(threadId). |
| - self.appCommands = AppDefaultCommand.localCommands(appID: thread.appId) |
| + // The thread's app-local verbs for the "/" typeahead (/add in To-do), |
| + // then the command-host verbs (/emoji) — every native composer offers |
| + // what the per-thread command webview can answer (see sendDraft). |
| + self.appCommands = AppDefaultCommand.localCommands(appID: thread.appId) + WebPanelCommands.catalog |
| self.threadID = thread.id |
| + // The command host needs the full thread (key) + instance to seed its |
| + // webview; nil (a mount without an instance) just disables forwarding. |
| + self.thread = thread |
| + self.instance = instance |
| + self.instanceKeyB64 = instanceKeyB64 |
| } |
| private let defaultCommand: AppDefaultCommand.Spec? |
| private let appCommands: [(name: String, desc: String)] |
| private let threadID: String |
| + private let thread: ChatThread |
| + private let instance: SavedInstance? |
| + private let instanceKeyB64: String? |
| // The companion app's start-of-history intro (chatIntro on the web), |
| // shown above the earliest message once all history is loaded. |
| @@ -90,6 +100,13 @@ struct NativeFeedView: View { |
| @State private var routeNotice: String? |
| @State private var routeNoticeGen = 0 |
| @State private var dcRouting = false |
| + // The thread's COMMAND HOST — the hidden per-thread webview that runs |
| + // mini-app slash commands (/emoji) and shows their panels; the panel |
| + // reveals the webview above the composer (see CommandPanelWebView.swift). |
| + @State private var commandHost: CommandHost? |
| + @State private var panelVisible = false |
| + @State private var panelHeight: CGFloat = 240 |
| + @State private var panelAnimates = true // the open post's animate flag, reused on close |
| // The native composer's draft — cleared only when a send actually reaches |
| // the server (model.send returns false on a down socket, keeping the text). |
| @State private var draft = "" |
| @@ -674,6 +691,33 @@ struct NativeFeedView: View { |
| mentionAvatar: mentionAvatar) { pick($0, token: token) } |
| .padding(.horizontal, 10) |
| } |
| + // A command's results panel (the command-host webview — the page |
| + // draws its own header + ✕): in-flow above the banners, occupying |
| + // layout like the web's .cmd-panel, sized by the page's measure. |
| + // Mounted PERMANENTLY at height 0 once the host exists, for three |
| + // reasons: revealing by height (not insertion) animates as a real |
| + // ease-up instead of SwiftUI's insertion fade; the page always has |
| + // the pane's true width, so its height measure is right the first |
| + // time (no post-reveal jump); and an attached webview keeps its |
| + // rendering callbacks live. |
| + if let host = commandHost { |
| + // The command-host page suppresses its own border/shadow (the |
| + // webview is edge-to-edge there) — this frame is the panel's |
| + // chrome: a hairline and a subtle lift, matching the web's |
| + // .cmd-panel. Both fade with visibility so the parked 0-height |
| + // frame leaves no ghost line above the composer. |
| + CommandPanelWebView(host: host) |
| + .frame(height: panelVisible ? panelHeight : 0) |
| + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) |
| + .overlay( |
| + RoundedRectangle(cornerRadius: 10, style: .continuous) |
| + .strokeBorder(.primary.opacity(panelVisible ? 0.08 : 0), lineWidth: 1) |
| + ) |
| + .shadow(color: .black.opacity(panelVisible ? 0.10 : 0), radius: 7, y: 2) |
| + .padding(.horizontal, 8) |
| + .allowsHitTesting(panelVisible) |
| + .accessibilityHidden(!panelVisible) |
| + } |
| if let replyTo { |
| composerBanner("Replying to \(replyTo.name ?? "")", |
| detail: replyTo.snippet) { cancelReply() } |
| @@ -724,6 +768,9 @@ struct NativeFeedView: View { |
| // The typing beats: alive while there's a draft, an immediate |
| // clear when it empties (edits don't announce themselves). |
| model.typingBeat(active: !text.isEmpty && editingID == nil) |
| + // A "/" draft pre-warms the command host, so the page has booted |
| + // by the time Enter runs the command (a cold page queues it). |
| + if text.first == "/", commandHost == nil { ensureCommandHost() } |
| } |
| } |
| @@ -908,10 +955,20 @@ struct NativeFeedView: View { |
| return .ignored |
| } |
| .onKeyPress(.escape) { |
| - guard !taItems.isEmpty else { return .ignored } |
| - taToken = nil |
| - taItems = [] |
| - return .handled |
| + if !taItems.isEmpty { |
| + taToken = nil |
| + taItems = [] |
| + return .handled |
| + } |
| + // An open command panel is the most recently summoned |
| + // layer — Escape peels it before reply/edit below (the |
| + // page posts {panel: null} back, which collapses the |
| + // frame, so the panel's onClose hook always runs). |
| + if panelVisible { |
| + commandHost?.dismissPanel() |
| + return .handled |
| + } |
| + return .ignored |
| } |
| .focused($composerFocused) |
| // Regaining the app drops the caret into the message box, the |
| @@ -982,6 +1039,54 @@ struct NativeFeedView: View { |
| } |
| } |
| + // The thread's command host, created on first use (and pre-warmed the |
| + // moment a draft turns into a "/" — see onChange(of: draft)). Re-points |
| + // `sink` at THIS mount every call, like the model-cache re-point pattern, |
| + // so a revisited thread's cached host talks to the pane on screen. |
| + @discardableResult |
| + private func ensureCommandHost() -> CommandHost? { |
| + guard let instance else { return nil } |
| + let host = commandHost ?? CommandHostCache.shared.host( |
| + for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| + if commandHost == nil { commandHost = host } |
| + host.sink = { event in |
| + switch event { |
| + case .panel(_, let height, let animate): // the web panel draws its own header |
| + // The app's call (spec.animate): ease the reveal, or appear |
| + // instantly. The web panel skips its in-page ease in command- |
| + // host mode, so this is the ONE animation either way. |
| + panelAnimates = animate |
| + withAnimation(animate ? .easeOut(duration: 0.22) : nil) { |
| + panelHeight = min(height, 340) |
| + panelVisible = true |
| + } |
| + case .panelClosed: |
| + withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) { |
| + panelVisible = false |
| + } |
| + case .panelHeight(let h): |
| + // Late re-measures (fonts, images) ease too — an abrupt snap |
| + // here read as a "jump" after the reveal. |
| + if panelVisible { |
| + withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) { |
| + panelHeight = min(h, 340) |
| + } |
| + } |
| + case .composerInsert(let text): |
| + // A panel acting on the composer (an /emoji tap). Caret-aware |
| + // splicing needs the NSTextView-backed field (the typeahead's |
| + // known limitation) — append, the common caret-at-end case. |
| + draft += text |
| + composerFocused = true |
| + case .notice(let from, let text): |
| + // A command's private "only visible to you" reply — the same |
| + // inline slot the default-command push-back uses. |
| + setRouteNotice(from.isEmpty ? text : "\(from) — \(text)") |
| + } |
| + } |
| + return host |
| + } |
| + |
| private func sendDraft() { |
| // An edit replaces the message in place — no attachments/reply staging. |
| // cancelEdit (not inline clears) so an edit inside an open thread view |
| @@ -996,6 +1101,21 @@ struct NativeFeedView: View { |
| // theme, everything else falls through to a normal send so a bot |
| // answers it (matching the web). |
| if staged.isEmpty, replyTo == nil, draft.first == "/" { |
| + // A command the web engine owns (/emoji — WebPanelCommands) runs in |
| + // the thread's command-host webview: its reply posts back as an |
| + // inline notice, a panel reveals the webview above the composer. |
| + // Cleared immediately, like the web composer — runCommand consumes |
| + // a command attempt synchronously (a booting page queues it). |
| + if WebPanelCommands.owns(draft) { |
| + if let host = ensureCommandHost() { |
| + cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)") |
| + host.run(draft) |
| + draft = "" // onChange(of: draft) ends the typing beat |
| + } else { |
| + cmdLog.error("no command host (no instance for this pane) — command dropped") |
| + } |
| + return |
| + } |
| switch SlashCommand.parse(draft, meName: model.myScreenName) { |
| case .me(let line): |
| if model.sendAction(line) { draft = "" } |
diff --git a/main.go b/main.go| index eef4b2e..3749f10 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -547,7 +547,11 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| // only holds ciphertext), so media-src must allow blob: or the |
| // <video> is refused. Without this it falls back to default-src 'self'. |
| "media-src 'self' blob:; "+ |
| - "connect-src 'self'; "+ |
| + // *.giphy.com is the /gif app's CLIENT-side doorway (hostBridge.gif in |
| + // shell.js): search + GIF download happen in the browser, so the server |
| + // never sees the query or the pick — the deliberate, disclosed sibling |
| + // of the unfurl trade-off. Keep connect-src pinned to named hosts. |
| + "connect-src 'self' https://*.giphy.com; "+ |
| // 'self' admits exactly one same-origin frame: /app-sandbox, the |
| // opaque-origin cage member apps run in (memberapps.go). |
| "frame-src 'self' https://www.youtube-nocookie.com; "+ |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index a08a1a9..e540bf4 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -51,6 +51,9 @@ import { |
| parseHere, |
| runAppMention, |
| setThreadIsGroup, |
| + setCommandPanelHost, |
| + openCommandPanel, |
| + closeCommandPanel, |
| } from "../web/static/apps.js"; |
| import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app |
| import { app as pollApp, pollSummary } from "../web/static/apps/poll.js"; |
| @@ -63,6 +66,8 @@ import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner |
| import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin) |
| import { app as chatApp, viewItems, threadItems } from "../web/static/apps/chat.js"; // the chat app |
| import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault) |
| +import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app) |
| +import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif |
| // Each app's fold is synthesised from its `events` map by defineApp — the same |
| // (state, event) → state reducer the framework folds with. Test it directly. |
| @@ -1667,5 +1672,169 @@ await test("runAppMention: a bare mention gets the usage reply; plain text never |
| resetThread("grp4"); |
| }); |
| +// ---- command panels (ctx.panel) + the /emoji reference app ----------------- |
| + |
| +await test("searchEmoji filters by name substring and collapses aliases", () => { |
| + const cats = searchEmoji("cat"); |
| + assert.ok(cats.some((r) => r.ch === "🐱"), "cat matches"); |
| + assert.ok(cats.every((r) => r.name.includes("cat")), "only matching names"); |
| + // laughing/satisfied are aliases of one glyph — a bare search dedupes them. |
| + const all = searchEmoji(""); |
| + assert.equal(all.filter((r) => r.ch === "😆").length, 1, "aliases collapse to one tile"); |
| + assert.ok(all.length > 100, "a bare query browses the whole set"); |
| + assert.deepEqual(searchEmoji("zzzznope"), [], "no match → empty"); |
| +}); |
| + |
| +await test("ctx.panel opens a command panel in the thread you TYPED in", async () => { |
| + const opened = []; |
| + setCommandPanelHost((threadId, spec) => { |
| + opened.push({ threadId, spec }); |
| + return true; |
| + }); |
| + const replies = []; |
| + assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true); |
| + await tick(); |
| + assert.equal(opened.length, 1); |
| + assert.equal(opened[0].threadId, "chatX", "the panel opens where you typed, not a routed app-thread"); |
| + assert.equal(typeof opened[0].spec.render, "function"); |
| + assert.match(opened[0].spec.title, /cat/); |
| + assert.equal(opened[0].spec.sender.name, "Emoji", "runCommand injects the app persona as the panel's sender"); |
| + assert.equal(replies.length, 0, "the panel IS the answer — no reply too"); |
| +}); |
| + |
| +await test("a command falls back to a reply when no chat surface can draw a panel", async () => { |
| + setCommandPanelHost(() => false); // the thread has no mounted chat pane |
| + const replies = []; |
| + assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true); |
| + await tick(); |
| + assert.equal(replies.length, 1, "ctx.panel returned false → the command replied instead"); |
| + assert.match(replies[0].text, /🐱/); |
| +}); |
| + |
| +await test("openCommandPanel guards: no host / no render → false; close reaches the host", () => { |
| + const calls = []; |
| + setCommandPanelHost((threadId, spec) => (calls.push({ threadId, spec }), true)); |
| + assert.equal(openCommandPanel("t1", { title: "no render" }), false, "a panel must have render()"); |
| + assert.equal(calls.length, 0); |
| + assert.equal(openCommandPanel("t1", { render() {} }), true); |
| + // The spec passes through verbatim — animate/position are the host's to read. |
| + assert.equal(openCommandPanel("t1", { render() {}, animate: false }), true); |
| + assert.equal(calls[calls.length - 1].spec.animate, false); |
| + closeCommandPanel("t1"); |
| + assert.equal(calls.length, 3); // two opens + the close |
| + assert.equal(calls[2].spec, null, "close is a null spec to the same host"); |
| + setCommandPanelHost(null); |
| + assert.equal(openCommandPanel("t1", { render() {} }), false, "no host → false (caller falls back to a reply)"); |
| +}); |
| + |
| +await test("gif: pickRendition prefers downsized, skips oversize, tolerates missing sizes", () => { |
| + const images = { |
| + downsized: { url: "https://media0.giphy.com/a.gif", width: "320", height: "240", size: "900000" }, |
| + fixed_height: { url: "https://media0.giphy.com/b.gif", width: "356", height: "200", size: "1500000" }, |
| + original: { url: "https://media0.giphy.com/c.gif", width: "480", height: "360", size: "9000000" }, |
| + }; |
| + assert.equal(pickRendition(images).url, "https://media0.giphy.com/a.gif"); |
| + // downsized too big → the next acceptable rendition wins |
| + assert.equal(pickRendition({ ...images, downsized: { ...images.downsized, size: String(GIF_MAX_BYTES + 1) } }).url, "https://media0.giphy.com/b.gif"); |
| + // unknown size sails through — the byte fetch re-checks the real length |
| + assert.equal(pickRendition({ downsized: { url: "https://media0.giphy.com/d.gif", width: "1", height: "1" } }).url, "https://media0.giphy.com/d.gif"); |
| + // nothing acceptable → null |
| + assert.equal(pickRendition({ original: { url: "https://media0.giphy.com/c.gif", size: String(GIF_MAX_BYTES * 2) } }), null); |
| + assert.equal(pickRendition(undefined), null); |
| + // The post walks ALL acceptable renditions (fallback when one download |
| + // fails), deduped by url, best first. |
| + const cands = renditionCandidates(images); |
| + assert.deepEqual(cands.map((c) => c.url.split("/").pop()), ["a.gif", "b.gif"], "oversize original dropped; order kept"); |
| + assert.deepEqual(renditionCandidates(undefined), []); |
| +}); |
| + |
| +await test("gif: gifItems maps a Giphy page and drops unusable results", () => { |
| + const data = [ |
| + { id: "g1", title: "Cat", images: { |
| + fixed_height_small: { url: "https://media0.giphy.com/s.gif", width: "89", height: "100" }, |
| + downsized: { url: "https://media0.giphy.com/f.gif", width: "320", height: "240", size: "500000" }, |
| + } }, |
| + { id: "g2", title: "No preview", images: { downsized: { url: "https://media0.giphy.com/x.gif", size: "1" } } }, |
| + { id: "g3", title: "No postable rendition", images: { fixed_height_small: { url: "https://media0.giphy.com/y.gif" } } }, |
| + null, |
| + ]; |
| + const items = gifItems(data); |
| + assert.equal(items.length, 1); // g2 has no preview rendition, g3 nothing postable |
| + assert.equal(items[0].id, "g1"); |
| + assert.equal(items[0].preview.url, "https://media0.giphy.com/s.gif"); |
| + assert.deepEqual(gifItems(null), []); |
| +}); |
| + |
| +await test("gif: /gif opens a panel where you typed; no host doorway → a reply", async () => { |
| + const opened = []; |
| + setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); |
| + setHostBridge({ gif: { search: async () => [], bytes: async () => null, upload: async () => null } }); |
| + const replies = []; |
| + assert.equal(runCommand("/gif office party", "chatX", (r) => replies.push(r)), true); |
| + await tick(); |
| + assert.equal(opened.length, 1); |
| + assert.equal(opened[0].threadId, "chatX"); |
| + assert.match(opened[0].spec.title, /office party/); |
| + assert.equal(replies.length, 0); |
| + // Without the host doorway (e.g. a stripped-down embed) it explains itself. |
| + setHostBridge(null); |
| + assert.equal(runCommand("/gif cats", "chatX", (r) => replies.push(r)), true); |
| + await tick(); |
| + assert.equal(replies.length, 1); |
| + assert.match(replies[0].text, /isn't available/); |
| + assert.equal(opened.length, 1, "no panel without the doorway"); |
| + setCommandPanelHost(null); |
| + assert.equal(gifApp.personal, false); |
| +}); |
| + |
| +await test("the declarative panel form: ctx.panel({data}) + a panel() hook synthesizes render", async () => { |
| + const seen = []; |
| + const fixture = defineApp({ |
| + name: "paneldemo", |
| + title: "Panel Demo", |
| + glyph: "▣", |
| + personal: false, |
| + listed: false, |
| + initialState: () => ({}), |
| + events: {}, |
| + commands: { "/paneldemo": (args, ctx) => ctx.panel({ title: "Demo", data: { args } }) }, |
| + panel(data, el, pctx) { |
| + seen.push({ data, el, keys: Object.keys(pctx).sort() }); |
| + }, |
| + }); |
| + const opened = []; |
| + setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true)); |
| + assert.equal(runCommand("/paneldemo hello", "chatX"), true); |
| + await tick(); |
| + assert.equal(opened.length, 1); |
| + const spec = opened[0].spec; |
| + assert.equal(typeof spec.render, "function", "the hook synthesizes the host render"); |
| + // The host invokes render(el, handle); the hook gets (data, el, panelCtx) |
| + // with the handle's members plus the app ctx's emit/id/me/getState. |
| + const el = {}; |
| + spec.render(el, { close() {}, insertText() {}, refresh() {}, attachFiles() {}, threadId: "chatX" }); |
| + assert.equal(seen.length, 1); |
| + assert.deepEqual(seen[0].data, { args: "hello" }); |
| + assert.equal(seen[0].el, el); |
| + for (const k of ["close", "insertText", "emit", "id", "me", "getState"]) { |
| + assert.ok(seen[0].keys.includes(k), `panelCtx offers ${k}`); |
| + } |
| + assert.ok(fixture.panel, "the hook stays on the app object"); |
| + setCommandPanelHost(null); |
| +}); |
| + |
| +await test("a no-match /emoji answers with a reply, and the app stays command-only", async () => { |
| + setCommandPanelHost((threadId, spec) => true); |
| + const replies = []; |
| + assert.equal(runCommand("/emoji zzzznope", "chatX", (r) => replies.push(r)), true); |
| + await tick(); |
| + assert.equal(replies.length, 1); |
| + assert.match(replies[0].text, /No emoji match/); |
| + // Command-only shape: no personal thread to route to, nothing to instantiate. |
| + assert.equal(emojiApp.personal, false); |
| + assert.equal(emojiApp.listed, false); |
| + setCommandPanelHost(null); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/test/sandbox.test.mjs b/test/sandbox.test.mjs| index fc82111..d274445 100644 |
| --- a/test/sandbox.test.mjs |
| +++ b/test/sandbox.test.mjs |
| @@ -33,7 +33,7 @@ import { |
| normalizeCommands, |
| manifestOf, |
| } from "../web/static/sandbox-frame.js"; |
| -import { validateManifest, cleanJSON, sanitizeAppEvent, sanitizeChat, registerMemberApp, resolveDark } from "../web/static/sandbox.js"; |
| +import { validateManifest, cleanJSON, sanitizeAppEvent, sanitizeChat, sanitizePanelSpec, registerMemberApp, resolveDark } from "../web/static/sandbox.js"; |
| import { tokenizeText as realTokenize, formatBytes as realBytes } from "../web/static/format.js"; |
| // ---- SDK drift pins: the sandbox copies must behave exactly like apps.js ---- |
| @@ -448,3 +448,36 @@ test("registerMemberApp refuses collisions and double registration", () => { |
| /prefixed/, |
| ); |
| }); |
| + |
| +// ---- command panels (the declarative sandbox form) -------------------------- |
| + |
| +test("sanitizePanelSpec clamps the title, whitelists position/animate, caps data", () => { |
| + const sp = sanitizePanelSpec({ title: "Hi", position: "top", animate: false, data: { a: 1 } }); |
| + assert.deepEqual(sp, { title: "Hi", data: { a: 1 }, position: "top", animate: false }); |
| + assert.equal(sanitizePanelSpec({ title: "x".repeat(200) }).title.length, 80); |
| + // A hostile position/animate never passes through; missing data → null. |
| + const loose = sanitizePanelSpec({ position: "overlay", animate: "yes" }); |
| + assert.equal(loose.position, undefined); |
| + assert.equal(loose.animate, undefined); |
| + assert.equal(loose.data, null); |
| + // Un-cleanable or oversize data rejects the whole request. |
| + assert.equal(sanitizePanelSpec({ data: { f: () => {} } }), null); |
| + assert.equal(sanitizePanelSpec({ data: "y".repeat(70 * 1024) }), null); |
| + assert.equal(sanitizePanelSpec("nope"), null); |
| +}); |
| + |
| +test("evalMemberApp captures a panel hook; the mood example evaluates whole", async () => { |
| + const { readFileSync } = await import("node:fs"); |
| + const src = readFileSync(new URL("../web/static/apps-disabled/mood.js", import.meta.url), "utf8"); |
| + const spec = evalMemberApp(src, "mood"); |
| + assert.equal(typeof spec.panel, "function", "the panel hook survives the sandbox transform"); |
| + assert.ok(spec.commands["/mood"], "the /mood command registers"); |
| + // The reducer folds a check-in and validates hostile input to a no-op. |
| + const s1 = spec.reduce(undefined, { type: "mood.set", id: "a", mood: "🙂", note: "ok", name: "Ana", ts: 5 }); |
| + assert.equal(s1.items.length, 1); |
| + assert.equal(spec.reduce(s1, { type: "mood.set", id: "b", mood: "<script>" }).items.length, 1, "unknown mood folds to a no-op"); |
| + // Its manifest extracts cleanly and validates for deploy. |
| + const m = manifestOf(spec); |
| + assert.equal(validateManifest(m), null); |
| + assert.equal(m.chat, true); |
| +}); |
diff --git a/tools/lchat-dev-mac.mjs b/tools/lchat-dev-mac.mjs| new file mode 100644 |
| index 0000000..2d68878 |
| --- /dev/null |
| +++ b/tools/lchat-dev-mac.mjs |
| @@ -0,0 +1,101 @@ |
| +#!/usr/bin/env node |
| +// lchat-dev-mac — seed the NATIVE Mac app with the dev server's space, for |
| +// click-testing a branch build against tools/lchat-dev-up.mjs. |
| +// |
| +// Why this exists: the Mac app's only product entry is "Sign in with Eleven" |
| +// (the home-server vault), which can't know about a local dev instance — and |
| +// the join page's "Open in the app" link is deliberately unhandled (joining |
| +// lives on the web; a native join flow comes later). So for dev builds this |
| +// script does what the vault would: it mints + claims a fresh add-device |
| +// token for the seeded "you" account and writes a SavedInstance the app can |
| +// load. |
| +// |
| +// It writes ~/Library/Application Support/lchat/instances.json — the store's |
| +// legacy READ fallback (InstancesStore in ios/LChatCore Keychain.swift). That |
| +// file holds the dev identity's PRIVATE KEY in the clear, which is exactly why |
| +// the app never WRITES it anymore — acceptable here only because this is a |
| +// throwaway localhost identity; never run this against a real space. The app |
| +// deletes the file on its first successful keychain save (migration), so |
| +// re-run the script if the space vanishes after the keychain adopts it. |
| +// |
| +// Usage: node tools/lchat-dev-mac.mjs (after tools/lchat-dev-up.mjs; quit |
| +// the Mac app first — it reads the stores at launch) |
| +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; |
| +import { homedir } from "node:os"; |
| +import path from "node:path"; |
| +import { fileURLToPath } from "node:url"; |
| +import { randomSecret, deriveInvite } from "../web/static/crypto.js"; |
| + |
| +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| +const cfgPath = path.join(root, ".lchat-dev.json"); |
| +if (!existsSync(cfgPath)) { |
| + console.error("No .lchat-dev.json — run tools/lchat-dev-up.mjs first."); |
| + process.exit(1); |
| +} |
| +const cfg = JSON.parse(readFileSync(cfgPath, "utf8")); |
| + |
| +// 1) Mint + claim a fresh add-device invite for the seeded "you" account. |
| +// wrapped is empty — we already hold the private key locally (dev config); |
| +// the claim only needs to yield a device token. |
| +const T = randomSecret(); |
| +const inv = await deriveInvite(T); |
| +let r = await fetch(cfg.base + "/api/admin/key-invites", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json", Authorization: "Bearer " + cfg.adminToken }, |
| + body: JSON.stringify({ label: "mac-dev", user_id: cfg.you.userId, id: inv.id, wrapped: "", claim_hash: inv.claimHash }), |
| +}); |
| +if (!r.ok) { |
| + console.error("minting the device invite failed:", r.status, await r.text()); |
| + process.exit(1); |
| +} |
| +r = await fetch(`${cfg.base}/api/key-invite/${encodeURIComponent(inv.id)}/claim`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ claim_secret: inv.claimSecret }), |
| +}); |
| +if (!r.ok) { |
| + console.error("claiming the device invite failed:", r.status, await r.text()); |
| + process.exit(1); |
| +} |
| +const { token } = await r.json(); |
| + |
| +// 2) Write the SavedInstance (Codable field names — ios/LChatCore Models.swift) |
| +// and a matching rail entry (SavedChat — ios/LChatMac MacInstance.swift), |
| +// merging with whatever is already there. |
| +const dir = path.join(homedir(), "Library/Application Support/lchat"); |
| +mkdirSync(dir, { recursive: true }); |
| +const instPath = path.join(dir, "instances.json"); |
| +const serversPath = path.join(dir, "servers.json"); |
| + |
| +const instance = { |
| + origin: cfg.base, |
| + deviceToken: token, |
| + screenName: "You", |
| + userPub: cfg.you.publicKey, |
| + userPriv: cfg.you.privateKey, |
| + myUserId: cfg.you.userId, |
| + groupName: cfg.groupName || "Dev chat", |
| +}; |
| +const readJSON = (p) => { |
| + try { |
| + return JSON.parse(readFileSync(p, "utf8")); |
| + } catch { |
| + return []; |
| + } |
| +}; |
| +const instances = readJSON(instPath).filter((i) => i && i.origin !== instance.origin); |
| +instances.push(instance); |
| +writeFileSync(instPath, JSON.stringify(instances, null, 2) + "\n"); |
| + |
| +const servers = readJSON(serversPath); |
| +if (!servers.some((s) => s && typeof s.url === "string" && s.url.startsWith(cfg.base))) { |
| + servers.push({ url: cfg.base, name: "You", lastSeenId: 0 }); |
| + writeFileSync(serversPath, JSON.stringify(servers, null, 2) + "\n"); |
| +} |
| + |
| +console.log("Seeded the Mac app with the dev space:"); |
| +console.log(" origin: " + cfg.base); |
| +console.log(" token: " + token.slice(0, 8) + "… (fresh device token)"); |
| +console.log(" stores: " + instPath); |
| +console.log(" " + serversPath); |
| +console.log("Launch the app — the space should be in the rail, chats fully native."); |
diff --git a/web/static/apps-disabled/README.md b/web/static/apps-disabled/README.md| new file mode 100644 |
| index 0000000..cb1afd7 |
| --- /dev/null |
| +++ b/web/static/apps-disabled/README.md |
| @@ -0,0 +1,24 @@ |
| +# 🤖 apps-disabled/ — reference apps that DON'T ship |
| + |
| +Apps in this folder are complete and tested but deliberately **not imported by |
| +`shell.js`**, so they aren't part of the default client. They exist to exercise |
| +the *installable*-app paths: |
| + |
| +- **Admin custom app**: paste a file's source into the Admin console's custom |
| + apps section (PUT `/api/admin/apps/{name}`) and it loads for every member |
| + with full app privileges — `ctx.panel`, `ctx.host` doorways, the lot. |
| + Import paths here are written as `"../apps.js"` on purpose: custom apps are |
| + served from `/static/apps-custom/`, the same depth as this folder, so a file |
| + works unchanged in both places. |
| +- **Member app** (App Studio / bot-built): expect a sandboxed subset — member |
| + apps run in an opaque-origin iframe with no network, no keys, and (today) no |
| + command-panel plumbing, so `gif.js` specifically can NOT run there by design. |
| + |
| +Current residents: |
| + |
| +- `mood.js` — `/mood` opens a command panel of feeling buttons; a tap |
| + dispatches a `mood.set` event and dismisses. THE member-app panel example: |
| + written against the declarative `ctx.panel({ title, data })` + |
| + `panel(data, el, panelCtx)` form, so it runs identically pasted into App |
| + Studio (sandboxed) or compiled in. Sandbox-legal by construction — imports |
| + only from `../apps.js`, no fetch, no host doorways. |
diff --git a/web/static/apps-disabled/mood.js b/web/static/apps-disabled/mood.js| new file mode 100644 |
| index 0000000..ea252ae |
| --- /dev/null |
| +++ b/web/static/apps-disabled/mood.js |
| @@ -0,0 +1,91 @@ |
| +// Mood check-in: the COMMAND-PANEL example for member apps. `/mood [note]` |
| +// opens a panel of feeling buttons; tapping one dispatches a `mood.set` event |
| +// into your Mood thread (an action line in its chat, a row in its history) |
| +// and dismisses the panel. This is the whole "panel buttons dispatch events" |
| +// shape in ~80 lines, written against the DECLARATIVE panel form — the |
| +// command calls ctx.panel({ title, data }) and the top-level |
| +// panel(data, el, panelCtx) hook draws it — which is the one form the |
| +// member-app sandbox supports (a render closure can't cross its bridge), and |
| +// which works identically compiled-in. To try it as a member app: paste this |
| +// file into App Studio and deploy. Sandbox-legal by construction: imports |
| +// only from ../apps.js, no fetch, no host doorways. |
| +import { defineApp, addItem } from "../apps.js"; |
| + |
| +const MOODS = ["😄", "🙂", "😐", "😮💨", "😖"]; |
| + |
| +export const app = defineApp({ |
| + name: "mood", |
| + title: "Mood", |
| + glyph: "🌤️", |
| + initialState: () => ({ items: [] }), |
| + |
| + events: { |
| + "mood.set": (s, e) => |
| + e.id && typeof e.mood === "string" && MOODS.includes(e.mood) |
| + ? { items: addItem(s.items, { id: e.id, mood: e.mood, note: typeof e.note === "string" ? e.note.slice(0, 200) : "", by: e.name || "", ts: e.ts || 0 }) } |
| + : s, |
| + }, |
| + |
| + commands: { |
| + "/mood": { |
| + desc: "log how you're feeling", |
| + args: "[note]", |
| + run(args, ctx) { |
| + ctx.panel({ title: "How are you feeling?", data: { note: (args || "").trim() } }); |
| + }, |
| + }, |
| + }, |
| + |
| + // Companion projection: a check-in reads as an action line in the chat view. |
| + chatMessage(e) { |
| + if (e.type !== "mood.set") return null; |
| + return { text: `is feeling ${e.mood}${e.note ? " — " + e.note : ""}`, action: true }; |
| + }, |
| + |
| + // The panel: five buttons; a tap emits the event and closes. `data` is what |
| + // the command passed (the note typed after /mood); panelCtx offers |
| + // { emit, id, me, getState, close, insertText } in both worlds. |
| + panel(data, el, ctx) { |
| + const row = document.createElement("div"); |
| + row.className = "mood-row"; |
| + for (const mood of MOODS) { |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.className = "mood-btn"; |
| + b.textContent = mood; |
| + b.onclick = async () => { |
| + await ctx.emit({ type: "mood.set", id: ctx.id(), mood, note: (data && data.note) || "" }); |
| + ctx.close(); |
| + }; |
| + row.appendChild(b); |
| + } |
| + el.appendChild(row); |
| + }, |
| + |
| + styles: ` |
| +.mood-row { display: flex; gap: 8px; padding: 6px 2px; } |
| +.mood-btn { |
| + border: none; background: var(--bubble-them); border-radius: 10px; |
| + font-size: 26px; line-height: 1; padding: 10px 12px; cursor: pointer; |
| +} |
| +.mood-btn:hover { outline: 2px solid var(--accent); } |
| +.mood-item { display: flex; gap: 10px; align-items: baseline; padding: 6px 2px; border-bottom: 1px solid var(--border); } |
| +.mood-note { color: var(--muted); font-size: 13px; } |
| +`, |
| + |
| + // The app-thread surface: newest first, the history of check-ins. |
| + render(state, el) { |
| + const items = [...((state && state.items) || [])].sort((a, b) => b.ts - a.ts); |
| + for (const it of items) { |
| + const row = document.createElement("div"); |
| + row.className = "mood-item"; |
| + const glyph = document.createElement("span"); |
| + glyph.textContent = it.mood; |
| + const note = document.createElement("span"); |
| + note.className = "mood-note"; |
| + note.textContent = it.note || new Date(it.ts).toLocaleString(); |
| + row.append(glyph, note); |
| + el.appendChild(row); |
| + } |
| + }, |
| +}); |
diff --git a/web/static/apps.js b/web/static/apps.js| index d2074ea..14de905 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -73,6 +73,29 @@ injectStyles( |
| display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; |
| overflow-y: auto; padding: 16px; gap: 10px; |
| } |
| +/* A command's transient results panel (see openCommandPanel): in-flow chrome |
| + above the composer (default) or above the feed (.cmd-panel--top), occupying |
| + layout space — never floating over content (CLAUDE.md). */ |
| +.cmd-panel { |
| + flex: 0 0 auto; display: flex; flex-direction: column; min-height: 0; |
| + background: var(--panel); border-top: 1px solid var(--border); |
| + box-shadow: 0 -6px 18px var(--shadow); /* a subtle lift off the feed above */ |
| +} |
| +.cmd-panel--top { |
| + border-top: none; border-bottom: 1px solid var(--border); |
| + box-shadow: 0 6px 18px var(--shadow); /* pinned at the top — cast downward */ |
| +} |
| +.cmd-panel-head { |
| + display: flex; align-items: center; gap: 6px; padding: 8px 14px 0; |
| + font-size: 12px; color: var(--muted); |
| +} |
| +.cmd-panel-title { font-weight: 600; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| +.cmd-panel-x { |
| + flex: 0 0 auto; border: none; background: none; color: var(--muted); |
| + font-size: 16px; line-height: 1; padding: 2px 4px; cursor: pointer; border-radius: 6px; |
| +} |
| +.cmd-panel-x:hover { color: var(--ink); background: var(--bubble-them); } |
| +.cmd-panel-body { padding: 8px 14px 10px; max-height: 34vh; overflow-y: auto; } |
| /* An app's glyph avatar (sidebar + header) and the "app" badge on its row. */ |
| .app-avatar { display: flex; align-items: center; justify-content: center; background: var(--bubble-them); font-size: 18px; } |
| .app-badge { |
| @@ -1062,6 +1085,10 @@ export function runAppMention(text, threadId, respond) { |
| if (noticeCalmsDown(r)) return; // the action line in the feed is the record |
| reply(r); |
| }; |
| + // Mentions get the same transient-panel doorway a slash command does — the |
| + // mention is typed in this thread, so the panel opens right here. |
| + ctx.panel = (s) => openCommandPanel(threadId, panelSpecFor(m.app, ctx, sender, s)); |
| + ctx.closePanel = () => closeCommandPanel(threadId); |
| Promise.resolve(spec.run(m.args, ctx)).catch((e) => console.error(e)); |
| return true; |
| } |
| @@ -1077,6 +1104,66 @@ export function parseHere(args) { |
| return m ? { rest: (m[1] || "").trim() } : null; |
| } |
| +// ---- command panels: a transient surface a command opens ------------------ |
| +// |
| +// A command can answer with more than a private reply: it can open a PANEL — a |
| +// temporary, interactive surface in the chat you TYPED in (just above the |
| +// composer by default, or pinned above the feed with position: "top"). It's |
| +// the richer sibling of the reply, still in place: /gif would show a grid of |
| +// results, tapping one attaches it and dismisses the panel. A panel is |
| +// client-only chrome — never sent, never folded, gone on dismissal or when the |
| +// pane unmounts — so it carries no events and no durable state. One panel per |
| +// thread: opening another replaces it. The USER dismisses it (its ✕, or |
| +// Escape) or the APP does (panel.close()). The chat app injects the host that |
| +// actually draws it; openCommandPanel returns false when the thread has no |
| +// mounted chat surface, so a command can fall back to a plain reply. |
| +let panelHost = null; // (threadId, spec|null) => boolean — the chat app injects it |
| +export function setCommandPanelHost(fn) { |
| + panelHost = fn; |
| +} |
| +export function openCommandPanel(threadId, spec) { |
| + if (!panelHost || !spec || typeof spec.render !== "function") return false; |
| + return !!panelHost(threadId, spec); |
| +} |
| +export function closeCommandPanel(threadId) { |
| + if (panelHost) panelHost(threadId, null); |
| +} |
| +// A host-side observer of panel visibility, fired by the HOST's controller for |
| +// every show and dismiss — command open, replacement, the ✕, Escape, and the |
| +// app's own panel.close() all land here, so it's the one reliable signal that |
| +// the panel's box appeared or went away. The command-host embed (the native |
| +// Mac shell's panel webview) uses it to tell the shell to reveal or collapse |
| +// the webview around the panel. |
| +let panelObserver = null; // (threadId, spec|null) => void |
| +export function setCommandPanelObserver(fn) { |
| + panelObserver = fn; |
| +} |
| +export function notifyCommandPanel(threadId, spec) { |
| + if (!panelObserver) return; |
| + try { |
| + panelObserver(threadId, spec || null); |
| + } catch (e) { |
| + console.error(e); |
| + } |
| +} |
| + |
| +// panelSpecFor resolves a command's panel request to the host spec. Two forms: |
| +// a `render` closure (the trusted-world original), or the DECLARATIVE form — |
| +// ctx.panel({ title, data }) plus a top-level `panel(data, el, panelCtx)` hook |
| +// on the app. The declarative form is the one the member-app sandbox supports |
| +// (a closure can't cross its bridge), and it works identically here, so one |
| +// app file runs compiled-in or uploaded. panelCtx = the host handle (close / |
| +// insertText / …) plus the app ctx's emit/id/me/getState. |
| +function panelSpecFor(app, ctx, sender, spec) { |
| + const s = { sender, ...spec }; |
| + if (typeof s.render !== "function" && app && typeof app.panel === "function") { |
| + const data = s.data === undefined ? null : s.data; |
| + s.render = (el, handle) => |
| + app.panel(data, el, { ...handle, emit: ctx.emit, id: ctx.id, me: ctx.me, getState: ctx.getState }); |
| + } |
| + return s; |
| +} |
| + |
| // /me is unlike every other command: it BROADCASTS a real message (an |
| // action-style line, `{text, action:true}`) rather than answering privately, |
| // so the composer (chat.js) handles it directly, before runCommand ever sees |
| @@ -1185,6 +1272,12 @@ export function runCommand(text, activeThreadId, respond) { |
| if (target === activeThreadId && r && r.short) return; |
| reply({ sender, ...r }); |
| }; |
| + // ctx.panel opens a transient results surface where you TYPED (like |
| + // ctx.reply, it's bound to the active thread, not the routed target) — |
| + // see the command-panels section above. Returns false when this thread |
| + // can't draw one, so the command can answer with a reply instead. |
| + ctx.panel = (spec) => openCommandPanel(activeThreadId, panelSpecFor(app, ctx, sender, spec)); |
| + ctx.closePanel = () => closeCommandPanel(activeThreadId); |
| await app.commands[cmd.name].run(args, ctx); |
| })(); |
| return true; |
| @@ -1286,7 +1379,8 @@ function runNoticeAction(a) { |
| } |
| // Whether animations are allowed (honours the OS "reduce motion" setting). |
| -function motionOK() { |
| +// Exported for surface hosts (the chat app's command panel eases open/shut). |
| +export function motionOK() { |
| try { |
| return !(typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches); |
| } catch { |
diff --git a/web/static/apps/README.md b/web/static/apps/README.md| index f22b065..aa2c46f 100644 |
| --- a/web/static/apps/README.md |
| +++ b/web/static/apps/README.md |
| @@ -359,6 +359,127 @@ render.) Three rules keep this honest: |
| A default-command thread is text-only: the composer hides its attach affordances |
| (there's no one to read a file), and they return if a bot joins. |
| +### Command panels — a transient results surface |
| + |
| +A command can answer with more than a private reply: `ctx.panel(spec)` opens a |
| +**panel** — a temporary, interactive surface in the chat you *typed* in, sitting |
| +just above the composer (or pinned above the feed with `position: "top"`). It's |
| +still the "answer in place" doctrine: nothing navigates, nothing overlays what |
| +you're reading (the panel takes layout space), and leaving is always the user's |
| +tap. Two working references: `apps/emoji.js` (`/emoji cat` opens a picker; a |
| +tap drops the emoji into your message via `panel.insertText`) and |
| +`apps/gif.js` (`/gif high five` shows Giphy results — it asks for the member's |
| +own free Giphy key on first launch, stored device-side; a tap posts the GIF |
| +into the chat as an ordinary encrypted attachment and closes the panel — its |
| +network + upload go through the host's scoped `ctx.host.gif` doorway, because |
| +apps never fetch). |
| + |
| +```js |
| +"/emoji": { |
| + run(args, ctx) { |
| + const results = searchEmoji(args); |
| + if (!results.length) return ctx.reply({ text: "No matches." }); |
| + const opened = ctx.panel({ |
| + title: `Emoji matching “${args}”`, // header line, next to the app's glyph |
| + // position: "top", // pin above the feed instead (default: above the composer) |
| + // animate: false, // appear/vanish instantly (default: ease up on open, roll down on dismiss) |
| + render(el, panel) { // build DOM into el — textContent for anything user-typed |
| + for (const r of results) { |
| + const b = document.createElement("button"); |
| + b.textContent = r.ch; |
| + b.onclick = () => { panel.insertText(r.ch); panel.close(); }; |
| + el.appendChild(b); |
| + } |
| + }, |
| + onClose() {}, // optional: told when the panel is replaced or dismissed |
| + }); |
| + if (!opened) ctx.reply({ text: "…" }); // no chat surface here → answer with a reply |
| + }, |
| +}, |
| +``` |
| + |
| +The `panel` handle a render gets: |
| + |
| +| member | what it does | |
| +| ------ | ------------ | |
| +| `panel.close()` | Dismiss the panel (the app deciding it's done). | |
| +| `panel.refresh()` | Re-run `render` (e.g. async results arrived — render a spinner first, refresh when ready). | |
| +| `panel.insertText(t)` | Insert `t` at the caret of the composer the panel sits above (forwarded to the native composer in the embedded shell). | |
| +| `panel.attachFiles(files)` | Stage files on that composer — the same encrypt-and-upload path the attach button uses. | |
| +| `panel.threadId` | The thread the panel is showing in (where you typed). | |
| + |
| +**A panel is generic UI, not a picker widget.** `render` gets a bare container |
| +and full control: build any DOM you like, own its clicks, and — because the |
| +handler's `ctx` is your app's real context — a button can dispatch a typed |
| +event straight into the thread's encrypted log, where it folds like any other |
| +app event: |
| + |
| +```js |
| +"/lunch": (args, ctx) => { |
| + ctx.panel({ |
| + title: "Start the lunch poll?", |
| + render(el, panel) { |
| + for (const q of ["Pizza or noodles?", "Eat out or order in?"]) { |
| + const b = document.createElement("button"); |
| + b.textContent = q; |
| + b.onclick = async () => { |
| + // An ordinary app event — encrypted, attributed, folded by every |
| + // member. The panel was just the UI that chose it. |
| + await ctx.emit({ type: "poll.create", id: ctx.id(), q, options: ["👍", "👎"] }); |
| + panel.close(); |
| + }; |
| + el.appendChild(b); |
| + } |
| + }, |
| + }); |
| +}, |
| +``` |
| + |
| +`/gif` composes the same pieces the other way round: its tiles call a |
| +privileged **host doorway** (`ctx.host.gif` — search/download/upload) and then |
| +`ctx.emit` a chat message with the uploaded attachment. If your panel needs |
| +the network or another privileged capability, don't reach for `fetch` (apps |
| +never fetch — Invariants #7): add a narrow, named doorway to `setHostBridge` |
| +in `shell.js`, the way `gif` and the connected services do, and route any new |
| +external host through the server's CSP deliberately (CLAUDE.md documents the |
| +Giphy exception). |
| + |
| +**The declarative form (and member apps).** Instead of a `render` closure you |
| +may declare a top-level **`panel(data, el, panelCtx)`** hook on the app and |
| +call `ctx.panel({ title, data })` — the host synthesizes the render, handing |
| +your hook the JSON `data` plus a panelCtx of `{ close, insertText, emit, id, |
| +me, getState }`. This is the ONLY form the member-app sandbox supports (a |
| +closure can't cross its bridge — the hook runs in a fresh sandboxed frame |
| +mounted inside the panel), and it works identically compiled-in, so one file |
| +runs in both worlds: see `apps-disabled/mood.js` and |
| +`docs/building-member-apps.md § Command panels`. |
| + |
| +**On the native apps.** The Mac app runs panel commands through a hidden |
| +per-thread *command-host* webview — your panel code runs there unchanged |
| +(`insertText` reaches the native composer; `ctx.emit` posts for real). One |
| +extra step: add your command to `WebPanelCommands.catalog` |
| +(`ios/LChatCore/Sources/LChatCore/SlashCommands.swift`) so the native composer |
| +forwards it and lists it in the typeahead. Only commands that act in the |
| +CURRENT thread (or need no thread) belong there — the native page holds a |
| +single thread's key, so a command that routes elsewhere (/todo) can't resolve |
| +in it. |
| + |
| +Rules that keep panels honest: |
| + |
| +- **A panel is client-only chrome.** Never sent, never folded, gone on dismissal |
| + or when the pane closes — state that matters goes through events, as ever. |
| +- **One per thread.** Opening another replaces it (the outgoing one's `onClose` |
| + fires). The user can always dismiss it — its ✕, or Escape. |
| +- **It's a response, not an ambush.** Only open one from a command the user just |
| + ran (`ctx.panel` exists only in command/mention handlers) — never spontaneously. |
| +- **`ctx.panel` returns `false`** when the thread has no mounted chat surface to |
| + draw in — fall back to `ctx.reply`. |
| +- **It eases open and shut by default** (and honours reduce-motion); pass |
| + `animate: false` for an instant panel. On the Mac the native shell drives the |
| + one animation, from the same flag. |
| +- The usual surface rules apply: build DOM nodes, `textContent` for user |
| + content, theme variables for styling (ship the CSS in your app's `styles`). |
| + |
| ## The events map & the reducer |
| Each handler is `(state, event, meta) => newState`. Rules: |
| @@ -442,6 +563,8 @@ server. |
| | `ctx.threadId` | anywhere | The thread the app is acting in. | |
| | `ctx.interval(fn,ms)` | render only | A ticker the framework stops for you on redraw / reset. | |
| | `ctx.onCleanup(fn)` | render only | Run `fn` before the next redraw or on thread reset. | |
| +| `ctx.panel(spec)` | commands only | Open a transient results panel where the command was typed (see "Command panels"). Returns `false` when the thread can't draw one. | |
| +| `ctx.closePanel()` | commands only | Dismiss that thread's open command panel. | |
| `ctx.interval` is how `remind.js` keeps its relative labels live without leaking |
| a timer — never call `setInterval` directly in a render, or you'll leak one per |
| @@ -814,5 +937,7 @@ Keep any non-trivial parsing pure and exported (like `parseReminder` in |
| - [ ] `import "/static/apps/<name>.js";` added in `web/static/shell.js`. |
| - [ ] `node --check web/static/apps/<name>.js` passes. |
| - [ ] Reducer / parser tests added to `test/apps.test.mjs`; `npm test` green. |
| +- [ ] A panel-opening command? Add it to `WebPanelCommands.catalog` |
| + (`ios/LChatCore/.../SlashCommands.swift`) so the native apps forward it. |
| - [ ] A user-facing changelog entry in `changelog/<date>-<slug>.md`. |
| - [ ] No trailing whitespace; user content rendered with `textContent`. |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index e6acc9d..d058ebf 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -22,7 +22,7 @@ |
| // |
| // This is the DEFAULT chat experience for group/DM threads; a device can |
| // switch back per-device (⋮ menu → "Use original chat" — see chat.js). |
| -import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly } from "../apps.js"; |
| +import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js"; |
| import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| import { renderCard, CARD_CSS } from "../cards.js"; |
| import { emojify } from "../emoji.js"; |
| @@ -216,6 +216,19 @@ function typingNames(threadId, exceptName) { |
| return out; |
| } |
| +// Command panels: the transient results surface a slash command can open |
| +// (ctx.panel — see the command-panels section of apps.js). Each mounted pane |
| +// registers its controller in buildSkeleton; the one injected host routes an |
| +// open/close to the pane it belongs to. Returning false when the thread has no |
| +// mounted chat surface lets the command fall back to a plain reply. |
| +const cmdPanels = new Map(); // threadId → (spec|null) => void |
| +setCommandPanelHost((threadId, spec) => { |
| + const show = cmdPanels.get(threadId); |
| + if (!show) return false; |
| + show(spec); |
| + return true; |
| +}); |
| + |
| // Which reactions are mine, keyed "sid|emoji" — seeded from the load-time mine |
| // flags and kept current by local toggles, exactly like the original feed's |
| // myReactions set (live reaction frames carry counts only). |
| @@ -825,7 +838,9 @@ function renderItem(m, ctx) { |
| for (const a of m.attachments) { |
| if (a.type && a.type.startsWith("image/")) { |
| const img = document.createElement("img"); |
| - img.className = "msg-image"; |
| + // A GIF's click belongs to its play/pause toggle, not the lightbox — |
| + // .msg-gif swaps the zoom-in cursor for a plain pointer. |
| + img.className = a.type === "image/gif" ? "msg-image msg-gif" : "msg-image"; |
| img.alt = a.name || "photo"; |
| // Reserve the image's final box before the blob decrypts (see the original |
| // appendMessage note) so the feed doesn't jump as photos pop in. |
| @@ -838,7 +853,8 @@ function renderItem(m, ctx) { |
| host.loadBlob(ctx.threadId, a).then((url) => { |
| if (!url) return; |
| img.src = url; |
| - img.onclick = () => openLightbox(url, img); |
| + if (a.type === "image/gif") wireGifToggle(img, url); |
| + else img.onclick = () => openLightbox(url, img); |
| }); |
| } |
| content.appendChild(img); |
| @@ -1832,6 +1848,40 @@ function attachmentBlobIds(m) { |
| return ids; |
| } |
| +// An animated GIF plays in the feed, and a click pauses/resumes it in place — |
| +// the click is the toggle, not a lightbox opener (the message menu still holds |
| +// the other actions). Pausing swaps the img's src for a canvas-rendered still |
| +// of the GIF's first frame (the "default image" drawImage yields for animated |
| +// sources — no re-decode of the whole animation); resuming restores the blob |
| +// URL. prefers-reduced-motion starts the GIF frozen. |
| +function wireGifToggle(img, url) { |
| + let still = null; // the frozen frame's data URL, rendered once on first pause |
| + const freeze = () => { |
| + if (!still) { |
| + if (!img.naturalWidth) return; // not decoded yet — nothing to freeze |
| + const c = document.createElement("canvas"); |
| + c.width = img.naturalWidth; |
| + c.height = img.naturalHeight; |
| + c.getContext("2d").drawImage(img, 0, 0); |
| + still = c.toDataURL(); |
| + } |
| + img.src = still; |
| + img.classList.add("gif-paused"); |
| + }; |
| + img.onclick = () => { |
| + if (img.classList.contains("gif-paused")) { |
| + img.src = url; |
| + img.classList.remove("gif-paused"); |
| + } else { |
| + freeze(); |
| + } |
| + }; |
| + if (matchMedia("(prefers-reduced-motion: reduce)").matches) { |
| + if (img.complete && img.naturalWidth) freeze(); |
| + else img.addEventListener("load", freeze, { once: true }); |
| + } |
| +} |
| + |
| function openLightbox(url, source) { |
| const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| const back = document.createElement("div"); |
| @@ -2739,6 +2789,160 @@ function buildSkeleton(el, ctx) { |
| fileInput.value = ""; |
| stageFiles(files); |
| }; |
| + |
| + // ---- command panel: the transient results surface a command opens ---- |
| + // (ctx.panel — see the command-panels section of apps.js.) In-flow, never |
| + // floating: it sits above the composer stack by default, or pinned above the |
| + // feed with position: "top", taking layout space either way. One per thread — |
| + // opening another replaces it; its ✕, Escape, or the app's panel.close() |
| + // dismisses it. Client-only chrome: nothing here is sent or folded. |
| + const panelBox = document.createElement("section"); |
| + panelBox.className = "cmd-panel hidden"; |
| + panelBox.setAttribute("role", "region"); |
| + let panelSpec = null; |
| + const panelOpen = () => !!panelSpec; |
| + const renderPanelBody = (spec, body) => { |
| + body.replaceChildren(); |
| + // The handle a panel renders against: dismiss it, redraw it, and act on |
| + // the composer it sits above — insert text at the caret, or stage |
| + // attachments through the same path the attach button / paste / drop use. |
| + const panel = { |
| + threadId: ctx.threadId, |
| + close: () => hidePanel(), |
| + refresh: () => { |
| + if (panelSpec === spec && body.isConnected) renderPanelBody(spec, body); |
| + }, |
| + insertText: (t) => { |
| + const text = String(t == null ? "" : t); |
| + if (!text) return; |
| + // Embedded: the native shell owns the visible composer — forward the |
| + // insertion (a shell that doesn't handle composerInsert ignores it). |
| + if (inEmbed) return host.native({ composerInsert: text }); |
| + const start = input.selectionStart == null ? input.value.length : input.selectionStart; |
| + const end = input.selectionEnd == null ? start : input.selectionEnd; |
| + input.value = input.value.slice(0, start) + text + input.value.slice(end); |
| + input.selectionStart = input.selectionEnd = start + text.length; |
| + input.dispatchEvent(new Event("input", { bubbles: true })); |
| + input.focus(); |
| + }, |
| + attachFiles: (files) => stageFiles([...(files || [])]), |
| + }; |
| + try { |
| + spec.render(body, panel); |
| + } catch (e) { |
| + // A panel must never take down the pane — contain the error in place. |
| + console.error(`command panel (${(spec.sender && spec.sender.name) || "app"}) render failed:`, e); |
| + body.replaceChildren(); |
| + const p = document.createElement("p"); |
| + p.textContent = "This panel hit an error and couldn't load."; |
| + body.appendChild(p); |
| + } |
| + }; |
| + // Whether the panel eases open/shut: the app's call (animate: false opts |
| + // out), gated on reduced-motion — and skipped in the command-host embed, |
| + // where the panel is the whole page and the NATIVE shell animates the |
| + // frame instead (an in-page ease inside a just-revealed frame reads as a |
| + // flash, not a slide). |
| + const panelEases = (spec) => |
| + spec && spec.animate !== false && motionOK() && !document.body.classList.contains("command-host"); |
| + const clearPanelEase = () => { |
| + panelBox.style.removeProperty("height"); |
| + panelBox.style.removeProperty("overflow"); |
| + panelBox.style.removeProperty("transition"); |
| + }; |
| + const showPanel = (spec) => { |
| + const prev = panelSpec; |
| + panelSpec = spec && typeof spec.render === "function" ? spec : null; |
| + clearPanelEase(); // a mid-animation replace starts clean |
| + // Replacing (or closing) a panel tells the outgoing one — best-effort. |
| + if (prev && prev.onClose) { |
| + try { |
| + prev.onClose(); |
| + } catch (e) { |
| + console.error(e); |
| + } |
| + } |
| + panelBox.replaceChildren(); |
| + panelBox.classList.toggle("hidden", !panelSpec); |
| + panelBox.classList.toggle("cmd-panel--top", !!(panelSpec && panelSpec.position === "top")); |
| + if (!panelSpec) { |
| + notifyCommandPanel(ctx.threadId, null); |
| + return; |
| + } |
| + // Both placements are in-flow: "top" pins it above the feed (under the |
| + // shared-app surface); the default sits at the bottom of the pane, above |
| + // the composer stack (before the zero-height .composer-float anchor). |
| + root.insertBefore(panelBox, panelSpec.position === "top" ? feedWrap : float); |
| + const head = document.createElement("div"); |
| + head.className = "cmd-panel-head"; |
| + const s = panelSpec.sender || {}; |
| + if (s.glyph) { |
| + const g = document.createElement("span"); |
| + g.textContent = s.glyph; |
| + head.appendChild(g); |
| + } |
| + const title = document.createElement("span"); |
| + title.className = "cmd-panel-title"; |
| + title.textContent = panelSpec.title || s.name || ""; |
| + const x = document.createElement("button"); |
| + x.type = "button"; |
| + x.className = "cmd-panel-x"; |
| + x.setAttribute("aria-label", "Dismiss"); |
| + x.innerHTML = icon("x"); |
| + x.onclick = () => hidePanel(); |
| + head.append(title, x); |
| + const body = document.createElement("div"); |
| + body.className = "cmd-panel-body"; |
| + panelBox.append(head, body); |
| + renderPanelBody(panelSpec, body); |
| + notifyCommandPanel(ctx.threadId, panelSpec); |
| + // Ease up from nothing to the panel's natural height (see panelEases). |
| + if (panelEases(panelSpec)) { |
| + const h = panelBox.offsetHeight; |
| + if (h) { |
| + panelBox.style.overflow = "hidden"; |
| + panelBox.style.height = "0px"; |
| + void panelBox.offsetHeight; // commit the start height |
| + panelBox.style.transition = "height 0.22s ease"; |
| + panelBox.style.height = h + "px"; |
| + panelBox.addEventListener("transitionend", clearPanelEase, { once: true }); |
| + setTimeout(clearPanelEase, 350); // backstop if transitionend never fires |
| + } |
| + } |
| + // A bottom panel takes its space from the feed — keep the newest in view. |
| + if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true)); |
| + }; |
| + // Dismissal rolls the panel down before tearing it out, unless the app (or |
| + // the OS) asked for instant. panelClosing keeps a second Escape (or the ✕) |
| + // from re-arming the roll-up mid-flight; a NEW panel opening mid-animation |
| + // wins — showPanel resets the inline styles, and the captured spec check |
| + // keeps the stale finish() from tearing the newcomer down. |
| + let panelClosing = false; |
| + const hidePanel = () => { |
| + if (!panelSpec || panelClosing) return; |
| + if (!panelEases(panelSpec) || !panelBox.isConnected) return showPanel(null); |
| + panelClosing = true; |
| + const closing = panelSpec; |
| + let finished = false; |
| + const finish = () => { |
| + if (finished) return; |
| + finished = true; |
| + panelClosing = false; |
| + clearPanelEase(); |
| + if (panelSpec === closing) showPanel(null); |
| + }; |
| + const startH = panelBox.offsetHeight; |
| + if (!startH) return finish(); |
| + panelBox.style.overflow = "hidden"; |
| + panelBox.style.height = startH + "px"; |
| + void panelBox.offsetHeight; |
| + panelBox.style.transition = "height 0.2s ease"; |
| + panelBox.style.height = "0px"; |
| + panelBox.addEventListener("transitionend", finish, { once: true }); |
| + setTimeout(finish, 320); |
| + }; |
| + // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape. |
| + cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel())); |
| input.addEventListener("paste", (e) => { |
| const files = [...((e.clipboardData && e.clipboardData.files) || [])]; |
| if (files.length) { |
| @@ -3009,6 +3213,12 @@ function buildSkeleton(el, ctx) { |
| setSendQueued(false); // un-queue first; a second Escape cancels reply/edit |
| return; |
| } |
| + // An open command panel is the most recently summoned layer — Escape peels |
| + // it before the thread view / reply / edit below. |
| + if (e.key === "Escape" && panelOpen()) { |
| + hidePanel(); |
| + return; |
| + } |
| // An open thread view closes on Escape (dropping its staged reply) — unless |
| // an edit is in flight, which the branch below cancels first (cancelEdit |
| // re-stages the thread reply, so a second Escape then closes the view). |
| @@ -3400,6 +3610,14 @@ function buildSkeleton(el, ctx) { |
| const m = latest(false); |
| if (m) composers.get(ctx.threadId).startReply(m); |
| } |
| + // Escape dismisses an open command panel when the composer isn't focused |
| + // (the focused case is in input.onkeydown), with the same layer checks as |
| + // the thread-view close below — one Escape peels one layer, never two. |
| + if (e.key === "Escape" && panelOpen() && !e.defaultPrevented && document.activeElement !== input && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) { |
| + e.preventDefault(); |
| + hidePanel(); |
| + return; |
| + } |
| // Escape closes an open thread view when the composer isn't focused (the |
| // focused case is handled in input.onkeydown). Skipped when a layer that |
| // owns Escape is up — the picker preventDefaults before us (capture phase), |
diff --git a/web/static/apps/emoji.js b/web/static/apps/emoji.js| new file mode 100644 |
| index 0000000..fe7deb4 |
| --- /dev/null |
| +++ b/web/static/apps/emoji.js |
| @@ -0,0 +1,84 @@ |
| +// Emoji browser: `/emoji [search]` opens a COMMAND PANEL (the transient |
| +// results surface above the composer — see apps.js) with the matching emoji |
| +// from the curated shortcode set; tapping one drops it into your message and |
| +// dismisses the panel. The reference implementation for panels: no events, no |
| +// state, no thread of its own — the command and its temporary surface are the |
| +// whole app. |
| +import { defineApp } from "../apps.js"; |
| +import { EMOJI } from "../emoji.js"; |
| + |
| +// searchEmoji filters the shortcode set by a substring of the name and |
| +// collapses aliases (laughing/satisfied are one glyph) to one tile each, |
| +// keeping the first name as the label. Pure, so it's unit-testable in Node. |
| +export function searchEmoji(query) { |
| + const q = (query || "").trim().toLowerCase(); |
| + const seen = new Set(); |
| + const out = []; |
| + for (const [name, ch] of Object.entries(EMOJI)) { |
| + if (q && !name.includes(q)) continue; |
| + if (seen.has(ch)) continue; |
| + seen.add(ch); |
| + out.push({ name, ch }); |
| + } |
| + return out; |
| +} |
| + |
| +export const app = defineApp({ |
| + name: "emoji", |
| + title: "Emoji", |
| + glyph: "😊", |
| + // Command-only: no personal thread to route to (the command acts where you |
| + // typed it) and nothing to instantiate from the Apps menu. |
| + personal: false, |
| + listed: false, |
| + initialState: () => ({}), |
| + events: {}, |
| + |
| + commands: { |
| + "/emoji": { |
| + desc: "browse emoji — tap one to add it to your message", |
| + args: "[search]", |
| + run(args, ctx) { |
| + const q = (args || "").trim(); |
| + const results = searchEmoji(q); |
| + if (!results.length) { |
| + ctx.reply({ text: `No emoji match “${q}”. Try \`/emoji cat\`, or a bare \`/emoji\` to browse them all.` }); |
| + return; |
| + } |
| + const opened = ctx.panel({ |
| + title: q ? `Emoji matching “${q}”` : "Emoji — tap to add", |
| + render(el, panel) { |
| + const grid = document.createElement("div"); |
| + grid.className = "emoji-grid"; |
| + for (const r of results) { |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.className = "emoji-grid-btn"; |
| + b.textContent = r.ch; |
| + b.title = ":" + r.name + ":"; |
| + b.setAttribute("aria-label", r.name.replace(/_/g, " ")); |
| + b.onclick = () => { |
| + panel.insertText(r.ch); |
| + panel.close(); |
| + }; |
| + grid.appendChild(b); |
| + } |
| + el.appendChild(grid); |
| + }, |
| + }); |
| + // No chat surface to draw a panel in (shouldn't happen where a |
| + // composer exists, but stay honest): answer with a reply instead. |
| + if (!opened) ctx.reply({ text: results.slice(0, 30).map((r) => r.ch).join(" ") }); |
| + }, |
| + }, |
| + }, |
| + |
| + styles: ` |
| +.emoji-grid { display: flex; flex-wrap: wrap; gap: 2px; } |
| +.emoji-grid-btn { |
| + border: none; background: none; font-size: 22px; line-height: 1; |
| + padding: 6px; border-radius: 8px; cursor: pointer; |
| +} |
| +.emoji-grid-btn:hover { background: var(--bubble-them); } |
| +`, |
| +}); |
diff --git a/web/static/apps/gif.js b/web/static/apps/gif.js| new file mode 100644 |
| index 0000000..b2cdd90 |
| --- /dev/null |
| +++ b/web/static/apps/gif.js |
| @@ -0,0 +1,300 @@ |
| +// GIF search: `/gif [search]` opens a COMMAND PANEL of Giphy results — tap one |
| +// and it posts into the chat as a normal encrypted image message, from you. |
| +// The app never fetches: all Giphy traffic goes through the host's scoped |
| +// doorway (ctx.host.gif — search, bytes, upload), and what lands in the thread |
| +// is an ordinary attachment blob, encrypted client-side to the thread key. The |
| +// server never sees the search, the pick, or the bytes; Giphy sees the query |
| +// from YOUR client (the one deliberate trade-off — see CLAUDE.md, next to |
| +// unfurl's), authenticated with YOUR OWN free Giphy key — asked for on first |
| +// launch, kept in this device's localStorage, never sent to the lchat server. |
| +// Posting is direct — tap = send, like Slack's /giphy — so the same |
| +// flow works in the native shells' command-host webview, where there is no web |
| +// composer to stage into. |
| +import { defineApp } from "../apps.js"; |
| + |
| +// The largest GIF we'll post. Giphy's "downsized" rendition targets ~2 MB; |
| +// this cap is the backstop when only bigger renditions exist. |
| +export const GIF_MAX_BYTES = 8 * 1024 * 1024; |
| + |
| +// renditionCandidates lists the POSTABLE renditions of a Giphy result, best |
| +// first: downsized → fixed_height → original, skipping metadata-oversize ones |
| +// (sizes arrive as strings, sometimes "0"/absent — the byte fetch re-checks |
| +// the real length, so an unknown size stays in). The panel walks the list on |
| +// post, so one failing URL doesn't kill the send. Pure, unit-testable. |
| +export function renditionCandidates(images, maxBytes = GIF_MAX_BYTES) { |
| + const out = []; |
| + for (const name of ["downsized", "fixed_height", "original"]) { |
| + const r = images && images[name]; |
| + if (!r || !r.url) continue; |
| + const size = parseInt(r.size, 10) || 0; |
| + if (size > maxBytes) continue; |
| + if (out.some((c) => c.url === r.url)) continue; |
| + out.push({ url: r.url, w: parseInt(r.width, 10) || 0, h: parseInt(r.height, 10) || 0 }); |
| + } |
| + return out; |
| +} |
| + |
| +// The best single choice — candidates' head (kept for gifItems' usability gate). |
| +export function pickRendition(images, maxBytes = GIF_MAX_BYTES) { |
| + return renditionCandidates(images, maxBytes)[0] || null; |
| +} |
| + |
| +// gifItems maps a raw Giphy data[] page to what the panel renders: a preview |
| +// (small, fixed-height for an even grid) plus the full images map for |
| +// pickRendition at post time. Results without a usable preview or post |
| +// rendition are dropped. Pure, testable. |
| +export function gifItems(data) { |
| + const out = []; |
| + for (const g of data || []) { |
| + if (!g || !g.images) continue; |
| + const p = g.images.fixed_height_small || g.images.fixed_height; |
| + if (!p || !p.url || !pickRendition(g.images)) continue; |
| + out.push({ |
| + id: g.id || "", |
| + title: g.title || "GIF", |
| + preview: { url: p.url, w: parseInt(p.width, 10) || 0, h: parseInt(p.height, 10) || 0 }, |
| + images: g.images, |
| + }); |
| + } |
| + return out; |
| +} |
| + |
| +export const app = defineApp({ |
| + name: "gif", |
| + title: "GIFs", |
| + glyph: "🎞️", |
| + // Command-only, like /emoji: no personal thread, nothing to instantiate — |
| + // the command acts in the chat you typed it in. |
| + personal: false, |
| + listed: false, |
| + initialState: () => ({}), |
| + events: {}, |
| + |
| + commands: { |
| + "/gif": { |
| + desc: "find a GIF — tap one to send it here", |
| + args: "[search]", |
| + run(args, ctx) { |
| + const gifHost = ctx.host && ctx.host.gif; |
| + if (!gifHost) { |
| + ctx.reply({ text: "GIF search isn't available here." }); |
| + return; |
| + } |
| + const q = (args || "").trim(); |
| + const opened = ctx.panel({ |
| + title: q ? `GIFs matching “${q}”` : "GIFs — tap one to send it", |
| + render(el, panel) { |
| + buildGifPanel(el, panel, ctx, gifHost, q); |
| + }, |
| + }); |
| + if (!opened) ctx.reply({ text: "Open a chat to use /gif." }); |
| + }, |
| + }, |
| + }, |
| + |
| + styles: ` |
| +.gif-search { |
| + width: 100%; box-sizing: border-box; margin: 0 0 8px; |
| + padding: 6px 10px; font: inherit; font-size: 13px; color: var(--ink); |
| + background: var(--bubble-them); border: 1px solid var(--border); border-radius: 8px; |
| +} |
| +.gif-search:focus { outline: none; border-color: var(--accent); } |
| +.gif-grid { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; } |
| +.gif-tile { |
| + border: none; background: var(--bubble-them); padding: 0; cursor: pointer; |
| + border-radius: 8px; overflow: hidden; height: 100px; position: relative; |
| +} |
| +.gif-tile img { display: block; height: 100px; width: auto; } |
| +.gif-tile:hover { outline: 2px solid var(--accent); } |
| +.gif-tile--busy::after { |
| + content: "Sending…"; position: absolute; inset: 0; display: flex; |
| + align-items: center; justify-content: center; font-size: 12px; font-weight: 600; |
| + color: #fff; background: rgba(0, 0, 0, 0.55); |
| +} |
| +.gif-status { font-size: 12px; color: var(--muted); padding: 6px 2px; } |
| +.gif-credit { font-size: 10px; color: var(--muted); text-align: right; padding-top: 6px; } |
| +.gif-keylink { border: none; background: none; padding: 0; font: inherit; color: var(--link); cursor: pointer; } |
| +.gif-keyform { display: flex; flex-direction: column; gap: 8px; max-width: 46ch; padding: 4px 0 8px; } |
| +.gif-keyintro { margin: 0; font-size: 13px; color: var(--ink); line-height: 1.45; } |
| +.gif-keyform a { font-size: 12px; color: var(--link); } |
| +.gif-keyrow { display: flex; gap: 6px; } |
| +.gif-keyrow .gif-search { margin: 0; flex: 1 1 auto; } |
| +.gif-keysave { |
| + flex: 0 0 auto; border: none; border-radius: 8px; padding: 6px 14px; |
| + font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; |
| + background: var(--accent); color: var(--accent-ink, #fff); |
| +} |
| +`, |
| +}); |
| + |
| +// The panel: a refine-search box, the result grid, a status line, and the |
| +// attribution Giphy's terms ask for. All Giphy I/O rides the host doorway. |
| +function buildGifPanel(el, panel, ctx, gifHost, initialQuery) { |
| + let posting = false; |
| + let searchSeq = 0; |
| + |
| + const search = document.createElement("input"); |
| + search.className = "gif-search"; |
| + search.type = "search"; |
| + search.placeholder = "Search GIFs…"; |
| + search.value = initialQuery; |
| + |
| + const grid = document.createElement("div"); |
| + grid.className = "gif-grid"; |
| + const status = document.createElement("div"); |
| + status.className = "gif-status"; |
| + const credit = document.createElement("div"); |
| + credit.className = "gif-credit"; |
| + credit.append("Powered by GIPHY · "); |
| + const keyLink = document.createElement("button"); |
| + keyLink.type = "button"; |
| + keyLink.className = "gif-keylink"; |
| + keyLink.textContent = "API key…"; |
| + keyLink.onclick = () => showKeyForm(""); |
| + credit.appendChild(keyLink); |
| + // Status sits ABOVE the grid: the panel scrolls, and an error appended |
| + // below a tall grid would land out of view exactly when it matters. |
| + el.append(search, status, grid, credit); |
| + |
| + const setStatus = (text) => { |
| + status.textContent = text || ""; |
| + status.style.display = text ? "" : "none"; |
| + }; |
| + |
| + // First-launch setup (and the recovery path for a rejected key): /gif uses |
| + // YOUR OWN free Giphy key, stored on this device only — never sent to the |
| + // lchat server. The form replaces the grid until a key is saved. |
| + const showKeyForm = (problem) => { |
| + grid.replaceChildren(); |
| + setStatus(""); |
| + const box = document.createElement("div"); |
| + box.className = "gif-keyform"; |
| + const intro = document.createElement("p"); |
| + intro.className = "gif-keyintro"; |
| + intro.textContent = problem || |
| + "GIF search uses your own (free) Giphy API key — it stays on this device and is only ever sent to Giphy."; |
| + const how = document.createElement("a"); |
| + how.href = "https://developers.giphy.com/dashboard/"; |
| + how.target = "_blank"; |
| + how.rel = "noopener noreferrer"; |
| + how.textContent = "Get a key at developers.giphy.com"; |
| + const row = document.createElement("div"); |
| + row.className = "gif-keyrow"; |
| + const input = document.createElement("input"); |
| + input.className = "gif-search"; |
| + input.type = "text"; |
| + input.placeholder = "Paste your Giphy API key"; |
| + input.autocomplete = "off"; |
| + input.spellcheck = false; |
| + const save = document.createElement("button"); |
| + save.type = "button"; |
| + save.className = "gif-keysave"; |
| + save.textContent = "Save"; |
| + const submit = () => { |
| + const key = gifHost.setKey(input.value); |
| + if (!key) return; |
| + box.remove(); |
| + load(search.value.trim()); |
| + }; |
| + save.onclick = submit; |
| + input.onkeydown = (e) => { |
| + if (e.key === "Enter") { |
| + e.preventDefault(); |
| + submit(); |
| + } |
| + }; |
| + row.append(input, save); |
| + box.append(intro, how, row); |
| + grid.appendChild(box); |
| + input.focus(); |
| + }; |
| + |
| + const post = async (item, tile) => { |
| + if (posting) return; |
| + posting = true; |
| + tile.classList.add("gif-tile--busy"); |
| + setStatus(""); |
| + try { |
| + // Walk the acceptable renditions: a single oversize/failed download |
| + // falls back to the next size instead of failing the whole send. |
| + let r = null; |
| + let bytes = null; |
| + for (const cand of renditionCandidates(item.images)) { |
| + bytes = await gifHost.bytes(cand.url, GIF_MAX_BYTES); |
| + if (bytes) { |
| + r = cand; |
| + break; |
| + } |
| + } |
| + if (!bytes) throw new Error("fetch"); |
| + const name = (item.title || "gif").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "gif"; |
| + const att = await gifHost.upload(ctx.threadId, bytes, { name: name + ".gif", type: "image/gif", w: r.w, h: r.h }); |
| + if (!att) throw new Error("upload"); |
| + const sent = await ctx.emit({ text: "", attachments: [att] }); |
| + if (!sent) throw new Error("send"); |
| + panel.close(); // posted — the message in the feed is the result |
| + } catch (e) { |
| + console.error("gif post failed at", (e && e.message) || e); |
| + tile.classList.remove("gif-tile--busy"); |
| + posting = false; |
| + setStatus("Couldn't send that one — try another."); |
| + } |
| + }; |
| + |
| + const show = (items) => { |
| + grid.replaceChildren(); |
| + for (const item of items) { |
| + const tile = document.createElement("button"); |
| + tile.type = "button"; |
| + tile.className = "gif-tile"; |
| + tile.setAttribute("aria-label", "Send: " + item.title); |
| + const img = document.createElement("img"); |
| + img.src = item.preview.url; // https-only Giphy CDN — allowed by img-src |
| + img.alt = item.title; |
| + img.loading = "lazy"; |
| + tile.appendChild(img); |
| + tile.onclick = () => post(item, tile); |
| + grid.appendChild(tile); |
| + } |
| + }; |
| + |
| + const load = async (q) => { |
| + const seq = ++searchSeq; |
| + setStatus("Searching…"); |
| + const data = await gifHost.search(q); |
| + if (seq !== searchSeq) return; // a newer search superseded this one |
| + if (data === "key") { |
| + show([]); |
| + return showKeyForm(""); |
| + } |
| + if (data === "auth") { |
| + show([]); |
| + return showKeyForm("Giphy rejected that API key — check it and save again."); |
| + } |
| + if (!data) { |
| + show([]); |
| + setStatus("Couldn't reach Giphy — check your connection and try again."); |
| + return; |
| + } |
| + const items = gifItems(data); |
| + show(items); |
| + setStatus(items.length ? "" : `Nothing for “${q}”. Try another search.`); |
| + }; |
| + |
| + // Refining re-searches (debounced); Enter searches immediately. Escape |
| + // bubbles to the pane's handler, which closes the panel. |
| + let debounce = 0; |
| + search.oninput = () => { |
| + clearTimeout(debounce); |
| + debounce = setTimeout(() => load(search.value.trim()), 350); |
| + }; |
| + search.onkeydown = (e) => { |
| + if (e.key === "Enter") { |
| + e.preventDefault(); |
| + clearTimeout(debounce); |
| + load(search.value.trim()); |
| + } |
| + }; |
| + |
| + load(initialQuery); |
| +} |
diff --git a/web/static/sandbox-frame.js b/web/static/sandbox-frame.js| index 9732d64..83e654e 100644 |
| --- a/web/static/sandbox-frame.js |
| +++ b/web/static/sandbox-frame.js |
| @@ -32,7 +32,20 @@ |
| // { type: "unschedule", seq, sid } |
| // { type: "ephemeral", event } |
| // { type: "command-reply", seq, spec } |
| +// { type: "panel-open", seq, cmdSeq, spec } (a command asking for a panel) |
| +// { type: "panel-size", h } (panel frame → fit my content) |
| +// { type: "panel-close" } (panel frame dismissing itself) |
| +// { type: "panel-insert", text } (panel frame → composer insert) |
| // { type: "error", message } |
| +// |
| +// PANEL frames: a member app's command can open a command panel (the |
| +// transient surface above the composer — apps/README.md § Command panels). |
| +// A render closure can't cross the bridge, so the sandbox form is |
| +// declarative: the command calls ctx.panel({ title, data }) and the app |
| +// declares a top-level panel(data, el, panelCtx) hook. The host mounts a |
| +// FRESH sandboxed frame inside the real panel and initialises it with |
| +// { panel: { data } } — this runtime then calls the hook instead of starting |
| +// the render/fold loop. panelCtx = ctx + { close, insertText }. |
| // The pure text helpers from ../format.js — the SAME module the trusted apps |
| // use, so a member app's linkifying/formatting behaves identically. In Node |
| @@ -537,6 +550,24 @@ function bootFrame() { |
| replySpec = { text: String(r.text || ""), short: r.short != null ? String(r.short) : undefined }; |
| } |
| }, |
| + // Open a command panel (declarative — see the header). The host mounts |
| + // a fresh panel frame that runs the app's panel(data, el, panelCtx) |
| + // hook; everything crossing the bridge is plain JSON. Resolves whether |
| + // a panel actually opened (false: no hook, no chat surface, rejected). |
| + panel: (p) => { |
| + if (!spec || typeof spec.panel !== "function") { |
| + console.warn("ctx.panel needs a top-level panel(data, el, panelCtx) hook in the sandbox"); |
| + return Promise.resolve(false); |
| + } |
| + const c = p && typeof p === "object" ? p : {}; |
| + // Unset fields are OMITTED (an `undefined` property would trip the |
| + // host's whole-object JSON validation). |
| + const out = { data: c.data === undefined ? null : c.data }; |
| + if (c.title !== undefined) out.title = c.title; |
| + if (c.position !== undefined) out.position = c.position; |
| + if (c.animate !== undefined) out.animate = c.animate; |
| + return rpc("panel-open", { cmdSeq: m.seq, spec: out }).then((v) => !!v); |
| + }, |
| }); |
| try { |
| await handler.run(String(m.args || ""), cctx); |
| @@ -635,6 +666,41 @@ function bootFrame() { |
| } |
| chatLines = Array.isArray(seed.chat) ? seed.chat.slice(-CHAT_CAP) : []; |
| for (const it of seed.pend || []) if (it && it.e) fold(it.e, it.meta); |
| + |
| + // PANEL mode: this frame IS a command panel's body. Run the app's panel |
| + // hook against the seeded state and keep the host sized to the content — |
| + // no render/fold loop and no state reports (the app-thread's own frame |
| + // owns those; the host ignores a panel frame's "state" anyway). |
| + if (m.panel) { |
| + if (state === undefined) { |
| + try { |
| + state = spec.reduce(undefined, { type: "@@lchat/init" }, {}); |
| + } catch { |
| + state = null; |
| + } |
| + } |
| + if (typeof spec.panel !== "function") { |
| + showFatal("this app has no panel(data, el, panelCtx) hook"); |
| + return; |
| + } |
| + const pctx = Object.assign({}, ctx, { |
| + close: () => send({ type: "panel-close" }), |
| + insertText: (t) => send({ type: "panel-insert", text: String(t == null ? "" : t).slice(0, 2000) }), |
| + }); |
| + try { |
| + spec.panel(m.panel.data === undefined ? null : m.panel.data, root, pctx); |
| + } catch (e) { |
| + showFatal((e && e.message) || String(e)); |
| + return; |
| + } |
| + const postSize = () => { |
| + const h = Math.max(document.body.scrollHeight, root.scrollHeight || 0); |
| + send({ type: "panel-size", h: Math.min(600, Math.max(24, h)) }); |
| + }; |
| + if (typeof ResizeObserver !== "undefined") new ResizeObserver(postSize).observe(document.body); |
| + postSize(); |
| + return; |
| + } |
| // Materialise initialState even with zero events to fold: the reducer only |
| // seeds on the first fold, so a freshly-deployed app opened before any event |
| // would otherwise render with state === undefined — and a render that reads |
diff --git a/web/static/sandbox.js b/web/static/sandbox.js| index 17e60ba..64722a4 100644 |
| --- a/web/static/sandbox.js |
| +++ b/web/static/sandbox.js |
| @@ -183,6 +183,28 @@ export function sanitizeAppEvent(appName, ownedTypes, event) { |
| return e; |
| } |
| +// sanitizePanelSpec admits a command panel request from a frame (the |
| +// declarative ctx.panel form — see sandbox-frame.js's header): clamped title, |
| +// whitelisted position/animate, and `data` (the JSON handed to the app's |
| +// panel hook) cleaned and size-capped. Returns the cleaned spec or null. |
| +export function sanitizePanelSpec(spec) { |
| + if (!spec || typeof spec !== "object" || Array.isArray(spec)) return null; |
| + // Field-wise, not cleanJSON on the whole object: an app that leaves |
| + // position/animate unset ships them as `undefined` properties, which |
| + // cleanJSON (rightly) rejects wholesale — only `data` is arbitrary JSON. |
| + const data = spec.data === undefined || spec.data === null ? null : cleanJSON(spec.data); |
| + if (data === undefined) return null; |
| + const out = { title: String(spec.title || "").slice(0, 80), data }; |
| + if (spec.position === "top") out.position = "top"; |
| + if (spec.animate === false) out.animate = false; |
| + try { |
| + if (JSON.stringify(data).length > 64 * 1024) return null; |
| + } catch { |
| + return null; |
| + } |
| + return out; |
| +} |
| + |
| // ---- the proxy app --------------------------------------------------------- |
| // One shared stylesheet for every sandbox mount (injected once). |
| @@ -269,16 +291,19 @@ function initMount(rec) { |
| const state = rec.ctx.getState(); |
| const seed = seedFrom(state); |
| for (const it of seed.pend) if (it && it.meta && it.meta.id != null) rec.sent.add(it.meta.id); |
| + const init = { |
| + type: "lchat-sandbox-init", |
| + source: rec.app.memberApp.source, |
| + manifest: rec.app.memberApp.manifest, |
| + threadId: rec.ctx.threadId, |
| + me: { uid: rec.ctx.me, name: rec.ctx.meName }, |
| + theme: themeSnapshot(), |
| + seed, |
| + }; |
| + // A panel frame boots straight into the app's panel hook (sandbox-frame.js). |
| + if (rec.panelFrame) init.panel = { data: rec.panelData === undefined ? null : rec.panelData }; |
| rec.iframe.contentWindow.postMessage( |
| - { |
| - type: "lchat-sandbox-init", |
| - source: rec.app.memberApp.source, |
| - manifest: rec.app.memberApp.manifest, |
| - threadId: rec.ctx.threadId, |
| - me: { uid: rec.ctx.me, name: rec.ctx.meName }, |
| - theme: themeSnapshot(), |
| - seed, |
| - }, |
| + init, |
| // The frame's origin is opaque, so "*" is the only addressable target; |
| // the transferred port scopes the channel to exactly this frame. |
| "*", |
| @@ -308,6 +333,7 @@ function onFrameMessage(rec, ev) { |
| const ctx = rec.ctx; |
| switch (m.type) { |
| case "state": { |
| + if (rec.panelFrame) return; // the app-thread's own frame owns state |
| const upTo = m.upTo; |
| if (typeof upTo !== "number" || !Number.isFinite(upTo) || upTo < 0) return; |
| const state = cleanJSON(m.state); |
| @@ -372,12 +398,103 @@ function onFrameMessage(rec, ev) { |
| Promise.resolve(ctx.unschedule(sid)).then((ok) => reply(rec, m.seq, !!ok)); |
| break; |
| } |
| + case "panel-open": { |
| + // A COMMAND asking for a panel (declarative ctx.panel). Gated to a |
| + // still-live command invocation — the host ctx (with its panel opener, |
| + // bound to where the member typed) is looked up by the command's seq, |
| + // so a frame can't pop panels outside a command it was just asked to |
| + // run ("a response, not an ambush", for member apps too). |
| + const hostCtx = rec.cmdCtx && rec.cmdCtx.get(m.cmdSeq); |
| + const spec = hostCtx && typeof hostCtx.panel === "function" ? sanitizePanelSpec(m.spec) : null; |
| + if (!spec) { |
| + reply(rec, m.seq, false); |
| + return; |
| + } |
| + const opened = hostCtx.panel({ |
| + title: spec.title, |
| + position: spec.position, |
| + animate: spec.animate, |
| + render: (el, handle) => mountPanelFrame(app, hostCtx.threadId, spec.data, el, handle), |
| + onClose: () => teardownPanelMount(app.name), |
| + }); |
| + reply(rec, m.seq, !!opened); |
| + break; |
| + } |
| + case "panel-size": { |
| + // The panel frame fitting its content: clamp and size the iframe; the |
| + // host panel's own body scrolls beyond the cap. |
| + if (!rec.panelFrame) return; |
| + const h = typeof m.h === "number" && Number.isFinite(m.h) ? Math.min(600, Math.max(24, m.h)) : 0; |
| + if (h) rec.iframe.style.height = h + "px"; |
| + break; |
| + } |
| + case "panel-close": { |
| + if (rec.panelFrame && rec.panelHandle) rec.panelHandle.close(); |
| + break; |
| + } |
| + case "panel-insert": { |
| + // A panel acting on the composer it sits above (clamped; the same |
| + // doorway a trusted panel's insertText uses). Should follow a user tap. |
| + if (rec.panelFrame && rec.panelHandle && typeof m.text === "string") { |
| + rec.panelHandle.insertText(m.text.slice(0, 2000)); |
| + } |
| + break; |
| + } |
| case "error": |
| note(rec, `App error: ${String(m.message || "unknown").slice(0, 200)}`, true); |
| break; |
| } |
| } |
| +// ---- command panels for member apps ---------------------------------------- |
| + |
| +// One live panel frame per app (opening another replaces it, like the host |
| +// panel itself). The frame is just another mount: its emits/schedules ride the |
| +// same sanitized, rate-limited bridge; only `state` reports are ignored. |
| +const panelMounts = new Map(); // app name → mount record |
| + |
| +function mountPanelFrame(app, threadId, data, el, handle) { |
| + teardownPanelMount(app.name); |
| + injectSandboxStyles(); |
| + const iframe = document.createElement("iframe"); |
| + iframe.className = "sandbox-frame sandbox-panel-frame"; |
| + iframe.setAttribute("sandbox", "allow-scripts"); |
| + iframe.setAttribute("allow", ""); |
| + iframe.referrerPolicy = "no-referrer"; |
| + iframe.src = "/app-sandbox"; |
| + iframe.style.cssText = "width:100%;border:0;display:block;height:96px"; |
| + const rec = { |
| + iframe, |
| + el, |
| + app, |
| + ctx: memberCtx(threadId, app), |
| + port: null, |
| + sent: new Set(), |
| + emits: [], |
| + panelFrame: true, |
| + panelHandle: handle, |
| + panelData: data, |
| + }; |
| + mounts.set(iframe, rec); |
| + panelMounts.set(app.name, rec); |
| + iframe.addEventListener("load", () => { |
| + rec.port = null; |
| + initMount(rec); |
| + }); |
| + el.appendChild(iframe); |
| +} |
| + |
| +function teardownPanelMount(name) { |
| + const rec = panelMounts.get(name); |
| + if (!rec) return; |
| + panelMounts.delete(name); |
| + // Small grace so a click's in-flight emit still crosses before removal. |
| + setTimeout(() => { |
| + mounts.delete(rec.iframe); |
| + if (rec.iframe.parentNode) rec.iframe.remove(); |
| + }, 2000); |
| +} |
| + |
| function reply(rec, seq, value) { |
| if (rec.port && typeof seq === "number") rec.port.postMessage({ type: "rpc-reply", seq, value }); |
| } |
| @@ -541,7 +658,7 @@ async function runMemberCommand(app, hostCtx, cmdName, args) { |
| // must still be torn down, or it leaks a stranded hidden iframe. |
| try { |
| if (!rec.port) return; |
| - const spec = await sendCommand(rec, cmdName, args); |
| + const spec = await sendCommand(rec, cmdName, args, hostCtx); |
| if (spec && hostCtx.reply) hostCtx.reply(sanitizeReply(spec, app, hostCtx)); |
| } finally { |
| teardownTransient(rec); |
| @@ -549,9 +666,14 @@ async function runMemberCommand(app, hostCtx, cmdName, args) { |
| } |
| let cmdSeq = 0; |
| -function sendCommand(rec, cmd, args) { |
| +function sendCommand(rec, cmd, args, hostCtx) { |
| if (!rec.cmdWaiters) rec.cmdWaiters = new Map(); |
| + if (!rec.cmdCtx) rec.cmdCtx = new Map(); |
| const seq = ++cmdSeq; |
| + // The host ctx stays addressable past the command's reply: a handler may |
| + // reply first and open its panel after an await. The window is bounded. |
| + rec.cmdCtx.set(seq, hostCtx); |
| + setTimeout(() => rec.cmdCtx.delete(seq), 20000); |
| return new Promise((resolve) => { |
| rec.cmdWaiters.set(seq, resolve); |
| rec.port.postMessage({ type: "command", seq, cmd, args: String(args || "") }); |
diff --git a/web/static/shell.js b/web/static/shell.js| index d4029ed..9479235 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -91,6 +91,8 @@ import { |
| messageActionsFor, |
| renderInline as renderAppLine, |
| appStateFor, |
| + setCommandPanelObserver, |
| + closeCommandPanel, |
| } from "/static/apps.js"; |
| // Register the mini-apps (side-effect imports). Each is a single self-contained |
| // file (JS + its own CSS). Personal apps get their own sidebar thread; shared |
| @@ -104,6 +106,8 @@ import "/static/apps/board.js"; |
| import "/static/apps/connect.js"; |
| import "/static/apps/dnsimple.js"; |
| import "/static/apps/github.js"; |
| +import "/static/apps/emoji.js"; // /emoji — the command-panel reference app |
| +import "/static/apps/gif.js"; // /gif — Giphy search in a command panel (bring-your-own key; posts via ctx.host.gif) |
| import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command |
| import { startSetup } from "/static/apps/setup.js"; // the first-run setup conversation (also registers its app) |
| import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome conversation (also registers its app) |
| @@ -764,6 +768,9 @@ function hideLoading() { |
| // that (feed render, send, reactions, edits, typing, attachments) is reused as-is. |
| async function bootEmbed() { |
| document.body.classList.add("native-embed"); |
| + // Command-host pages collapse to just the panel (style.css .command-host |
| + // rules) — classed before the first render so the feed never flashes. |
| + if (embed.mode === "command") document.body.classList.add("command-host"); |
| try { |
| // The native shell can land straight in a custom app's thread, so the |
| // embed path needs registration complete before its render too. |
| @@ -811,6 +818,10 @@ async function bootEmbed() { |
| await openThread(tid); |
| connectWS(); |
| installNativeBridge(); |
| + // Command-host mode (the Mac shell's panel webview): this page exists to |
| + // RUN slash commands and show their panels — the native feed renders the |
| + // chat itself. See installCommandHost. |
| + if (embed.mode === "command") installCommandHost(tid); |
| startHeartbeat(); |
| // The pane has real content now — the native shell drops its loading |
| // veil on this (didFinish fires long before the app has painted). |
| @@ -924,6 +935,70 @@ function installNativeBridge() { |
| }; |
| } |
| +// ---- command host (embed.mode === "command") ------------------------------- |
| +// |
| +// The native Mac shell renders chat NATIVELY, so it has no JS command engine — |
| +// this page is that engine, one per thread: the shell keeps it hidden, forwards |
| +// a typed slash command to window.lchatNative.runCommand, and reveals the |
| +// webview only while a panel is showing (body.command-host CSS collapses |
| +// everything except the .cmd-panel, so the webview IS the panel). Private |
| +// replies post back as {notice} for the shell to render inline; panel |
| +// visibility and height post as {panel}; a panel's insertText already posts |
| +// {composerInsert} (chat.js) for the shell's own composer. |
| +function installCommandHost(tid) { |
| + // Content height for the shell's frame. scrollHeight is the content EXTENT, |
| + // immune to the max-height clamp and to the webview being 0-tall while |
| + // hidden — the one measurement that works before the shell reveals it. |
| + const measure = () => { |
| + const body = document.querySelector(".cmd-panel-body"); |
| + const head = document.querySelector(".cmd-panel-head"); |
| + return Math.min(600, (body ? body.scrollHeight : 200) + (head ? head.offsetHeight : 26) + 12); |
| + }; |
| + const postPanel = (spec) => { |
| + if (!spec) return nativePost({ panel: null }); |
| + // Measure synchronously — scrollHeight forces layout, and this page is |
| + // DETACHED from the native view hierarchy until the shell reveals it, so |
| + // a requestAnimationFrame here never fires (WebKit suspends rendering |
| + // callbacks for unparented webviews) and the panel would deadlock hidden. |
| + // Timers still run detached, so the late re-measure (fonts, images, and |
| + // the real width once the shell attaches the frame) uses setTimeout. |
| + nativePost({ |
| + panel: { |
| + title: spec.title || (spec.sender && spec.sender.name) || "", |
| + height: measure(), |
| + // The app's animation choice (animate: false = appear instantly) — |
| + // in command-host mode the NATIVE shell owns the reveal, so the flag |
| + // rides the post instead of easing in-page (see panelEases, chat.js). |
| + animate: spec.animate !== false, |
| + }, |
| + }); |
| + setTimeout(() => nativePost({ panelHeight: measure() }), 350); |
| + }; |
| + setCommandPanelObserver((threadId, spec) => { |
| + if (threadId === tid) postPanel(spec); |
| + }); |
| + // The shell can't see this page's console, so surface page-side failures as |
| + // {error} posts — they land in the native unified log (CommandHost). |
| + window.addEventListener("error", (e) => nativePost({ error: "page: " + (e.message || "error") })); |
| + window.addEventListener("unhandledrejection", (e) => |
| + nativePost({ error: "page: " + ((e.reason && (e.reason.message || e.reason)) || "unhandled rejection") })); |
| + const origConsoleError = console.error.bind(console); |
| + console.error = (...args) => { |
| + origConsoleError(...args); |
| + try { |
| + nativePost({ error: "console: " + args.map((a) => (a && a.message) || String(a)).join(" ").slice(0, 300) }); |
| + } catch {} |
| + }; |
| + // Returns whether the text was a command this engine answered (panel or |
| + // reply) — false lets the shell send it as a normal message (bot commands). |
| + window.lchatNative.runCommand = (text) => |
| + runCommand(text, tid, (r) => |
| + nativePost({ notice: { from: (r && r.sender && r.sender.name) || "", text: (r && r.text) || "" } })); |
| + // Escape in the native composer (and the shell's own chrome) dismisses. |
| + window.lchatNative.dismissPanel = () => closeCommandPanel(tid); |
| + nativePost({ commandHostReady: true }); |
| +} |
| + |
| // ---- data --------------------------------------------------------------- |
| function ensurePubkey() { |
| @@ -2857,8 +2932,103 @@ setHostBridge({ |
| } |
| }, |
| }, |
| + // The /gif app's scoped Giphy doorway (apps never fetch — the host does, and |
| + // only to *.giphy.com, which the CSP pins). This is the client-side sibling |
| + // of the unfurl exception: the SERVER never sees the search, the pick, or |
| + // the bytes — Giphy sees the query from the member's own client. The API |
| + // key is the member's own (the panel asks on first use — free at |
| + // developers.giphy.com) and lives in THIS device's localStorage: it's a |
| + // public client identifier, not a secret, and it never touches the server. |
| + gif: { |
| + hasKey() { |
| + try { |
| + return !!localStorage.getItem(GIPHY_KEY_STORE); |
| + } catch { |
| + return false; |
| + } |
| + }, |
| + // Store (or clear, with "") the member's key. Returns the trimmed value. |
| + setKey(k) { |
| + const key = String(k || "").trim(); |
| + try { |
| + if (key) localStorage.setItem(GIPHY_KEY_STORE, key); |
| + else localStorage.removeItem(GIPHY_KEY_STORE); |
| + } catch {} |
| + return key; |
| + }, |
| + // Search returns the raw data[] page — or a reason string: "key" (no key |
| + // stored yet), "auth" (Giphy rejected the key), null (network/other), so |
| + // the panel can ask for a key exactly when one is the problem. |
| + async search(q, limit = 24) { |
| + let key = ""; |
| + try { |
| + key = localStorage.getItem(GIPHY_KEY_STORE) || ""; |
| + } catch {} |
| + if (!key) return "key"; |
| + const base = "https://api.giphy.com/v1/gifs/"; |
| + const url = q |
| + ? `${base}search?api_key=${encodeURIComponent(key)}&q=${encodeURIComponent(q)}&limit=${limit}&rating=pg-13` |
| + : `${base}trending?api_key=${encodeURIComponent(key)}&limit=${limit}&rating=pg-13`; |
| + try { |
| + const r = await fetch(url, { signal: AbortSignal.timeout(15000) }); |
| + if (r.status === 401 || r.status === 403) return "auth"; |
| + if (!r.ok) return null; |
| + return (await r.json()).data || []; |
| + } catch (e) { |
| + console.error("gif search failed:", e); |
| + return null; |
| + } |
| + }, |
| + // Download one rendition's bytes — Giphy hosts only, size-capped (checked |
| + // again after download; Content-Length can be absent). Failures log the |
| + // step, so a native shell's unified log shows WHERE a post died. |
| + async bytes(url, maxBytes) { |
| + try { |
| + const u = new URL(url); |
| + if (u.protocol !== "https:" || !(u.hostname === "giphy.com" || u.hostname.endsWith(".giphy.com"))) { |
| + console.error("gif bytes refused non-giphy url"); |
| + return null; |
| + } |
| + const r = await fetch(u, { signal: AbortSignal.timeout(30000) }); |
| + if (!r.ok) { |
| + console.error("gif bytes fetch not ok:", r.status); |
| + return null; |
| + } |
| + const buf = new Uint8Array(await r.arrayBuffer()); |
| + if (maxBytes && buf.length > maxBytes) { |
| + console.error("gif bytes over cap:", buf.length); |
| + return null; |
| + } |
| + return buf; |
| + } catch (e) { |
| + console.error("gif bytes failed:", e); |
| + return null; |
| + } |
| + }, |
| + // Encrypt + upload bytes AS-IS to the thread's blob store (no image |
| + // re-encode — an animated GIF must survive; the caller already bounded the |
| + // size), returning the attachment metadata that rides inside the payload. |
| + async upload(threadId, bytes, meta = {}) { |
| + const key = threadKeys[threadId]; |
| + if (!key || !bytes || !bytes.length || bytes.length > MAX_UPLOAD) return null; |
| + try { |
| + const enc = await encryptBytes(key, bytes); |
| + const id = await uploadEncrypted(threadId, enc); |
| + const att = { id, name: meta.name || "gif.gif", type: meta.type || "image/gif", size: bytes.length }; |
| + if (meta.w) att.w = meta.w; |
| + if (meta.h) att.h = meta.h; |
| + return att; |
| + } catch (e) { |
| + console.error("gif upload failed:", e); |
| + return null; |
| + } |
| + }, |
| + }, |
| }); |
| +// Where the member's own Giphy key lives (see the hostBridge.gif note above). |
| +const GIPHY_KEY_STORE = "lchat-giphy-key"; |
| + |
| // The OAuth popup lands on /oauth/<service>/callback, which postMessages the |
| // one-time code back here; we hold the matching verifier. Complete on receipt. |
| window.addEventListener("message", (e) => { |
diff --git a/web/static/style.css b/web/static/style.css| index c4eebe1..631e988 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -607,6 +607,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| background: var(--bubble-them); |
| cursor: zoom-in; |
| } |
| +/* a GIF's click toggles play/pause, so the zoom-in cursor would lie */ |
| +.msg-image.msg-gif { cursor: pointer; } |
| /* full-screen image viewer */ |
| .lightbox { |
| @@ -2188,3 +2190,12 @@ html.native .thread-head { |
| flex: none; |
| height: calc(16px + var(--native-bottom-inset, 0px)); |
| } |
| + |
| +/* Command-host embed (embed.mode === "command"): the page the native Mac shell |
| + keeps beside each thread purely to run slash commands — everything except a |
| + command's panel collapses, so the webview the shell reveals IS the panel. |
| + The shell owns the frame (and caps the height via the posted measure). */ |
| +body.command-host .chatapp-root > :not(.cmd-panel) { display: none !important; } |
| +body.command-host .cmd-panel { border: none; box-shadow: none; } /* the native frame draws the border + shadow */ |
| +/* The shell grants the frame; the body scrolls within it (100vh = the frame). */ |
| +body.command-host .cmd-panel-body { max-height: calc(100vh - 40px); } |