Joiner onboarding: Eleven welcomes new members (#278)
* web: Eleven welcomes joiners — chat-first user onboarding
Claiming an invite lands a new member straight in a conversation with
Eleven (no name form): name (with the inviter's suggested name as a
chip), a profile photo sent through the chat (decrypted and re-encrypted
under the instance key — never plaintext), saving their profile behind a
passkey (softer joiner copy, same resume-on-return and stayed-in-place
paths as setup), and a finish offering the inviter's DM and the tour.
Gated by a local pending marker the join page writes (suggested name
included; never sent to a server) and cleared on completion. The native
add-chat webview keeps its form (it hands a named identity to the
shell). The sidecar's welcome DM stands down — one voice. Nameless
senders fall back to 'New member' (not 'Admin').
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server: LCHAT_SKIP_SETUP — test instances skip first-run onboarding
A dev/test convenience: mark the setup conversation complete on boot and
seed workable defaults (group name from LCHAT_GROUP_NAME or 'Test
Space'; member invites on) — the setup link still makes its claimer the
admin, they just skip the conversation. Only unset values are seeded, so
restarts never clobber in-test edits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: spent action chips, a fuller welcome intro, join-card caveat spacing
- A chip whose step has resolved renders SPENT: disabled, quiet, and
the tapped one shows its past-tense label (Skip for now → Skipped,
Keep → Kept). The script's step guard (go.taken) is the source of
truth; staleness folds into the notice vrev so the keyed feed
rebuilds rows when chips go stale. Tour chips get the same guard.
- Eleven's joiner greeting assumes nothing: what the space is (private,
invite-only), who runs it (the admin's name when set), and what the
encryption means — in lock-and-key terms.
- The join card's browser-only caveat gets clear air under the button
(.card .welcome-caveat so it outranks the generic .card p margin).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: photo descriptions (encrypted alt), contextual composer placeholders
- A message sent with a profile photo becomes its description: encrypted
under the instance key like the display name (users.avatar_alt_blob —
the server stores an opaque blob), set and cleared with the photo,
surfaced as the avatar's aria-label. The welcome photo prompt says so;
a profile-editor photo swap preserves the existing description.
- The composer placeholder follows the conversation: a scripted question
carries a hint — 'Type your name', 'Type a name for this space',
'Optional: describe your photo' — that replaces 'Message' when the
question renders and resets on the next hint-less reply.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: Eleven's conversations persist as real history; passkey comes first
- makeSay (apps/eleven.js, shared by setup + welcome): each line Eleven
says is emitted as an app event — encrypted and stored like any thread
event — and folds into the chat view as a real bubble, so the
conversation survives reloads as history instead of vanishing. Chips
and composer hints ride a client-only row (they need the live script);
on resume a line already in history re-arms its chips instantly, no
duplicate bubble, no typing beat. Chips-only rows render no bubble.
- The welcome flow saves ACCESS first: the passkey ceremony leads, so
the round-trip back lands mid-conversation with name and photo still
ahead — never straight onto 'all done'.
- 'This is the start of your chat with X' intro rows: Eleven's threads
and person-to-person DMs (no blank void under a first typing beat).
- Composer hints refined: sending resets the placeholder to 'Message'
immediately; a photo question's hint is attach-gated — it appears only
once a file is actually staged.
- join.js reloads on hashchange (a second invite link pasted into an
open join tab used to leave a stale card doing nothing).
- Fix a pre-existing TDZ: route() runs at module top level before
CUSTOM_APPS_TIMEOUT_MS initialized, so custom apps silently skipped on
every synchronous boot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web+server: welcome polish from the first two-device test
- The greeting says who runs the space: roster entries carry role
(metadata; it already gates the admin API), the client derives the
admin's name from their DECRYPTED label — never plaintext config —
and the welcome waits briefly for the instance-key grant so it can
actually say it. Names stay E2E.
- A successful invite claim broadcasts {type:joined}: the inviter's tab
swaps the pending-invite card for the live DM and picks up the new
member without a reload (it used to show copy/cancel-link forever).
- A keyless joiner polls for the instance key for ~1min, not 15s — the
cause of the other side rendering as 'Someone' until reload.
- The passkey ask is ONE bubble (context + question + chips attached),
so the opening run is two messages, not three.
- Chips flip to their spent state the instant they're tapped, not at
the next feed render; a chips-only row hugs the bubble it belongs to;
action lines get vertical breathing room.
- makeSay retries emits until the fold confirms them — the first line
raced the socket opening and silently vanished — and falls back to a
client-only notice if persistence is truly unavailable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: Enter saves the Edit-profile name
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server+web: fix the instance-key grant for fresh joiners; passkey-return ack
THE 'Someone' BUG: a keyless joiner's nudge asked holders to wrap the
instance key, but holders granted via their own connections() roster —
which doesn't include a joiner they share no thread with, so the very
person asking was silently skipped (always for bare invites; racy for DM
invites). Without the key the joiner can't decrypt anyone's name OR
publish their own — both sides render 'Someone' forever. The nudge now
carries the requester's user id + public key and holders wrap straight
to it. Verified live: granted in ~1.5s, unwraps clean (was: never).
Also:
- Back from the passkey ceremony, Eleven acknowledges properly ('Great —
welcome back. 🔒 Now that your access is secure, let's continue.') as
its own bubble, then asks the name — no generic pick-up line on top.
- Name question copy: 'First things first — what should folks here call
you?'
- The chips row tucks tight under its bubble (was still floating too
low).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the welcome shows your profile photo back before keeping it
Same shape as the admin setup's preview: the adopted photo renders in
the reply (decrypted object URL — an encrypted blob can't ride a
persisted event, so the preview is a client-only notice) with Keep /
Use-a-different chips; the step records and advances only on an
explicit keep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: Eleven wears the app icon; keyless tabs keep retrying for the key
- Apps can ship an avatar image (in-code URL only): Eleven's setup and
welcome threads show the app icon in the thread list and header
instead of the clock glyph.
- A keyless tab re-nudges for the instance key on window focus and on a
slow timer until it lands — a member whose first poll expired used to
stay 'Someone' (and see everyone as 'Someone') until a full reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: greeting credits the inviter (label reads moved after the key wait); round photo preview
The greeting said 'run by Paul' but not 'Paul invited you': the inviter
name was computed from the DM BEFORE the instance-key wait/decrypt that
was added for the admin name, so it read a still-encrypted label. All
label-derived reads now happen after the wait. Verified with a live
two-party repro (node inviter answering nudges + browser joiner):
'Paul invited you to Test Space — … run by Paul', sidebar shows Paul.
Also: the profile-photo preview renders as the circle it will actually
be.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: renaming in Edit profile publishes the label immediately
The save only wrote session.name locally; the encrypted label_blob was
only ever published as a boot side-effect, so after a rename every
other member kept seeing 'Someone' until the renamer's next reload.
This is THE remaining 'Someone' on real devices: the skip-setup admin
renames via Edit profile and invites without ever reloading. Verified
against the live canary: rename left users.label_blob empty; with the
fix it publishes on Save.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the joined event actually clears the pending-invite card
openThread(activeId) hits the already-open guard and returns before
updatePendingBanner — so the inviter kept staring at 'hasn't joined
yet' + copy/cancel until they navigated away. Refresh the banner
directly. Reproduced live on the canary: joiner fully in (claimed,
keyed, labelled) while the card sat stale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: staged-attachment strip takes real layout space; chips hug their bubble
- The staging preview floated ABSOLUTELY over the feed's bottom — with a
photo staged it sat exactly on top of the welcome's 'Skip for now'
chip, turning it into dead text (reproduced pixel-for-pixel on the
canary). Per the repo rule that nothing overlays content someone may
tap, the strip is in-flow now: the feed shrinks above it.
- The chips-row tightening never applied: the rules sat ABOVE the
.local-notice/.chip-row base rules with equal specificity, so source
order killed them (verified via computed styles: 6px/8px, not -4/0).
Re-placed after the base rules with a doubled class; the chips row
now tucks under its bubble.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: keep the feed pinned when the staging strip grows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: stable say keys (no duplicate questions on resume); Say-hi survives reloads
- Ack-carrying questions dedupe on a stable key, so a mid-step reload
doesn't re-emit the same question with a different ack prefix.
- The finish's 'Say hi to <inviter>' chip survives a mid-welcome reload:
fall back to the joiner's sole person-DM when the one-load join marker
is already consumed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: a 6px breath between a bubble and its chips (not glued, not floating)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: Eleven's messages glide in — no more scroll jumps between beats
The slide-up animation was already there (.msg.appear), but two INSTANT
scrollTop snaps sandwiched every smooth glide: the typing-indicator hook
pinned with a hard jump on each beat, and the chips-only notice row
force-snapped (pinNext) right after its bubble's glide. Typing beats now
scroll smoothly (reduced-motion honoured) and chips rows ride their
bubble's glide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the typing beat never flashes — 1s minimum, still scaling with length
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: scene-setting loading lines under the boot spinner
A fresh member's first load genuinely takes a few seconds — fill it with
shuffled lock-and-key one-liners ('Cutting your keys…', 'Sweeping for
eavesdroppers… all clear.') that ease in and out. The reveal waits until
the current line has been readable (≥1.1s); fast boots reveal before the
first line fires and keep the plain spinner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: fix the boot-breaking TDZ in the loading lines; typing row holds until the bubble lands
- LOADING_LINES/loadingMsg state were declared BELOW route()'s
synchronous module-level boot() call — startLoadingMessages threw,
boot aborted, and hideLoading threw too, leaving the spinner forever.
The 6f085d5 canary was broken for every boot because of this. All
boot-touched state now lives above the route() call, next to
CUSTOM_APPS_TIMEOUT_MS, which learned the same lesson.
- The typing indicator stays up until Eleven's bubble has actually
folded into the feed — dropping it at emit time collapsed the list
and then re-grew it when the message arrived a beat later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the boot loader is a clock dial that fills 1→10, then clicks to 11
A conic-gradient dial with faint hour ticks: the pie sweeps from 1
o'clock asymptotically towards 10 while the app loads (fast at first,
easing off, never arriving), and the moment everything's ready it snaps
decisively to 11 with a little scale pulse, holds a beat, and the app
fades in. The scene-setting one-liners are gone — the dial going to
eleven IS the scene.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the boot loader is an amp knob that goes to eleven
Modelled on an amplifier volume pot: labels 1–11 around a 300° sweep (1
at 7 o'clock, 11 at 5), a pointer notch on the knob, and a pie arc
filling behind it. While loading it eases from 1 towards 10 and starts
to SHAKE as the volume climbs. The finish is choreographed to feel like
a human turning it, never instant: even a fast load gets a minimum turn
to 10, a beat of pause, a decisive snap to eleven, then a glow (the 11
lights up) while the tremble dies down — and only then does the chat
fade in. Reduced motion: no shake, no glow pulse.
(An earlier uncommitted version of this spliced OUT route()/boot() by
anchoring the replacement past them — the app defined everything and
called nothing. Caught by driving a real boot; splice anchors now sit
tight around the engine block.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: hitting eleven POPS the knob off the amp face (glow retired)
The glow read as nothing (a box-shadow pulsing on a gradient circle).
Instead: tremble dies, then the whole knob scales up with a lifted
drop-shadow and springs back — a physical pop — while the 11 stays lit
in accent. Reduced motion skips the pop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: one loader story — spinner, 'Plugging in…', then the amp knob
The claim page keeps its radial spinner; when the claim lands it flashes
'Plugging in…' (fades in, holds a second, fades out) before navigating,
and the amp knob fades in on the far side to run its turn-to-eleven
choreography. No more two-loader whiplash on the way into a new space.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: no pop — hit eleven, light the 11, breathe, fast fade
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the knob stays hidden until posed at 1 — no unposed pre-JS flash
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: typing rows stay in view; tour gets beats; Say-hi focuses the composer
- The photo preview's image loads after its notice was pinned, growing
the feed ~180px — everything after it (the next typing row included)
hid below the fold, which read as 'the typing indicator is missing
after I update my photo'. Images re-pin the feed on load.
- Tour replies get the same short typing beat as Eleven's script when
launched from setup/welcome (one queued wrapper around respond).
- Tapping 'Say hi to <inviter>' focuses the composer in the opened DM.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: after eleven, the knob fades out before the chat appears
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: linger at 10 a beat longer before the snap to eleven
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: intent-aware feed glide (typing row never below the fold); shake goes to eleven
- Follow checks used to read scrollTop mid-glide and conclude we'd
scrolled away — the typing row then skipped its pin and sat below the
fold (reported after the name answer and the photo step). A shared
feedGlideToBottom now chases the growing bottom and counts as
at-bottom while in flight; user wheel/touch cancels the chase. The
render, typing row, staging strip, and the conversations' typing hook
all ride it.
- The snap to 11 no longer calms the knob: the shake RAMPS through the
snap and goes wild at eleven (2.3× the 10-tremble), rattling right
through the fade-out bow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the move from 10 to 11 is a true SNAP — no travel, one frame
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: longer tremble at 10 before the snap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: photo prompts mention drag-and-drop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: tutorial's reminder example reads naturally (/remind me to call mum…)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: stage dust — particles on the loader floor that dance to the volume
A canvas strip along the bottom of the boot cover: ~60–110 little balls
(muted ink, a few accent) resting on the floor. The same loudness level
that shakes the knob kicks them — still at 1, hopping by 7, full popcorn
at the cranked eleven, and still airborne through the bow. Gravity,
floor bounce with level-scaled restitution, wrap at the edges. Skipped
entirely under prefers-reduced-motion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: heavier dust — sharp kicks, real gravity, crisp bounces (no floating)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: only scroll intent cancels the feed glide (touchmove, not touchstart)
Tapping an action chip is a touchstart inside the feed — on touch
devices every chip tap killed the in-flight glide, so the next bubble
or typing row landed below the fold (reported after 'I'm X').
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: friendlier try-another-photo prompts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: the join claim is single-fire — a double tap can't burn its own invite
The welcome card's first tap could feel dead (pre-hydration), inviting a
second tap: the first claim succeeded silently while the duplicate hit
'already used' and THAT error is all the person saw — a working session
painted over as a spent link (reproduced from the canary DB: claim 19s
after mint, session minted, user staring at the error). The Continue
button now disables on first press and onContinue can only run once.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web+server: fixes from the high-effort branch review (10 confirmed)
Regressions / data loss / security:
- hideLoading only runs the amp-knob choreography when the dial was
actually shown — bootEmbed/showNoInvite/showA2HS blanked the cover for
~2.4s otherwise (a dialStarted guard, not just 'knob markup exists').
- uploadAvatar caps alt text (280 chars) and CHECKS the /api/avatar
response — a long photo caption over the 4096-char blob cap 400'd and
the client showed the photo as kept while nothing saved.
- The 'joined' broadcast moved from claim time to setPubkey (first key
only): the inviter's pending card now clears because the joiner is
actually keyed when it fires; firing at claim raced the pubkey POST.
- makeSay emits ONCE it confirms the frame sent (transport returns true)
instead of re-emitting on a slow echo — no more duplicated Eleven
lines permanently in the transcript.
- The instance-key nudge handler no longer wraps to a public key handed
to it in a raw WS frame: it reloads the roster and wraps only to the
server-published key for that claimed member (a forged nudge can't
harvest names/photos; the inviter shares the DM so grants still flow).
UX correctness:
- The photo step distinguishes 'still waiting on the space key' from a
bad photo, so a not-yet-keyed joiner isn't sent into a futile retry.
- Re-prompts at the photo step are client-only notices, so a second
text-only reply isn't swallowed by say-dedupe.
- A failed claim clears lchat_welcome_pending, so a stale marker can't
hijack a later add-device sign-in into fresh-joiner onboarding.
- feedGlideToBottom bails when the user scrolls up against it (scrollbar
drag / keyboard), not only on wheel/touchmove.
Verified: full go+node+js test suites, a deterministic two-party harness
(joined-after-pubkey, roster-validated grant, joiner sees the admin's
real name), and a clean browser boot of the merged tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
16 files changed,
+1469
−207
agent/agent.mjs+5 −2changelog/2026-07-07-welcome-conversation.md+8 −0db.go+22 −7main.go+60 −4roles_test.go+80 −0test/apps.test.mjs+14 −0web/pages/index.html+13 −1web/static/apps.js+47 −18web/static/apps/chat.js+98 −11web/static/apps/eleven.js+119 −0web/static/apps/setup.js+72 −75web/static/apps/tutorial.js+25 −8web/static/apps/welcome.js+301 −0web/static/join.js+87 −20web/static/shell.js+413 −46web/static/style.css+105 −15
diff --git a/agent/agent.mjs b/agent/agent.mjs| index 1ba7061..a88c093 100644 |
| --- a/agent/agent.mjs |
| +++ b/agent/agent.mjs |
| @@ -18,7 +18,7 @@ import { provision } from "./provision.mjs"; |
| import { runBot } from "./runtime.mjs"; |
| import { startAnnouncer } from "./announcer.mjs"; |
| import { CHIP_TOOLS } from "./actions.mjs"; |
| -import { ELEVEN_NAME, ELEVEN_PERSONA, ELEVEN_DOCS, loadElevenSession, startWelcomer } from "./eleven.mjs"; |
| +import { ELEVEN_NAME, ELEVEN_PERSONA, ELEVEN_DOCS, loadElevenSession } from "./eleven.mjs"; |
| // Synchronous logging: async stdout to a pipe is buffered and lost if the |
| // container is killed (e.g. a failed health check), so write key lines with |
| @@ -89,7 +89,10 @@ if (elevenSession) { |
| tools: CHIP_TOOLS, |
| commands: true, |
| }); |
| - startWelcomer(); |
| + // The welcomer is STOOD DOWN: the client-side welcome conversation |
| + // (web/static/apps/welcome.js) greets every joiner now — one voice, not two |
| + // Elevens saying hello. startWelcomer() in eleven.mjs stays for standalone |
| + // runs; re-wire it here only if the client welcome ever retires. |
| } |
| // --- Announcer: write-only deploy notes ------------------------------------- |
diff --git a/changelog/2026-07-07-welcome-conversation.md b/changelog/2026-07-07-welcome-conversation.md| new file mode 100644 |
| index 0000000..51f919a |
| --- /dev/null |
| +++ b/changelog/2026-07-07-welcome-conversation.md |
| @@ -0,0 +1,8 @@ |
| +🤖 Joining a space is now a conversation too. Open an invite link and you land |
| +straight in a chat with Eleven — no forms — who asks your name, offers to set |
| +your profile photo (send one like any picture, and a message with it becomes |
| +the photo's description), helps you save your profile behind a passkey so you |
| +can't lose access, and points you at whoever invited you. The message box even |
| +tells you what Eleven's waiting for — "Type your name" instead of "Message" — |
| +and buttons you've already used quietly grey out instead of pretending to |
| +still work. |
diff --git a/db.go b/db.go| index f8f1c20..1a1f357 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -351,6 +351,10 @@ CREATE TABLE IF NOT EXISTS custom_apps ( |
| // Bots keep their plaintext `label` (admin-authored); people move here so the |
| // server never holds a readable name. Set by the member who holds the key. |
| "ALTER TABLE users ADD COLUMN label_blob TEXT", |
| + // The profile photo's alt text, encrypted under the instance key like the |
| + // label — unlike the server photo's plaintext alt (branding), a member's |
| + // photo description is user content and never readable by the server. |
| + "ALTER TABLE users ADD COLUMN avatar_alt_blob TEXT", |
| // Group photo: an encrypted blob id (encrypted with the thread key). |
| "ALTER TABLE threads ADD COLUMN avatar_blob TEXT", |
| // User invites: the pending DM thread an invite bootstraps the joiner into |
| @@ -927,7 +931,13 @@ type RosterEntry struct { |
| LabelBlob string `json:"label_blob,omitempty"` |
| PublicKey string `json:"public_key"` |
| Avatar string `json:"avatar,omitempty"` |
| - IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person |
| + // The photo's alt text, encrypted under the instance key (client decrypts). |
| + AvatarAltBlob string `json:"avatar_alt_blob,omitempty"` |
| + // admin | moderator | member — lets clients say who runs the space (the |
| + // welcome conversation's "run by <admin>"). Server-known metadata, not |
| + // content; it already gates the admin API. |
| + Role string `json:"role,omitempty"` |
| + IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person |
| // One-line admin-authored blurb for agents (the Helpers catalog entry — |
| // doubles as the consent hint). Always "" for people. |
| Description string `json:"description,omitempty"` |
| @@ -1190,12 +1200,17 @@ func (db *DB) fullBotConfig(userID string) (FullBotConfig, bool, error) { |
| return c, err == nil, err |
| } |
| -func (db *DB) setAvatar(userID, blobID string) error { |
| - var b any |
| +// setAvatar sets the photo and its (encrypted) alt text together — a photo |
| +// change always re-states the alt, so a swap never keeps a stale description. |
| +func (db *DB) setAvatar(userID, blobID, altBlob string) error { |
| + var b, a any |
| if blobID != "" { |
| b = blobID |
| + if altBlob != "" { |
| + a = altBlob |
| + } |
| } |
| - _, err := db.sql.Exec("UPDATE users SET avatar_blob = ? WHERE id = ?", b, userID) |
| + _, err := db.sql.Exec("UPDATE users SET avatar_blob = ?, avatar_alt_blob = ? WHERE id = ?", b, a, userID) |
| return err |
| } |
| @@ -1204,7 +1219,7 @@ func (db *DB) setAvatar(userID, blobID string) error { |
| // they share a thread with). Kept for admin/back-end use and tests. |
| func (db *DB) roster() ([]RosterEntry, error) { |
| rows, err := db.sql.Query( |
| - `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai') |
| + `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`, |
| ) |
| @@ -1227,7 +1242,7 @@ func (db *DB) roster() ([]RosterEntry, error) { |
| // connections-only; only vetted, clearly-badged helpers are instance-visible. |
| func (db *DB) connections(userID string) ([]RosterEntry, error) { |
| rows, err := db.sql.Query(` |
| - SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai') |
| + SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.id = ? |
| OR u.id IN ( |
| @@ -1251,7 +1266,7 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) { |
| var e RosterEntry |
| var isAgent int |
| var kind string |
| - if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &isAgent, &e.Description, &kind); err != nil { |
| + if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &e.AvatarAltBlob, &e.Role, &isAgent, &e.Description, &kind); err != nil { |
| return nil, err |
| } |
| e.IsAgent = isAgent != 0 |
diff --git a/main.go b/main.go| index 3e4ffe8..cae86b1 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -189,6 +189,9 @@ func main() { |
| } |
| printAdminLink(*addr, tok) |
| } |
| + if err := applySkipSetup(db); err != nil { |
| + log.Fatalf("skip-setup defaults: %v", err) |
| + } |
| pages, err := loadPages() |
| if err != nil { |
| @@ -785,6 +788,7 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) { |
| // DIFFERENT key — that would orphan every thread wrapped to the old key (e.g. an |
| // admin "device link" that made a new device generate its own keypair). Adding a |
| // device ships the SAME keypair, so re-registering the identical key is fine. |
| + firstKey := s.db.userPublicKey(uid) == "" |
| if cur := s.db.userPublicKey(uid); cur != "" && cur != body.PublicKey { |
| httpError(w, http.StatusConflict, "public key already registered") |
| return |
| @@ -793,6 +797,13 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + // A fresh joiner is only NOW a keyed member — tell open tabs so the inviter's |
| + // pending-invite card swaps to the live DM (pendingInvitee needs a public key |
| + // to clear). Firing at claim time raced this POST and left the card stuck. |
| + if firstKey { |
| + out, _ := json.Marshal(map[string]any{"type": "joined", "user_id": uid}) |
| + s.hub.broadcast <- outbound{data: out} |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| @@ -1132,7 +1143,17 @@ func (s *Server) nudgeInstanceKey(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "not a member") |
| return |
| } |
| - out, _ := json.Marshal(map[string]any{"type": "instance_key_nudge"}) |
| + // Carry who's asking and their public key: a holder's own roster view |
| + // (connections) may not include the nudger at all — a fresh joiner shares |
| + // no thread with most members — so holders wrap to this key directly. |
| + msg := map[string]any{"type": "instance_key_nudge"} |
| + if uid, ok := s.db.deviceTokenUser(token); ok { |
| + if pub := s.db.userPublicKey(uid); pub != "" { |
| + msg["user_id"] = uid |
| + msg["public_key"] = pub |
| + } |
| + } |
| + out, _ := json.Marshal(msg) |
| s.hub.broadcast <- outbound{data: out} // members:nil → everyone |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| @@ -1172,6 +1193,9 @@ func (s *Server) postAvatar(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| Token string `json:"token"` |
| BlobID string `json:"blob_id"` |
| + // The photo's alt text, encrypted under the instance key by the client |
| + // (opaque here, like the label blob). |
| + AltBlob string `json:"alt_blob"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -1182,17 +1206,17 @@ func (s *Server) postAvatar(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| uid, ok := s.db.deviceTokenUser(body.Token) |
| - if !ok || len(body.BlobID) > 200 { |
| + if !ok || len(body.BlobID) > 200 || len(body.AltBlob) > 4096 { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
| - if err := s.db.setAvatar(uid, body.BlobID); err != nil { |
| + if err := s.db.setAvatar(uid, body.BlobID, body.AltBlob); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| // Tell every connected client to refresh this user's avatar (profile photos |
| // are instance-wide). members:nil → broadcast to everyone. |
| - out, _ := json.Marshal(map[string]any{"type": "avatar", "user_id": uid, "blob_id": body.BlobID}) |
| + out, _ := json.Marshal(map[string]any{"type": "avatar", "user_id": uid, "blob_id": body.BlobID, "alt_blob": body.AltBlob}) |
| s.hub.broadcast <- outbound{data: out} |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| @@ -2342,6 +2366,38 @@ func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name, "admin_name": adminName, "admin_email": adminEmail}) |
| } |
| +// applySkipSetup is a TEST/DEV convenience (LCHAT_SKIP_SETUP=1): mark the |
| +// first-run setup conversation complete and seed workable defaults, so a |
| +// scratch instance is usable the moment its setup link is claimed — the |
| +// claimer still becomes the admin, they just skip the conversation. Values an |
| +// admin can change are seeded only when unset, so restarts never clobber |
| +// in-test edits. Never set this on a real instance: it forfeits onboarding. |
| +func applySkipSetup(db *DB) error { |
| + if strings.TrimSpace(os.Getenv("LCHAT_SKIP_SETUP")) == "" { |
| + return nil |
| + } |
| + log.Printf("LCHAT_SKIP_SETUP set: first-run setup conversation disabled (test/dev)") |
| + if err := db.setSetupComplete(true); err != nil { |
| + return err |
| + } |
| + if !db.groupNameSet() { |
| + name := strings.TrimSpace(os.Getenv("LCHAT_GROUP_NAME")) |
| + if name == "" { |
| + name = "Test Space" |
| + } |
| + if err := db.setGroupName(name); err != nil { |
| + return err |
| + } |
| + } |
| + // Members can invite — the joiner flow is usually what a test instance is for. |
| + if _, ok, _ := db.getConfig("user_invites_enabled"); !ok { |
| + if err := db.setUsersCanInvite(true); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| // trimAltText caps the photo's alt text at a RUNE boundary — a plain byte slice |
| // mid-emoji would store invalid UTF-8 and render as U+FFFD on the join card. |
| func trimAltText(s string) string { |
diff --git a/roles_test.go b/roles_test.go| index 28197c0..40675ed 100644 |
| --- a/roles_test.go |
| +++ b/roles_test.go |
| @@ -156,6 +156,49 @@ func TestSetupCompleteBackfill(t *testing.T) { |
| } |
| } |
| +// LCHAT_SKIP_SETUP seeds a usable test instance: setup marked complete, a |
| +// group name and member invites defaulted — but only where nothing was set, |
| +// so restarts don't clobber in-test edits. |
| +func TestApplySkipSetup(t *testing.T) { |
| + db, err := openDB(t.TempDir() + "/skip.db") |
| + if err != nil { |
| + t.Fatalf("openDB: %v", err) |
| + } |
| + t.Setenv("LCHAT_SKIP_SETUP", "1") |
| + t.Setenv("LCHAT_GROUP_NAME", "Canary Nest") |
| + if err := applySkipSetup(db); err != nil { |
| + t.Fatalf("applySkipSetup: %v", err) |
| + } |
| + if !db.setupComplete() { |
| + t.Fatal("setup should be marked complete") |
| + } |
| + if got := db.getGroupName(); got != "Canary Nest" { |
| + t.Fatalf("group name: got %q", got) |
| + } |
| + if !db.usersCanInvite() { |
| + t.Fatal("member invites should default on") |
| + } |
| + // A restart must not clobber values changed during the test session. |
| + db.setGroupName("Renamed") |
| + db.setUsersCanInvite(false) |
| + if err := applySkipSetup(db); err != nil { |
| + t.Fatalf("re-apply: %v", err) |
| + } |
| + if got := db.getGroupName(); got != "Renamed" { |
| + t.Fatalf("restart clobbered the group name: %q", got) |
| + } |
| + if db.usersCanInvite() { |
| + t.Fatal("restart clobbered the invites toggle") |
| + } |
| + // Unset → no-op. |
| + t.Setenv("LCHAT_SKIP_SETUP", "") |
| + db2, _ := openDB(t.TempDir() + "/plain.db") |
| + applySkipSetup(db2) |
| + if db2.setupComplete() { |
| + t.Fatal("without the env var, setup must stay pending") |
| + } |
| +} |
| + |
| // /api/me tells a member their role and whether first-run setup is done — the |
| // client-side gate for the setup conversation. |
| func TestMeExposesRoleAndSetupState(t *testing.T) { |
| @@ -183,3 +226,40 @@ func TestMeExposesRoleAndSetupState(t *testing.T) { |
| t.Fatalf("want setup_complete true after set") |
| } |
| } |
| + |
| +// A profile photo's alt text rides as an opaque encrypted blob, set with the |
| +// photo and cleared with it — the server never sees a readable description. |
| +func TestAvatarAltBlob(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + uid, _ := srv.db.createUser("") |
| + srv.db.createKeyInvite("av", uid, "", testClaimHash, 0) |
| + tok, _, _ := srv.db.claimKeyInvite("av", "", "", testClaimSecret) |
| + srv.db.sql.Exec("UPDATE users SET public_key = 'pk' WHERE id = ?", uid) // roster lists joined (keyed) members only |
| + |
| + body := `{"token":"` + tok + `","blob_id":"blob1","alt_blob":"enc-alt"}` |
| + resp, err := http.Post(ts.URL+"/api/avatar", "application/json", strings.NewReader(body)) |
| + if err != nil || resp.StatusCode != 200 { |
| + t.Fatalf("post avatar: %v %d", err, resp.StatusCode) |
| + } |
| + roster, _ := srv.db.roster() |
| + var e *RosterEntry |
| + for i := range roster { |
| + if roster[i].UserID == uid { |
| + e = &roster[i] |
| + } |
| + } |
| + if e == nil || e.Avatar != "blob1" || e.AvatarAltBlob != "enc-alt" { |
| + t.Fatalf("roster entry: %+v", e) |
| + } |
| + |
| + // Removing the photo removes its description with it. |
| + if err := srv.db.setAvatar(uid, "", ""); err != nil { |
| + t.Fatalf("clear: %v", err) |
| + } |
| + roster, _ = srv.db.roster() |
| + for i := range roster { |
| + if roster[i].UserID == uid && (roster[i].Avatar != "" || roster[i].AvatarAltBlob != "") { |
| + t.Fatalf("clear left residue: %+v", roster[i]) |
| + } |
| + } |
| +} |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index faf4066..eeef063 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -48,6 +48,7 @@ import { app as bookmarksApp } from "../web/static/apps/bookmarks.js"; // regist |
| import { app as boardApp, sanitizePoints } from "../web/static/apps/board.js"; |
| import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // registers the DNSimple companion app |
| import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app |
| +import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app |
| import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin) |
| import { app as chatApp, viewItems } from "../web/static/apps/chat.js"; // the chat app |
| @@ -1163,6 +1164,19 @@ await test("admin: system-scope takeover, badged as an app-persona's opposite", |
| assert.equal(setupApp.senderKind, "bot", "the setup thread presents as Eleven (BOT), not APP"); |
| }); |
| +await test("welcome: the joiner conversation mirrors setup's shape", () => { |
| + const r = welcomeApp.reduce; |
| + const s = r(undefined, { type: "welcome.step", step: "name", label: "noted: you're Ada", value: "Ada" }); |
| + assert.equal(s.steps.name.done, true); |
| + assert.equal(r(s, { type: "welcome.step" }), s, "missing step → unchanged"); |
| + assert.equal(appWantsChatView("welcome"), true); |
| + assert.equal(appChatOnly("welcome"), true, "conversation-only, no Interface"); |
| + assert.equal(welcomeApp.senderKind, "bot", "presents as Eleven (BOT) in the sidebar"); |
| + assert.equal(welcomeApp.scope, "system", "never offered in the Apps menu"); |
| + const line = welcomeApp.chatMessage({ type: "welcome.step", step: "name", label: "noted: you're Ada" }); |
| + assert.deepEqual(line, { text: "noted: you're Ada", action: true }); |
| +}); |
| + |
| await test("conversational answers: one waiter per thread, consumed on delivery", () => { |
| const got = []; |
| awaitAnswer("t1", (text) => got.push(text)); |
diff --git a/web/pages/index.html b/web/pages/index.html| index ce1d4ad..3de64fc 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -55,7 +55,19 @@ |
| <!-- Full-screen cover shown until the app's first meaningful paint (theme bg is |
| already set pre-paint above), so a refresh doesn't flash an empty shell and |
| then jump as messages load + scroll. shell.js removes it once ready. --> |
| -<div id="app-loading" class="app-loading"><div class="spinner" aria-label="Loading"></div></div> |
| +<div id="app-loading" class="app-loading"> |
| + <!-- The amp knob: labelled 1–11 like the volume knob on an amplifier. The pie |
| + fills and the pointer turns towards 10 while loading; when the app is |
| + ready it goes to eleven, holds a beat, and glows. (Labels + motion are |
| + built by startLoadingDial in shell.js.) --> |
| + <!-- Dust on the stage: particles along the bottom that get bounced around as |
| + the volume climbs (see stepDust in shell.js). Decorative only. --> |
| + <canvas class="amp-dust" id="amp-dust" aria-hidden="true"></canvas> |
| + <div class="amp-knob" id="amp-knob" role="progressbar" aria-label="Loading"> |
| + <div class="amp-fill" id="amp-fill"></div> |
| + <div class="amp-body"><div class="amp-pointer" id="amp-pointer"></div></div> |
| + </div> |
| +</div> |
| <div id="toast" class="toast"></div> |
| <!-- The multi-thread shell: a thread list (master) + an open thread (detail). |
diff --git a/web/static/apps.js b/web/static/apps.js| index 0937237..4f19efe 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -951,33 +951,61 @@ function fillFullNotice(row, spec = {}, opts = {}) { |
| } |
| if (head.childElementCount) row.appendChild(head); |
| - const bubble = document.createElement("div"); |
| - bubble.className = "local-notice-bubble"; |
| - const body = document.createElement("div"); |
| - body.className = "local-notice-body"; |
| - renderNoticeBody(text || "", body); |
| - bubble.appendChild(body); |
| - // An optional preview image (spec.img). Only ever an IN-CODE same-origin URL |
| - // (e.g. the setup conversation previewing /api/server-avatar) — never user text. |
| - if (spec.img) { |
| - const img = document.createElement("img"); |
| - img.className = "local-notice-img"; |
| - img.src = spec.img; |
| - img.alt = spec.imgAlt || ""; |
| - bubble.appendChild(img); |
| + // A chips-only notice (a persisted bubble's live actions) has no bubble of |
| + // its own — the text it belongs to is already in the feed as real history. |
| + if (text || spec.img) { |
| + const bubble = document.createElement("div"); |
| + bubble.className = "local-notice-bubble"; |
| + const body = document.createElement("div"); |
| + body.className = "local-notice-body"; |
| + renderNoticeBody(text || "", body); |
| + bubble.appendChild(body); |
| + // An optional preview image (spec.img). Only ever an IN-CODE same-origin URL |
| + // (e.g. the setup conversation previewing /api/server-avatar) — never user text. |
| + if (spec.img) { |
| + const img = document.createElement("img"); |
| + img.className = "local-notice-img" + (spec.imgRound ? " local-notice-img--round" : ""); |
| + img.src = spec.img; |
| + img.alt = spec.imgAlt || ""; |
| + // The image arrives after the notice was pinned and grows the feed — |
| + // re-pin, or everything after it (the next typing row!) hides below the fold. |
| + img.onload = () => { |
| + const feed = img.closest(".chatapp-feed"); |
| + if (feed && feed.scrollHeight - feed.scrollTop - feed.clientHeight < 260) feed.scrollTop = feed.scrollHeight; |
| + }; |
| + bubble.appendChild(img); |
| + } |
| + row.appendChild(bubble); |
| } |
| - row.appendChild(bubble); |
| if (Array.isArray(actions) && actions.length) { |
| + if (!text && !spec.img) row.classList.add("local-notice--chips"); // hugs the bubble it belongs to |
| const chipRow = document.createElement("div"); |
| chipRow.className = "chip-row"; |
| + const paint = (btn, act) => { |
| + // A chip whose moment has passed (its step was answered or skipped — |
| + // a.stale() is the script's step guard) renders spent: disabled, and the |
| + // one that was actually tapped shows its past-tense label ("Skipped"). |
| + const stale = !!(act.stale && act.stale()); |
| + btn.className = "action-chip" + (stale ? " action-chip--spent" : ""); |
| + btn.textContent = stale && act.tapped ? act.done || act.label : act.label; |
| + btn.disabled = stale; |
| + }; |
| + const buttons = []; |
| for (const a of actions) { |
| if (!a || !a.label) continue; |
| const b = document.createElement("button"); |
| b.type = "button"; |
| - b.className = "action-chip"; |
| - b.textContent = a.label; |
| - b.onclick = () => runNoticeAction(a); |
| + paint(b, a); |
| + b.onclick = () => { |
| + if (a.stale && a.stale()) return; // raced a resolution — don't claim the tap did anything |
| + a.tapped = true; |
| + runNoticeAction(a); |
| + // An advancing tap resolves the step SYNCHRONOUSLY (go.taken flips in |
| + // run) — reflect it now, not at the next feed render. |
| + for (const [btn, act] of buttons) paint(btn, act); |
| + }; |
| + buttons.push([b, a]); |
| chipRow.appendChild(b); |
| } |
| row.appendChild(chipRow); |
| @@ -1010,6 +1038,7 @@ function fillCompactNotice(row, spec = {}) { |
| if (Array.isArray(actions)) { |
| for (const a of actions) { |
| if (!a || !a.label) continue; |
| + if (a.stale && a.stale()) continue; // spent chips add nothing to a compact one-liner |
| const b = document.createElement("button"); |
| b.type = "button"; |
| b.className = "lc-action"; |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 25fb3ba..afca96a 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -105,7 +105,82 @@ export function setNoticeTyping(threadId, name, on) { |
| // addLocalNotice records a command reply for `threadId` and re-renders the pane so |
| // it appears immediately. Anchored just above the current newest message so it |
| // sits where you typed it and never jumps around as the conversation continues. |
| +// The composer placeholder follows the conversation: a scripted question can |
| +// carry a `hint` ("Type your name") that replaces "Message" the moment the |
| +// question renders, or an `attachHint` ("Optional: describe your photo") that |
| +// applies only while attachments are staged — a photo question shouldn't |
| +// relabel the box before there's a photo. Sending resets to "Message" |
| +// immediately; the next question sets its own. Keyed like refreshFeed. |
| +const composerHints = new Map(); |
| +const composerAttachHints = new Map(); |
| +const refreshComposerHint = new Map(); |
| +function applyComposerHint(threadId) { |
| + const fn = refreshComposerHint.get(threadId); |
| + if (fn) fn(); |
| +} |
| +function clearComposerHint(threadId) { |
| + composerHints.set(threadId, ""); |
| + composerAttachHints.set(threadId, ""); |
| + applyComposerHint(threadId); |
| +} |
| + |
| +// Feed-follow with intent: feedGlideToBottom eases towards the (still |
| +// growing) bottom, and while it's in flight feedNearBottom stays true — an |
| +// instantaneous scrollTop read mid-glide looks "scrolled away" and used to make |
| +// the typing row skip its pin. A user wheel/touch cancels the chase. |
| +const feedGlides = new WeakSet(); |
| +const feedGlideCancels = new WeakSet(); |
| +export function feedNearBottom(feed, slack = 80) { |
| + return feedGlides.has(feed) || feed.scrollHeight - feed.scrollTop - feed.clientHeight < slack; |
| +} |
| +export function feedGlideToBottom(feed, jump) { |
| + if (!feedGlideCancels.has(feed)) { |
| + feedGlideCancels.add(feed); |
| + const cancel = () => feedGlides.delete(feed); |
| + // Cancel on SCROLL INTENT only — wheel, or an actual touch DRAG. A bare |
| + // touchstart is any tap in the feed (action chips included), and cancelling |
| + // on those broke the follow for every chip-tapped step on touch devices. |
| + feed.addEventListener("wheel", cancel, { passive: true }); |
| + feed.addEventListener("touchmove", cancel, { passive: true }); |
| + } |
| + if (jump || matchMedia("(prefers-reduced-motion: reduce)").matches) { |
| + feedGlides.delete(feed); |
| + feed.scrollTop = feed.scrollHeight; |
| + return; |
| + } |
| + if (feedGlides.has(feed)) return; // the running glide chases new growth itself |
| + feedGlides.add(feed); |
| + // Also detect intervention we DON'T get events for (scrollbar drag, PageUp, |
| + // arrow keys): if scrollTop is meaningfully below where we last placed it, the |
| + // user moved it — bail so we never fight them back down. |
| + let lastSet = feed.scrollTop; |
| + const step = () => { |
| + if (!feedGlides.has(feed)) return; // cancelled by the user (wheel/touch) |
| + if (feed.scrollTop < lastSet - 12) { |
| + feedGlides.delete(feed); |
| + return; |
| + } |
| + const target = feed.scrollHeight - feed.clientHeight; |
| + const d = target - feed.scrollTop; |
| + if (Math.abs(d) < 2) { |
| + feed.scrollTop = target; |
| + feedGlides.delete(feed); |
| + return; |
| + } |
| + feed.scrollTop += d * 0.16; |
| + lastSet = feed.scrollTop; |
| + requestAnimationFrame(step); |
| + }; |
| + requestAnimationFrame(step); |
| +} |
| + |
| function addLocalNotice(ctx, spec) { |
| + composerHints.set(ctx.threadId, spec.hint || ""); |
| + composerAttachHints.set(ctx.threadId, spec.attachHint || ""); |
| + applyComposerHint(ctx.threadId); |
| + // Hint-only (a persisted question with no chips): the placeholder was the |
| + // whole point — don't add an empty row to the feed. |
| + if (!spec.text && !spec.img && !(spec.actions && spec.actions.length)) return; |
| const msgs = (ctx.getState() && ctx.getState().msgs) || []; |
| const maxSid = msgs.length ? msgs[msgs.length - 1].sid : 0; |
| const list = localNotices.get(ctx.threadId) || []; |
| @@ -120,7 +195,10 @@ function addLocalNotice(ctx, spec) { |
| // A notice is always a response to something the reader just did (a command, |
| // an action chip, a scripted step) — snap to it like a local send, even if |
| // they'd scrolled up. |
| - pinNext.add(ctx.threadId); |
| + // A full notice snaps the feed like a local send (pinNext → instant); a |
| + // chips-only row rides the smooth glide of the bubble it belongs to — the |
| + // extra instant jump right after a glide is what reads as jank. |
| + if (spec.text || spec.img) pinNext.add(ctx.threadId); |
| const refresh = refreshFeed.get(ctx.threadId); |
| if (refresh) refresh(); |
| } |
| @@ -428,7 +506,7 @@ export const app = defineApp({ |
| // A local send arms pinNext: snap to the newest even if we'd scrolled up. |
| // Set.delete both reads and clears it, so it only forces this one render. |
| const forcePin = pinNext.delete(ctx.threadId); |
| - const wasAtBottom = forcePin || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60 || !feed.childElementCount; |
| + const wasAtBottom = forcePin || feedNearBottom(feed, 60) || !feed.childElementCount; |
| const msgs = (state && state.msgs) || []; |
| // A sender's message arriving means they've stopped typing — clear their |
| @@ -457,7 +535,11 @@ export const app = defineApp({ |
| for (const it of items) { |
| if (it.notice) { |
| it.cont = !!(prevNotice && prevNotice.sender && it.notice.sender && prevNotice.sender.name === it.notice.sender.name); |
| - it.vrev = `notice|${it.cont ? 1 : 0}|${it.solo ? 1 : 0}`; |
| + // Spent chips (their step resolved — a.stale()) fold into the vrev so |
| + // the keyed renderer rebuilds the row when a later render pass finds |
| + // them newly stale; without this a resolved chip stays looking live. |
| + const spent = (it.notice.actions || []).reduce((n, a) => n + (a && a.stale && a.stale() ? 1 : 0), 0); |
| + it.vrev = `notice|${it.cont ? 1 : 0}|${it.solo ? 1 : 0}|${spent}`; |
| prevNotice = it.notice; |
| } else { |
| prevNotice = null; |
| @@ -482,11 +564,7 @@ export const app = defineApp({ |
| // straight to the newest; a plain live append glides (the original ease), |
| // respecting reduced-motion. A send jumps rather than gliding the whole |
| // history so it lands instantly on your message, not after a long scroll. |
| - if (firstBuild || forcePin || matchMedia("(prefers-reduced-motion: reduce)").matches) { |
| - feed.scrollTop = feed.scrollHeight; |
| - } else { |
| - feed.scrollTo({ top: feed.scrollHeight, behavior: "smooth" }); |
| - } |
| + feedGlideToBottom(feed, firstBuild || forcePin); |
| } |
| // The in-feed typing row, exactly like the original feed: a .typing element |
| @@ -530,9 +608,9 @@ export const app = defineApp({ |
| row.textContent = label; |
| } |
| } |
| - const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| + const atBottom = feedNearBottom(feed); |
| feed.appendChild(row); // (re-)appending keeps it below the newest message |
| - if (atBottom) feed.scrollTop = feed.scrollHeight; |
| + if (atBottom) feedGlideToBottom(feed); |
| }; |
| refreshTyping.set(ctx.threadId, drawTyping); |
| drawTyping(); |
| @@ -2195,7 +2273,11 @@ function buildSkeleton(el, ctx) { |
| const field = document.createElement("div"); |
| field.className = "composer-field"; |
| const input = document.createElement("textarea"); |
| - input.placeholder = "Message"; |
| + input.placeholder = composerHints.get(ctx.threadId) || "Message"; |
| + refreshComposerHint.set(ctx.threadId, () => { |
| + const attach = pending.length ? composerAttachHints.get(ctx.threadId) : ""; |
| + input.placeholder = attach || composerHints.get(ctx.threadId) || "Message"; |
| + }); |
| input.rows = 1; |
| input.autocomplete = "off"; |
| const send = document.createElement("button"); |
| @@ -2312,6 +2394,10 @@ function buildSkeleton(el, ctx) { |
| // uploading, then a thumbnail (image) or a file chip, each with its remove. |
| const refreshStaged = () => { |
| if (inEmbed) host.native({ staged: pending.length > 0 }); // enables the native send button |
| + applyComposerHint(ctx.threadId); // an attach-gated hint appears/disappears with the staged files |
| + // The strip takes real layout space, so the feed shrinks when it appears — |
| + // keep the newest messages (and any action chips) in view, not under the fold. |
| + if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true)); |
| preview.textContent = ""; |
| preview.classList.toggle("hidden", pending.length === 0); |
| for (const p of pending) { |
| @@ -2815,6 +2901,7 @@ function buildSkeleton(el, ctx) { |
| input.style.height = "auto"; |
| refreshSend(); |
| stopTypingBeat(); // command / edit / send all clear the field — end the beat now |
| + clearComposerHint(ctx.threadId); // the question was answered — back to "Message" now, not at the reply |
| }; |
| // /me is the one command that BROADCASTS a real message — an action-style |
| // line ("Alice waves"), not a bubble — instead of answering privately, so |
diff --git a/web/static/apps/eleven.js b/web/static/apps/eleven.js| new file mode 100644 |
| index 0000000..2b99de8 |
| --- /dev/null |
| +++ b/web/static/apps/eleven.js |
| @@ -0,0 +1,119 @@ |
| +// Shared machinery for Eleven's scripted conversations (setup, welcome): the |
| +// paced reply queue, the per-step tap guards, and PERSISTENT lines — each new |
| +// line Eleven says is emitted as an app event (encrypted and stored like any |
| +// thread event) and folds into the chat view as a real bubble, so the |
| +// conversation survives reloads as history instead of vanishing. Only the |
| +// action chips and composer hints ride a client-only notice (they need the |
| +// running script's closures); on a resumed run a line already in history |
| +// re-arms its chips instantly, with no typing beat and no duplicate bubble. |
| +import { emitAppEvent, appStateFor, awaitAnswer } from "../apps.js"; |
| + |
| +function sleep(ms) { |
| + return new Promise((r) => setTimeout(r, ms)); |
| +} |
| + |
| +// The per-step tap guard: first advancing tap (or typed answer) wins. `taken` |
| +// is readable so a step's chips can render themselves spent once it resolves. |
| +export function onceStep() { |
| + const go = (fn) => { |
| + if (go.taken) return; |
| + go.taken = true; |
| + fn(); |
| + }; |
| + go.taken = false; |
| + return go; |
| +} |
| + |
| +// An advancing chip: consumes the step; `done` is its past-tense label once |
| +// tapped ("Skipped"), and stale ties its liveness to the step guard. |
| +export const advance = (go, label, fn, done) => ({ label, done, stale: () => go.taken, run: () => go(fn) }); |
| + |
| +// Ask a typed question: the next composer message in this thread is the answer. |
| +export function ask(env, go, onText) { |
| + awaitAnswer(env.threadId, (text) => go(() => onText(text.trim()))); |
| +} |
| + |
| +// makeSay(appName, sayType): build the app's say(env, text, actions, extra). |
| +// Replies play through a queue with a "thinking" beat, then typing scaled to |
| +// the reply's length (with jitter, first message quickest). Each line is |
| +// persisted via a `sayType` event keyed by its text; the app's reducer records |
| +// the key in state.said and its chatMessage projects the text as a bubble. |
| +// extra: { hint, attachHint } ride the chips notice (composer placeholder); |
| +// { local: true } keeps a line client-only (e.g. an image preview). |
| +export function makeSay(appName, sayType) { |
| + let queue = Promise.resolve(); |
| + let first = true; |
| + const said = (env, key) => { |
| + const st = appStateFor(env.threadId, appName); |
| + return !!(st && st.said && st.said[key]); |
| + }; |
| + return function say(env, text, actions, extra) { |
| + const opener = first; |
| + first = false; |
| + const opts = extra || {}; |
| + // Steps that fold an ack into their question ("Lovely to meet you! …") |
| + // pass a stable key, so a resumed run recognises the question even though |
| + // its ack prefix differs — otherwise the variant re-emits as a duplicate. |
| + const key = opts.key || text; |
| + queue = queue.then(async () => { |
| + // The beat turns the indicator ON and holds it a readable moment (≥1s, |
| + // scaling with reply length) — but does NOT turn it off: the caller drops |
| + // it only once the bubble is actually in the feed, so the list never |
| + // collapses and then re-grows between indicator and message. |
| + const beat = async () => { |
| + if (!env.typing) return; |
| + await sleep(opener ? 150 : 300 + Math.random() * 250); |
| + env.typing(true); |
| + const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4); |
| + await sleep(Math.max(1000, ms * (0.85 + Math.random() * 0.3))); |
| + }; |
| + const beatOff = () => env.typing && env.typing(false); |
| + if (opts.local) { |
| + await beat(); |
| + beatOff(); |
| + env.respond({ sender: env.sender, text, actions, ...opts }); |
| + return; |
| + } |
| + if (!said(env, key)) { |
| + await beat(); |
| + // Emit ONCE it actually sends. The transport returns false while the |
| + // socket is still connecting (the boot-time race the first line hits), |
| + // so retry only until it reports the frame went out — never re-emit a |
| + // frame that already sent, or a slow echo would persist the line twice. |
| + let sent = false; |
| + for (let attempt = 0; attempt < 12 && !sent && !said(env, key); attempt++) { |
| + sent = await emitAppEvent(env.threadId, { type: sayType, key, text, ts: Date.now() }); |
| + if (!sent) await sleep(150); |
| + } |
| + // Then wait (passively) for the echo to fold into the transcript so the |
| + // chips row below renders under the question it belongs to. |
| + for (let i = 0; i < 30 && !said(env, key); i++) await sleep(100); |
| + beatOff(); // the bubble is in the feed (or we're falling back) — indicator off AFTER it lands |
| + if (!said(env, key)) { |
| + // Persistence failed outright (offline?) — the member still sees the |
| + // line, as a client-only notice; a later run re-says it for keeps. |
| + env.respond({ sender: env.sender, text, actions, ...opts }); |
| + return; |
| + } |
| + } |
| + if ((actions && actions.length) || opts.hint || opts.attachHint) { |
| + env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint }); |
| + } |
| + }); |
| + return queue; |
| + }; |
| +} |
| + |
| +// The reducer entry for a say event and its chat-view projection — one line |
| +// each in the app's spec: `...sayEvents("welcome.say")` under `events`, and |
| +// `const line = sayBubble(e, "welcome.say"); if (line) return line;` first in |
| +// chatMessage. |
| +export function sayEvents(sayType) { |
| + return { |
| + [sayType]: (s, e) => (e.key ? { ...s, said: { ...(s.said || {}), [e.key]: true } } : s), |
| + }; |
| +} |
| +export function sayBubble(e, sayType) { |
| + if (e.type === sayType && e.text) return { text: e.text }; |
| + return null; |
| +} |
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js| index b4ff709..5278be3 100644 |
| --- a/web/static/apps/setup.js |
| +++ b/web/static/apps/setup.js |
| @@ -13,30 +13,36 @@ |
| // events make completed steps part of the encrypted transcript (the ✓ action |
| // lines you see after a reload), and whose Interface view is a progress card. |
| import { defineApp, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js"; |
| +import { onceStep, advance, ask, makeSay, sayEvents, sayBubble } from "./eleven.js"; |
| import { startTour } from "./tutorial.js"; |
| export const app = defineApp({ |
| name: "setup", |
| title: "Eleven", |
| glyph: "🕚", |
| + avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph |
| // The sidebar badges this thread BOT, not APP — it presents as a conversation |
| // with Eleven, and the app machinery underneath is an implementation detail. |
| senderKind: "bot", |
| + chatIntro: "This is the start of your chat with Eleven.", |
| // Chat-only: the conversation IS the thread — no Interface view, no toggle. |
| // (The old setup progress card was superseded by the Admin panel.) |
| chatOnly: true, |
| // System scope: never offered in the Apps menu — the host mounts it for the |
| // admin on first run (maybeStartSetup in shell.js), and only for them. |
| scope: "system", |
| - initialState: () => ({ steps: {} }), |
| + initialState: () => ({ steps: {}, said: {} }), |
| events: { |
| // A completed step. `label` is the human line the chat view shows; `value` |
| // (optional) remembers the answer, for steps that resume on it. |
| - "setup.step": (s, e) => (e.step ? { steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s), |
| + "setup.step": (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s), |
| + // Eleven's own lines persist too (via makeSay), so the conversation is |
| + // real history after a reload, not just the ✓ action lines. |
| + ...sayEvents("setup.say"), |
| }, |
| - // Completed steps project into the chat view as /me-style action lines, so the |
| - // transcript survives reloads even though the questions (local notices) don't. |
| chatMessage(e) { |
| + const line = sayBubble(e, "setup.say"); |
| + if (line) return line; |
| if (e.type !== "setup.step" || !e.label) return null; |
| return { text: e.label, action: true }; |
| }, |
| @@ -63,7 +69,7 @@ export function startSetup(env) { |
| // A resumed run (reload, or the round-trip to the passkey page) gets a brief |
| // re-greeting instead of the full hello; the steps then skip what's done. |
| const resumed = !!bridge.myName(); |
| - if (resumed) say(env, "Welcome back — let's pick up where we left off."); |
| + if (resumed) say(env, "Welcome back — let's pick up where we left off.", null, { local: true }); |
| spaceInfo() |
| .then((info) => { |
| env.space = info || {}; |
| @@ -74,49 +80,10 @@ export function startSetup(env) { |
| .finally(() => stepName(env, !resumed)); |
| } |
| -// Replies play through a queue with a brief "Eleven is typing…" beat first — |
| -// instant answers read as canned, a beat reads as conversation. The beat scales |
| -// with the reply's length (capped) and the host provides the indicator hook. |
| -function sleep(ms) { |
| - return new Promise((r) => setTimeout(r, ms)); |
| -} |
| -let sayQueue = Promise.resolve(); |
| -let firstSay = true; // the opener greets faster — you just arrived, Eleven was expecting you |
| -function say(env, text, actions, extra) { |
| - const opener = firstSay; |
| - firstSay = false; |
| - sayQueue = sayQueue.then(async () => { |
| - if (env.typing) { |
| - // A beat of "thinking" before the indicator, then typing scaled to the |
| - // reply's length, with a little jitter — quick enough not to drag, uneven |
| - // enough to read as natural rather than mechanical. |
| - await sleep(opener ? 150 : 300 + Math.random() * 250); |
| - env.typing(true); |
| - const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4); |
| - await sleep(ms * (0.85 + Math.random() * 0.3)); |
| - env.typing(false); |
| - } |
| - env.respond({ sender: env.sender, text, actions, ...(extra || {}) }); |
| - }); |
| - return sayQueue; |
| -} |
| - |
| -// The same per-step tap guard as the tour: first advancing tap wins. |
| -function onceStep() { |
| - let done = false; |
| - return (fn) => { |
| - if (done) return; |
| - done = true; |
| - fn(); |
| - }; |
| -} |
| -const advance = (go, label, fn) => ({ label, run: () => go(fn) }); |
| - |
| -// Ask a typed question: the next composer message in this thread is the answer. |
| -// Chips stay live alongside (Keep / Skip), and whichever wins cancels the other. |
| -function ask(env, go, onText) { |
| - awaitAnswer(env.threadId, (text) => go(() => onText(text.trim()))); |
| -} |
| +// The paced reply queue, tap guards, and typed-answer helper are shared with |
| +// the welcome conversation (eleven.js). say() PERSISTS each line as a |
| +// setup.say event, so the conversation reads back as real history. |
| +const say = makeSay("setup", "setup.say"); |
| // ---- steps ------------------------------------------------------------------ |
| @@ -133,7 +100,7 @@ function stepName(env, greet) { |
| "Let's take two minutes to set things up.", |
| ); |
| } |
| - say(env, "First — what should everyone here call you? Reply with your name."); |
| + say(env, "First — what should everyone here call you? Reply with your name.", undefined, { hint: "Type your name" }); |
| ask(env, go, async (name) => { |
| if (!name) return stepName(env, false); |
| name = name.slice(0, 32); // the same cap the profile rename applies |
| @@ -153,16 +120,19 @@ function stepGroup(env, ack) { |
| // re-run) — a fresh instance's compiled-in default is not worth keeping. |
| const current = env.space && env.space.nameSet ? env.space.name || "" : ""; |
| const chips = []; |
| - if (current) chips.push(advance(go, `Keep “${current}”`, () => stepEmail(env))); |
| + if (current) chips.push(advance(go, `Keep “${current}”`, () => stepEmail(env), "Kept")); |
| say( |
| env, |
| (ack ? ack + "\n\n" : "") + |
| "Next, this space needs a name. It's the one everyone sees — on the sign-in page, in invites, on the app icon.", |
| + undefined, |
| + { key: "q-group" }, |
| ); |
| say( |
| env, |
| current ? `It's currently “${current}” — keep that, or reply with a new name.` : "What should we call it? Reply with a name.", |
| chips.length ? chips : undefined, |
| + { hint: "Type a name for this space" }, |
| ); |
| ask(env, go, async (name) => { |
| if (!name) return stepGroup(env); |
| @@ -194,7 +164,7 @@ function stepEmail(env) { |
| // question again. |
| function askEmail(env, text) { |
| const go = onceStep(); |
| - say(env, text, [advance(go, "Skip for now", () => stepPhoto(env))]); |
| + say(env, text, [advance(go, "Skip for now", () => stepPhoto(env), "Skipped")], { hint: "Type a contact email" }); |
| ask(env, go, async (email) => { |
| const result = await setupBridge().setContactEmail(email); |
| if (result !== "ok") { |
| @@ -213,7 +183,7 @@ function askEmail(env, text) { |
| function stepPhoto(env) { |
| if (env.done("photo")) return stepInvites(env); |
| say(env, "Want to give this space a face? The photo shows on the welcome page and when people join — you can always change it later."); |
| - askPhoto(env, "Send me a photo — tap the + next to the message box and hit send. Type a message with it if you want to include alt text."); |
| + askPhoto(env, "Send me a photo — tap the + next to the message box, or just drag a picture into the chat, and hit send. Type a message with it if you want to include alt text."); |
| } |
| // The photo arrives THROUGH THE CHAT, like sending any picture: the next |
| @@ -223,17 +193,27 @@ function askPhoto(env, prompt) { |
| const go = onceStep(); |
| // Skipping must disarm the photo waiter, or the next thing typed (steps from |
| // here on are chip-only) would still be treated as a photo attempt. |
| - say(env, prompt, [ |
| - advance(go, "Skip for now", () => { |
| - cancelAnswer(env.threadId); |
| - stepInvites(env); |
| - }), |
| - ]); |
| + say( |
| + env, |
| + prompt, |
| + [ |
| + advance( |
| + go, |
| + "Skip for now", |
| + () => { |
| + cancelAnswer(env.threadId); |
| + stepInvites(env); |
| + }, |
| + "Skipped", |
| + ), |
| + ], |
| + { attachHint: "Optional: type alt text for the photo" }, |
| + ); |
| const arm = () => |
| awaitAnswer(env.threadId, (text, payload) => { |
| const img = ((payload && payload.attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/")); |
| if (!img) { |
| - say(env, "Send it as a picture — tap the + next to the message box — or tap Skip."); |
| + say(env, "Send it as a picture — tap the + next to the message box or drag one in — or tap Skip."); |
| return arm(); |
| } |
| // The adopt runs INSIDE the step guard: if Skip won the race, the sent |
| @@ -269,13 +249,18 @@ function stepPhotoPreview(env) { |
| : "") + |
| "\n\nHappy with it, or want to try another?", |
| [ |
| - advance(go, "Keep this photo", async () => { |
| - await env.record("photo", "set the space photo", env.photoAlt || ""); |
| - stepInvites(env); |
| - }), |
| - advance(go, "Use a different photo", () => askPhoto(env, "Send me another photo, then — with a message for its alt text, if you like.")), |
| + advance( |
| + go, |
| + "Keep this photo", |
| + async () => { |
| + await env.record("photo", "set the space photo", env.photoAlt || ""); |
| + stepInvites(env); |
| + }, |
| + "Kept", |
| + ), |
| + advance(go, "Use a different photo", () => askPhoto(env, "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like alt text. 📷")), |
| ], |
| - { img: "/api/server-avatar?t=" + Date.now(), imgAlt: env.photoAlt || "The space photo" }, |
| + { img: "/api/server-avatar?t=" + Date.now(), imgAlt: env.photoAlt || "The space photo", local: true }, |
| ); |
| } |
| @@ -322,6 +307,7 @@ function stepKeys(env) { |
| [ |
| { |
| label: "Save a passkey (recommended)", |
| + stale: () => go.taken, |
| run: () => { |
| // On the web this navigates away and setup RESUMES here on return. |
| // In the native app the ceremony opens in the system browser and this |
| @@ -333,7 +319,7 @@ function stepKeys(env) { |
| }, 1500); |
| }, |
| }, |
| - advance(go, "Not now", () => stepKeysConfirm(env)), |
| + advance(go, "Not now", () => stepKeysConfirm(env), "Skipped"), |
| ], |
| ); |
| } |
| @@ -352,10 +338,15 @@ function stepKeysWaiting(env) { |
| await env.record("keys", label, "passkey"); |
| stepFinish(env, true); |
| }), |
| - advance(go, "It didn't work — skip for now", async () => { |
| - await env.record("keys", "Skipped the credentials backup for now", "skipped"); |
| - stepFinish(env, false); |
| - }), |
| + advance( |
| + go, |
| + "It didn't work — skip for now", |
| + async () => { |
| + await env.record("keys", "Skipped the credentials backup for now", "skipped"); |
| + stepFinish(env, false); |
| + }, |
| + "Skipped", |
| + ), |
| ]); |
| } |
| @@ -367,6 +358,7 @@ function stepKeysConfirm(env) { |
| [ |
| { |
| label: "Go back and save it", |
| + stale: () => go.taken, |
| run: () => { |
| setupBridge().savePasskey(); |
| setTimeout(() => { |
| @@ -374,10 +366,15 @@ function stepKeysConfirm(env) { |
| }, 1500); |
| }, |
| }, |
| - advance(go, "I understand the risk", async () => { |
| - await env.record("keys", "Skipped the credentials backup for now", "skipped"); |
| - stepFinish(env, false); |
| - }), |
| + advance( |
| + go, |
| + "I understand the risk", |
| + async () => { |
| + await env.record("keys", "Skipped the credentials backup for now", "skipped"); |
| + stepFinish(env, false); |
| + }, |
| + "Understood", |
| + ), |
| ], |
| ); |
| } |
| @@ -397,7 +394,7 @@ async function stepFinish(env, backedUp) { |
| ); |
| say(env, "Where would you like to start?", [ |
| { label: "Open admin", run: () => openAppThread("admin") }, |
| - { label: "Take the tour", run: () => startTour(env.respond) }, |
| + { label: "Take the tour", run: () => startTour(env.respond, env.typing) }, |
| ]); |
| running.delete(env.threadId); |
| } |
diff --git a/web/static/apps/tutorial.js b/web/static/apps/tutorial.js| index 97e9d8f..d7ef9f8 100644 |
| --- a/web/static/apps/tutorial.js |
| +++ b/web/static/apps/tutorial.js |
| @@ -24,10 +24,26 @@ registerFloorCommand("/tutorial", { |
| // startTour(respond): respond(spec) appends one Eleven notice to the current feed. |
| // Both entry points supply their own respond bound to the thread you're in. |
| -export function startTour(respond) { |
| +export function startTour(respond, typing) { |
| const bot = defaultBot(); |
| + // With a typing hook (the setup/welcome conversations pass theirs), each tour |
| + // reply gets a short beat first — same voice, same rhythm as Eleven's script. |
| + let q = Promise.resolve(); |
| + const respondWithBeat = !typing |
| + ? respond |
| + : (spec) => { |
| + q = q.then(async () => { |
| + if (spec && spec.text) { |
| + typing(true); |
| + await new Promise((r) => setTimeout(r, Math.min(1400, 550 + (spec.text.length || 0) * 3))); |
| + typing(false); |
| + } |
| + respond(spec); |
| + }); |
| + return q; |
| + }; |
| const env = { |
| - respond, |
| + respond: respondWithBeat, |
| // Speak as Eleven (with its AI/BOT badge) when it's a member here; otherwise |
| // still speak as "Eleven" the guide — the tour is a client feature, not a bot. |
| sender: bot || { name: "Eleven", kind: "bot" }, |
| @@ -48,17 +64,18 @@ export function startTour(respond) { |
| // notice (and its chips) are rebuilt on every feed refresh, so without this a |
| // second tap on a stale "Next" would append the same step twice. |
| function onceStep() { |
| - let done = false; |
| - return (fn) => { |
| - if (done) return; |
| - done = true; |
| + const go = (fn) => { |
| + if (go.taken) return; |
| + go.taken = true; |
| fn(); |
| }; |
| + go.taken = false; |
| + return go; |
| } |
| // advance = a chip that moves the tour on (guarded); side = a chip that does |
| // something without advancing (open an app), so it stays tappable if you return. |
| -const advance = (go, label, fn) => ({ label, run: () => go(fn) }); |
| +const advance = (go, label, fn) => ({ label, stale: () => go.taken, run: () => go(fn) }); |
| const side = (label, fn) => ({ label, run: fn }); |
| function say(env, text, actions) { |
| @@ -123,7 +140,7 @@ function stepRemind(env) { |
| const go = onceStep(); |
| say( |
| env, |
| - "And Reminders — for nudging yourself later. Try typing `/remind call mum in 10 minutes` (or `/remind standup at 9:00`). " + |
| + "And Reminders — for nudging yourself later. Try typing `/remind me to call mum in 10 minutes` (or `/remind standup at 9:00`). " + |
| "It'll message you at the right moment, on every device — and even the reminder's text and time stay private to you.\n\n" + |
| "Same as before: give it a go, then pop back to this chat with me whenever you're ready, or tap “Next”.", |
| [side("Open Reminders", () => openAppThread("remind")), advance(go, "Next →", () => stepPrivacy(env))], |
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js| new file mode 100644 |
| index 0000000..55a00a5 |
| --- /dev/null |
| +++ b/web/static/apps/welcome.js |
| @@ -0,0 +1,301 @@ |
| +// Joiner onboarding — Eleven welcomes a NEW member the moment they arrive: |
| +// saving their access behind a passkey FIRST (so the round-trip back from the |
| +// ceremony lands mid-conversation with steps still to do, not on "all done"), |
| +// then name, profile photo, and an invitation to take the tour. Chat-first, |
| +// like the admin setup conversation (docs/conversational-onboarding.md §4): |
| +// claiming an invite lands straight in this conversation, no name form. |
| +// |
| +// Like setup.js this is a CLIENT-DRIVEN script in Eleven's voice — it must work |
| +// with no AI and no sidecar (the sidecar's welcome DM stood down in its |
| +// favour). The "welcome" app gives the conversation a home: a chat-only solo |
| +// thread whose events are the encrypted transcript — Eleven's lines persist as |
| +// real bubbles (welcome.say, via makeSay) and completed steps as action lines |
| +// (welcome.step) — and the resume points across reloads and the passkey |
| +// round-trip. It talks to the same host bridge as setup (setSetupBridge). |
| +import { defineApp, setupBridge, cancelAnswer, awaitAnswer, appStateFor, emitAppEvent, spaceInfo } from "../apps.js"; |
| +import { onceStep, advance, ask, makeSay, sayEvents, sayBubble } from "./eleven.js"; |
| +import { startTour } from "./tutorial.js"; |
| + |
| +export const app = defineApp({ |
| + name: "welcome", |
| + title: "Eleven", |
| + glyph: "🕚", |
| + avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph |
| + // Presents as a conversation with Eleven (BOT in the sidebar, chat-only |
| + // surface) — the app machinery underneath is an implementation detail. |
| + senderKind: "bot", |
| + chatIntro: "This is the start of your chat with Eleven.", |
| + chatOnly: true, |
| + // System scope: never offered in the Apps menu — the host mounts it for a |
| + // freshly-joined member (maybeStartWelcome in shell.js), exactly once. |
| + scope: "system", |
| + initialState: () => ({ steps: {}, said: {} }), |
| + events: { |
| + "welcome.step": (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s), |
| + ...sayEvents("welcome.say"), |
| + }, |
| + chatMessage(e) { |
| + const line = sayBubble(e, "welcome.say"); |
| + if (line) return line; |
| + if (e.type !== "welcome.step" || !e.label) return null; |
| + return { text: e.label, action: true }; |
| + }, |
| +}); |
| + |
| +// ---- the conversation ------------------------------------------------------- |
| + |
| +const running = new Set(); |
| +const say = makeSay("welcome", "welcome.say"); |
| + |
| +// startWelcome(env): env = { threadId, respond, typing, suggested, inviterName, |
| +// openPendingThread } — the host supplies the notice sink, the typing beat, the |
| +// inviter's suggested name (from the invite fragment, never the server), who |
| +// invited them (decrypted roster label, "" if unknown), and a callback opening |
| +// the DM the invite created (null if none). |
| +export function startWelcome(env) { |
| + const bridge = setupBridge(); |
| + if (!bridge || running.has(env.threadId)) return; |
| + running.add(env.threadId); |
| + env.sender = { name: "Eleven", kind: "bot" }; |
| + env.done = (step) => { |
| + const st = appStateFor(env.threadId, "welcome"); |
| + return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| + }; |
| + env.record = (step, label, value) => emitAppEvent(env.threadId, { type: "welcome.step", step, label, value: value || "" }); |
| + // A resumed run (reload, or the round-trip back from the passkey page): the |
| + // history is real bubbles now, so a brief re-greeting is all that's needed — |
| + // the steps then skip past whatever's already done. The passkey RETURN gets |
| + // its own richer ack in stepAccess, so it skips the generic line here. |
| + const resumed = env.done("access") || env.done("name") || !!bridge.myName(); |
| + const passkeyReturn = bridge.enrolled() && !env.done("access"); |
| + if (resumed && !passkeyReturn) say(env, "Welcome back — let's pick up where we left off.", null, { local: true }); |
| + spaceInfo() |
| + .then((info) => { |
| + env.space = info || {}; |
| + }) |
| + .catch(() => { |
| + env.space = {}; |
| + }) |
| + .finally(() => stepAccess(env, !resumed)); |
| +} |
| + |
| +// ---- steps -------------------------------------------------------------------- |
| + |
| +// The passkey ceremony comes FIRST: it's the one thing that protects everything |
| +// after it, and the round-trip back from the passkey page re-enters the |
| +// conversation with the friendlier steps (name, photo) still ahead. |
| +function stepAccess(env, greet) { |
| + const bridge = setupBridge(); |
| + const space = (env.space && env.space.name) || "this space"; |
| + if (greet) { |
| + // Assume nothing: say what this place IS, who runs it, and what the |
| + // encryption means for them — in lock-and-key terms, not jargon. |
| + const admin = env.adminName || ""; |
| + const invited = env.inviterName ? `${env.inviterName} invited you to ${space}` : `You've been invited to ${space}`; |
| + const runBy = admin ? `run by ${admin}` : `run by one of its members`; |
| + say( |
| + env, |
| + `Welcome! 👋 I'm Eleven, your guide here.\n\n` + |
| + `${invited} — a private, invite-only chat space. It isn't a big platform with accounts and feeds: it's a small server ${runBy}, just for the people in it.\n\n` + |
| + `Everything here — messages, photos, even names — is locked with keys only members hold, so nobody outside a chat can ever read it. Not the server, and not whoever runs it.`, |
| + ); |
| + } |
| + if (bridge.enrolled() || env.done("access")) { |
| + if (!env.done("access")) { |
| + // Just back from the passkey ceremony: acknowledge it worked, then move |
| + // on — a separate bubble, so the ack lands before the next question. |
| + env.record("access", "Saved your access securely with a passkey", "passkey"); |
| + say(env, "Great — welcome back. 🔒 Now that your access is secure, let's continue."); |
| + } |
| + return stepName(env); |
| + } |
| + const go = onceStep(); |
| + // One bubble, chips attached — context and ask together, so the opening run |
| + // is two messages (intro + this), not three. |
| + say(env, "Before anything else, let's make sure you can't lose your way in. 🔑\n\n" + |
| + "Your access to this space lives in a key that exists only in this browser — there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\n" + |
| + "Shall we save it behind a passkey now? One tap — you'll be brought straight back and we'll carry on.", [ |
| + { |
| + label: "Save my access (recommended)", |
| + stale: () => go.taken, |
| + run: () => { |
| + bridge.savePasskey(); // web: navigates away; the welcome resumes on return |
| + setTimeout(() => { |
| + if (!setupBridge().enrolled()) go(() => stepAccessWaiting(env)); |
| + }, 1500); |
| + }, |
| + }, |
| + advance( |
| + go, |
| + "Not now", |
| + async () => { |
| + if (bridge.deferBackupReminders) bridge.deferBackupReminders(); |
| + await env.record("access", "Skipped the access backup for now", "skipped"); |
| + stepName(env, "No problem — I'll nudge you again in a day or so."); |
| + }, |
| + "Skipped", |
| + ), |
| + ]); |
| +} |
| + |
| +// The stayed-in-place passkey path (the ceremony opened elsewhere). |
| +function stepAccessWaiting(env) { |
| + const go = onceStep(); |
| + say(env, "I'll wait here while you finish saving the passkey. When you're done, tap Continue.", [ |
| + advance(go, "Continue", async () => { |
| + const label = setupBridge().enrolled() |
| + ? "Saved your access securely with a passkey" |
| + : "Saved your access with a passkey (in your browser)"; |
| + await env.record("access", label, "passkey"); |
| + stepName(env, "Perfect — that's saved. 🔒"); |
| + }), |
| + advance( |
| + go, |
| + "It didn't work — skip for now", |
| + async () => { |
| + const bridge = setupBridge(); |
| + if (bridge.deferBackupReminders) bridge.deferBackupReminders(); |
| + await env.record("access", "Skipped the access backup for now", "skipped"); |
| + stepName(env, "No problem — I'll nudge you again in a day or so."); |
| + }, |
| + "Skipped", |
| + ), |
| + ]); |
| +} |
| + |
| +function stepName(env, ack) { |
| + if (env.done("name") || setupBridge().myName()) return stepPhoto(env); |
| + const go = onceStep(); |
| + const chips = []; |
| + if (env.suggested) chips.push(advance(go, `I'm ${env.suggested}`, () => acceptName(env, env.suggested))); |
| + say(env, (ack ? ack + "\n\n" : "") + "First things first — what should folks here call you? Reply with your name.", chips.length ? chips : undefined, { |
| + hint: "Type your name", |
| + key: "q-name", |
| + }); |
| + ask(env, go, (name) => { |
| + if (!name) return stepName(env); |
| + acceptName(env, name); |
| + }); |
| +} |
| + |
| +async function acceptName(env, name) { |
| + name = name.slice(0, 32); // the same cap the profile rename applies |
| + await setupBridge().setMyName(name); |
| + await env.record("name", `noted: you're ${name}`, name); |
| + stepPhoto(env, `Lovely to meet you, ${name}! 🤝`); |
| +} |
| + |
| +function stepPhoto(env, ack) { |
| + if (env.done("photo")) return stepFinish(env); |
| + say( |
| + env, |
| + (ack ? ack + "\n\n" : "") + |
| + "Want to add a profile photo? Only members of this space can see it — like everything else here, it's encrypted.", |
| + undefined, |
| + { key: "q-photo" }, |
| + ); |
| + askPhoto(env, "Send me a photo — tap the + next to the message box, or just drag a picture into the chat, and hit send. If you type a message with it, I'll keep that as the photo's description. Or skip for now."); |
| +} |
| + |
| +// The photo arrives through the chat, like sending any picture. Unlike the |
| +// admin setup's SERVER photo (public branding), a profile photo stays inside |
| +// the encryption: the host re-encrypts it under the instance key. |
| +function askPhoto(env, prompt) { |
| + const go = onceStep(); |
| + say( |
| + env, |
| + prompt, |
| + [ |
| + advance( |
| + go, |
| + "Skip for now", |
| + () => { |
| + cancelAnswer(env.threadId); |
| + stepFinish(env, true); |
| + }, |
| + "Skipped", |
| + ), |
| + ], |
| + { attachHint: "Optional: describe your photo" }, |
| + ); |
| + const arm = () => |
| + awaitAnswer(env.threadId, (text, payload) => { |
| + const img = ((payload && payload.attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/")); |
| + if (!img) { |
| + say(env, "Send it as a picture — tap the + next to the message box or drag one in — or tap Skip.", null, { local: true }); |
| + return arm(); |
| + } |
| + go(async () => { |
| + // Any text sent with the photo becomes its description (alt text) — |
| + // encrypted like everything else here, unlike the admin setup's public |
| + // branding photo, so no consent warning is needed. |
| + const ok = await setupBridge().adoptProfilePhoto(env.threadId, img, (text || "").trim()); |
| + if (ok === "nokey") { |
| + // The instance key hasn't reached us yet — the photo is fine, we just |
| + // can't encrypt it. Say so honestly and let them try again shortly. |
| + say(env, "One sec — I'm still finishing your secure setup. Try sending it again in a moment, or skip for now.", null, { local: true }); |
| + return askPhoto(env, "Ready when you are — send that photo again, or skip it."); |
| + } |
| + if (!ok) { |
| + say(env, "Hmm, I couldn't use that one — try another?", null, { local: true }); |
| + return askPhoto(env, "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like a description. 📷"); |
| + } |
| + stepPhotoPreview(env); |
| + }); |
| + }); |
| + arm(); |
| +} |
| + |
| +// Show the adopted photo back and let them swap it until it's right — recorded |
| +// (and moved past) only on an explicit keep, like the admin setup's preview. |
| +// The preview is a client-only notice: the photo is an encrypted blob, so its |
| +// decrypted object URL can't ride a persisted event. |
| +async function stepPhotoPreview(env) { |
| + const go = onceStep(); |
| + let url = null; |
| + try { |
| + url = await setupBridge().myAvatarURL(); |
| + } catch {} |
| + say( |
| + env, |
| + "Here's how it looks. Happy with it, or want to try another?", |
| + [ |
| + advance( |
| + go, |
| + "Keep this photo", |
| + async () => { |
| + await env.record("photo", "set your profile photo"); |
| + stepFinish(env); |
| + }, |
| + "Kept", |
| + ), |
| + advance(go, "Use a different photo", () => askPhoto(env, "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like a description. 📷")), |
| + ], |
| + url ? { img: url, imgAlt: "Your profile photo", imgRound: true, local: true } : { local: true }, |
| + ); |
| +} |
| + |
| +async function stepFinish(env, skippedPhoto) { |
| + const bridge = setupBridge(); |
| + if (bridge.markWelcomeDone) bridge.markWelcomeDone(); |
| + const st = appStateFor(env.threadId, "welcome"); |
| + const backedUp = bridge.enrolled() || !!(st && st.steps && st.steps.access && st.steps.access.value === "passkey"); |
| + const space = (env.space && env.space.name) || "this space"; |
| + say( |
| + env, |
| + (skippedPhoto ? "No problem — you can add one anytime from your profile.\n\n" : "") + |
| + `That's it — you're all set. 🎉 Enjoy ${space}!` + |
| + (backedUp ? "" : "\n\nAnd do come back to that access backup soon."), |
| + undefined, |
| + { key: "q-finish" }, |
| + ); |
| + const chips = []; |
| + if (env.openPendingThread && env.inviterName) { |
| + chips.push({ label: `Say hi to ${env.inviterName}`, run: env.openPendingThread }); |
| + } else if (env.openPendingThread) { |
| + chips.push({ label: "Open your first chat", run: env.openPendingThread }); |
| + } |
| + chips.push({ label: "Take the tour", run: () => startTour(env.respond, env.typing) }); |
| + say(env, "Where would you like to start?", chips); |
| + running.delete(env.threadId); |
| +} |
diff --git a/web/static/join.js b/web/static/join.js| index ad0369a..ef3ea5b 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -26,6 +26,17 @@ const isStandalone = |
| const { T, name: fragName } = parseInviteFragment(location.hash.slice(1)); |
| +// Single-fire guard for the claim: whatever path calls it (button, double tap, |
| +// Enter), only the first attempt proceeds; the rest are ignored. |
| +let claimStarted = false; |
| +function onceOnly(fn) { |
| + return (...a) => { |
| + if (claimStarted) return; |
| + claimStarted = true; |
| + return fn(...a); |
| + }; |
| +} |
| + |
| init(); |
| function init() { |
| @@ -102,16 +113,27 @@ async function start() { |
| adoptExisting(id, payload.threadKey, existing); |
| }); |
| } |
| - // New person: pick a screen name (pre-filled with the inviter's suggestion), |
| - // generate a keypair, join into the DM. |
| - return showWelcome("join", () => |
| - showForm(fragName, async (name) => { |
| - showStatus("Setting up…"); |
| - const kp = await generateUserKeypair(); |
| - const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| - finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf); |
| - }), |
| - ); |
| + // New person: chat-first — claim nameless and let Eleven's welcome |
| + // conversation ask the name in-chat (the inviter's suggestion rides along |
| + // in the pending marker). The native add-chat webview keeps the form: it |
| + // hands a NAMED identity straight back to the shell. |
| + if (window.__lchatEmbed) { |
| + return showWelcome("join", () => |
| + showForm(fragName, async (name) => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| + finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf); |
| + }), |
| + ); |
| + } |
| + return showWelcome("join", async () => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| + markWelcomePending(fragName); |
| + finish(id, "", kp.publicKey, kp.privateKey, wrappedSelf); |
| + }); |
| } |
| // Setup invite (a fresh instance's first user, who becomes its admin): no name |
| @@ -125,15 +147,37 @@ async function start() { |
| }); |
| } |
| - // New person (or unknown payload shape): pick a screen name (pre-filled if the |
| - // inviter suggested one), then generate a fresh keypair. |
| - showWelcome("join", () => |
| - showForm(fragName, async (name) => { |
| - showStatus("Setting up…"); |
| - const kp = await generateUserKeypair(); |
| - finish(id, name, kp.publicKey, kp.privateKey); |
| - }), |
| - ); |
| + // New person (or unknown payload shape): same chat-first dance — the name is |
| + // Eleven's first question, not a form (native add-chat keeps its form). |
| + if (window.__lchatEmbed) { |
| + return showWelcome("join", () => |
| + showForm(fragName, async (name) => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + finish(id, name, kp.publicKey, kp.privateKey); |
| + }), |
| + ); |
| + } |
| + showWelcome("join", async () => { |
| + showStatus("Setting up…"); |
| + const kp = await generateUserKeypair(); |
| + markWelcomePending(fragName); |
| + finish(id, "", kp.publicKey, kp.privateKey); |
| + }); |
| +} |
| + |
| +// The pending marker hands the joiner to Eleven's welcome conversation on the |
| +// app's first load (shell.js maybeStartWelcome), carrying the inviter's |
| +// suggested name. Local-only — the name suggestion never touches the server. |
| +// A new invite pasted into an already-open join tab changes only the URL |
| +// fragment — a same-document navigation, so nothing re-runs and the old |
| +// card (or error) just sits there. Reload to process the new link. |
| +addEventListener("hashchange", () => location.reload()); |
| + |
| +function markWelcomePending(suggested) { |
| + try { |
| + localStorage.setItem("lchat_welcome_pending", JSON.stringify({ suggested: suggested || "" })); |
| + } catch {} |
| } |
| // The welcome card: the invite is inspected (a non-consuming GET) but nothing is |
| @@ -183,7 +227,12 @@ function showWelcome(kind, onContinue) { |
| const storeBtn = card.querySelector("#welcome-store"); |
| if (storeURL) storeBtn.onclick = () => window.open(storeURL, "_blank", "noopener"); |
| else storeBtn.closest(".row").remove(); |
| - card.querySelector("#welcome-web").onclick = () => onContinue(); |
| + const webBtn = card.querySelector("#welcome-web"); |
| + const go = onceOnly(onContinue); |
| + webBtn.onclick = () => { |
| + webBtn.disabled = true; // instant feedback — no dead-feeling first tap |
| + go(); |
| + }; |
| } |
| async function finish(id, name, pub, priv, wrappedSelf = "") { |
| @@ -199,9 +248,11 @@ async function finish(id, name, pub, priv, wrappedSelf = "") { |
| }) |
| ).json(); |
| } catch { |
| + localStorage.removeItem("lchat_welcome_pending"); // the claim didn't take — drop the marker |
| return showError("Couldn't reach the server. Try again."); |
| } |
| if (!claim.ok || !claim.token) { |
| + localStorage.removeItem("lchat_welcome_pending"); |
| return showError("This invite was just used. Ask for a new one."); |
| } |
| try { |
| @@ -227,6 +278,7 @@ async function finish(id, name, pub, priv, wrappedSelf = "") { |
| } catch {} |
| return; |
| } |
| + await plugIn(); |
| location.href = "/"; // drop the fragment from the URL bar |
| } |
| @@ -247,6 +299,7 @@ async function adoptExisting(id, threadKeyB64, existing) { |
| return showError("This invite has been used or is no longer valid. Ask for a new one."); |
| } |
| if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id); |
| + await plugIn(); |
| location.href = "/"; |
| } catch { |
| showError("Couldn't reach the server. Try again."); |
| @@ -301,6 +354,20 @@ function showStatus(_msg) { |
| showLoader(); |
| } |
| +// The beat between "claimed" and the app: flash "Plugging in…" under the |
| +// spinner, fade it out, then navigate — the amp knob fades in on the far side. |
| +async function plugIn() { |
| + if (!loader) return; |
| + const msg = document.createElement("div"); |
| + msg.className = "plugging-in"; |
| + msg.textContent = "Plugging in…"; |
| + loader.appendChild(msg); |
| + requestAnimationFrame(() => msg.classList.add("show")); |
| + await new Promise((r) => setTimeout(r, 1000)); |
| + msg.classList.remove("show"); |
| + await new Promise((r) => setTimeout(r, 300)); |
| +} |
| + |
| function showError(msg) { |
| card.classList.remove("onboard", "status"); |
| card.innerHTML = ""; |
diff --git a/web/static/shell.js b/web/static/shell.js| index 90cba58..403bd9e 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -89,8 +89,9 @@ import "/static/apps/connect.js"; |
| import "/static/apps/dnsimple.js"; |
| 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) |
| import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip) |
| -import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping } from "/static/apps/chat.js"; |
| +import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom } from "/static/apps/chat.js"; |
| import { parseInviteFragment, buildInviteFragment } from "/static/invite.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js"; |
| @@ -464,12 +465,168 @@ const hidden = {}; // thread id -> bool: closed (hidden from the list), synced f |
| const pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server |
| let chatFilter = ""; // lowercased substring filtering the thread list (search box) |
| let activeId = null; |
| +let pendingJoinThread = null; // the DM a fresh invite created — the welcome conversation offers it at the end |
| let ws = null; |
| let everConnected = false; // has the socket ever opened? (distinguishes reconnect from first connect) |
| let wsConnected = false; // is the socket open right now? (gates the send button while reconnecting) |
| let pingTimer = null; // keepalive ping interval while the socket is open |
| let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a brief blip |
| +// Custom-app import race: this const must initialize BEFORE route() below runs |
| +// boot() synchronously, or loadCustomApps hits its own const in the temporal |
| +// dead zone and every custom app silently skips on a same-tick boot. |
| +const CUSTOM_APPS_TIMEOUT_MS = 4000; |
| + |
| +// The boot amp knob (see index.html): labels 1–11 around a 300° pot (1 at 7 |
| +// o'clock, 11 at 5 o'clock, 30° apart). While loading, the fill + pointer ease |
| +// from 1 towards 10 — and the knob starts to SHAKE as the volume climbs. The |
| +// finish is choreographed to feel like a human turning it, never instant: |
| +// hideLoading eases the rest of the way to 10 (a minimum total turn even on a |
| +// fast load), holds a beat, snaps to eleven, and lets it glow. |
| +// (All declared above route()'s synchronous boot() call — the TDZ rule.) |
| +let clockRaf = 0; |
| +let dialStarted = false; // did this boot show the amp knob? (embed/no-invite/A2HS don't) |
| +let dialT0 = 0; |
| +let dialSweep = 0; |
| +const DIAL_MIN_TURN_MS = 1800; // a fast load still gets a human-speed turn to 10 |
| +// Stage dust: particles resting along the loader's bottom edge that the volume |
| +// bounces around — still at 1, hopping by 7, popcorn at eleven. |
| +let dust = null; |
| +function initDust() { |
| + const canvas = document.getElementById("amp-dust"); |
| + if (!canvas || matchMedia("(prefers-reduced-motion: reduce)").matches) return; |
| + const dpr = window.devicePixelRatio || 1; |
| + const w = window.innerWidth; |
| + const h = 140; |
| + canvas.width = w * dpr; |
| + canvas.height = h * dpr; |
| + const g = canvas.getContext("2d"); |
| + if (!g) return; |
| + g.scale(dpr, dpr); |
| + const css = getComputedStyle(document.documentElement); |
| + const ink = (css.getPropertyValue("--muted") || "#888").trim(); |
| + const accent = (css.getPropertyValue("--accent") || "#7c7cf4").trim(); |
| + const parts = Array.from({ length: Math.min(110, Math.max(60, Math.round(w / 14))) }, () => ({ |
| + x: Math.random() * w, |
| + y: h - 2 - Math.random() * 2, |
| + vx: 0, |
| + vy: 0, |
| + r: 0.8 + Math.random() * 2.1, |
| + c: Math.random() < 0.18 ? accent : ink, |
| + a: 0.3 + Math.random() * 0.5, |
| + })); |
| + dust = { g, w, h, parts }; |
| +} |
| +function stepDust(level) { |
| + if (!dust) return; |
| + const { g, w, h, parts } = dust; |
| + g.clearRect(0, 0, w, h); |
| + for (const p of parts) { |
| + // Volume kicks: both the odds and the violence grow with the level. Sharp |
| + // impulses + heavy gravity = a BOUNCE, not a float. |
| + if (level > 0 && p.vy === 0 && Math.random() < 0.02 + level * 0.2) { |
| + p.vy = -(2.2 + Math.random() * 5.5) * level; |
| + p.vx += (Math.random() - 0.5) * 2.2 * level; |
| + } |
| + p.vy += 0.62; // gravity — heavy, so hops are snappy |
| + p.y += p.vy; |
| + p.x += p.vx; |
| + p.vx *= 0.97; |
| + if (p.y >= h - 2) { |
| + p.y = h - 2; |
| + p.vy = p.vy > 1.2 ? p.vy * -0.42 : 0; // crisp rebound, quick rest |
| + } |
| + if (p.x < -4) p.x += w + 8; |
| + else if (p.x > w + 4) p.x -= w + 8; |
| + g.globalAlpha = p.a; |
| + g.fillStyle = p.c; |
| + g.beginPath(); |
| + g.arc(p.x, p.y, p.r, 0, 7); |
| + g.fill(); |
| + } |
| + g.globalAlpha = 1; |
| +} |
| +function dialPaint(knob, fill, body, sweep, shakeScale) { |
| + fill.style.setProperty("--sweep", sweep.toFixed(1) + "deg"); |
| + body.style.transform = `rotate(${(-150 + sweep).toFixed(1)}deg)`; |
| + // Loudness shake: nothing below ~5, building to a visible tremble by 10–11. |
| + const level = Math.max(0, (sweep - 120) / 180) * (shakeScale == null ? 1 : shakeScale); |
| + stepDust(level); // the stage dust dances to the same level |
| + if (level > 0 && !matchMedia("(prefers-reduced-motion: reduce)").matches) { |
| + const a = 1.7 * level; |
| + knob.style.transform = `translate(${((Math.random() - 0.5) * 2 * a).toFixed(2)}px, ${((Math.random() - 0.5) * 2 * a).toFixed(2)}px) rotate(${((Math.random() - 0.5) * 1.6 * level).toFixed(2)}deg)`; |
| + } else { |
| + knob.style.transform = ""; |
| + } |
| +} |
| +function startLoadingDial() { |
| + const knob = document.getElementById("amp-knob"); |
| + const fill = document.getElementById("amp-fill"); |
| + const body = knob && knob.querySelector(".amp-body"); |
| + if (!knob || !fill || !body) return; |
| + dialStarted = true; |
| + for (let n = 1; n <= 11; n++) { |
| + const num = document.createElement("span"); |
| + num.className = "amp-num"; |
| + num.dataset.n = String(n); |
| + num.textContent = String(n); |
| + num.style.setProperty("--a", -150 + (n - 1) * 30 + "deg"); |
| + knob.appendChild(num); |
| + } |
| + initDust(); |
| + // Pose at 1 BEFORE revealing: paint the first frame, then fade the knob in |
| + // on the next frame so it never shows unposed. |
| + dialPaint(knob, fill, body, 0); |
| + requestAnimationFrame(() => knob.classList.add("ready")); |
| + dialT0 = Date.now(); |
| + const tick = () => { |
| + const t = (Date.now() - dialT0) / 1000; |
| + dialSweep = 270 * (1 - Math.exp(-t / 2.5)); // 0° = volume 1 … 270° = volume 10, never arriving |
| + dialPaint(knob, fill, body, dialSweep); |
| + clockRaf = requestAnimationFrame(tick); |
| + }; |
| + clockRaf = requestAnimationFrame(tick); |
| +} |
| +function clickDialToEleven() { |
| + if (!dialStarted) return 0; // boot paths that never showed the knob just fade |
| + const knob = document.getElementById("amp-knob"); |
| + const fill = document.getElementById("amp-fill"); |
| + const body = knob && knob.querySelector(".amp-body"); |
| + if (!knob || !fill || !body) return 0; |
| + cancelAnimationFrame(clockRaf); |
| + // Choreography: finish the turn to 10 at human speed (topping the total turn |
| + // up to the minimum), hold a beat, SNAP to eleven, then glow — shaking harder |
| + // all the way, easing off as the glow fades. |
| + const toTen = Math.max(350, DIAL_MIN_TURN_MS - (Date.now() - dialT0)); |
| + const beat = 850; // sit at 10, trembling — let the anticipation really build |
| + const eleven = 620; // AT eleven the shake goes wild (this IS the loudest it goes) |
| + const bow = 330; // …and it fades out still rattling, before the chat appears |
| + const CRANKED = 2.3; // shake multiplier at 11 — well past the 10 tremble |
| + const from = dialSweep; |
| + const t0 = Date.now(); |
| + const run = () => { |
| + const t = Date.now() - t0; |
| + if (t < toTen) { |
| + const k = t / toTen; |
| + dialPaint(knob, fill, body, from + (270 - from) * (1 - (1 - k) * (1 - k))); |
| + } else if (t < toTen + beat) { |
| + dialPaint(knob, fill, body, 270); |
| + } else if (t < toTen + beat + eleven + bow) { |
| + // The SNAP: no travel, no ease — one frame it's at 10, the next it's AT |
| + // eleven, lit, shaking uncontrollably (and fading, during the bow). |
| + dialPaint(knob, fill, body, 300, CRANKED); |
| + knob.classList.add("pop"); |
| + if (t >= toTen + beat + eleven) knob.classList.add("out"); |
| + } else { |
| + knob.classList.add("out"); |
| + return; |
| + } |
| + requestAnimationFrame(run); |
| + }; |
| + requestAnimationFrame(run); |
| + return toTen + beat + eleven + bow; |
| +} |
| + |
| // If we've just returned from the home server's /restore page, adopt the |
| // recovered session before routing (checkRestoreReturn writes it to storage). |
| // Otherwise route straight away — the extra tick only happens on a real return. |
| @@ -497,8 +654,8 @@ function route() { |
| // registration (typeOwner, personalApps) is complete when folding starts. A |
| // broken or slow custom app must never block or white-screen boot: every |
| // import failure is contained to a console.error, and the whole phase races a |
| -// short timeout so boot proceeds regardless. |
| -const CUSTOM_APPS_TIMEOUT_MS = 4000; |
| +// short timeout so boot proceeds regardless. (Its timeout const is declared up |
| +// by route()'s invocation — see the TDZ note there.) |
| async function loadCustomApps() { |
| try { |
| const load = (async () => { |
| @@ -519,6 +676,7 @@ async function loadCustomApps() { |
| async function boot() { |
| try { |
| + startLoadingDial(); |
| await loadCustomApps(); |
| await ensurePubkey(); |
| // Role + first-run state, fetched in parallel with the heavy loads: |
| @@ -559,6 +717,9 @@ async function boot() { |
| // First-run: open the admin's setup conversation before the reveal, so the |
| // first thing an admin ever sees IS the chat with Eleven (no-op otherwise). |
| await maybeStartSetup(mePromise); |
| + // A freshly-joined member gets Eleven's welcome instead (mutually exclusive |
| + // with admin setup — a setup claim never sets the welcome marker). |
| + await maybeStartWelcome(); |
| } catch (e) { |
| console.error(e); |
| app.innerHTML = |
| @@ -570,12 +731,16 @@ async function boot() { |
| } |
| // Fade out and remove the full-screen loading cover (shown until the app's first |
| -// meaningful paint). Idempotent — safe to call from any entry path. |
| +// meaningful paint). Idempotent — safe to call from any entry path. If a loading |
| +// line is mid-display, the reveal waits until it's been readable (≥1.1s). |
| function hideLoading() { |
| const el = document.getElementById("app-loading"); |
| if (!el) return; |
| - el.classList.add("hidden"); |
| - setTimeout(() => el.remove(), 250); // after the CSS fade |
| + const clicked = clickDialToEleven(); // the dial clicks to 11 — let it land |
| + setTimeout(() => { |
| + el.classList.add("hidden"); |
| + setTimeout(() => el.remove(), 250); // after the CSS fade |
| + }, clicked); |
| } |
| // Embed bootstrap: render a single thread's feed for the native shell. Skips the |
| @@ -589,7 +754,7 @@ async function bootEmbed() { |
| // embed path needs registration complete before its render too. |
| await loadCustomApps(); |
| myUserId = embed.myUserId || ""; |
| - setCurrentUser(myUserId, session.name || "Admin"); |
| + setCurrentUser(myUserId, session.name || "New member"); |
| await loadRoster(); // token-only; gives sender labels + profile photos |
| if (embed.instanceKey) { |
| try { |
| @@ -722,10 +887,10 @@ async function loadRoster() { |
| const me = roster.find((e) => e.public_key === session.userPub); |
| myUserId = me ? me.user_id : ""; |
| } |
| - // The "Admin" fallback (here and on every outbound payload): a setup-invite |
| - // claimer is signed in nameless until Eleven's first question — if they send |
| - // anything before answering, recipients see "Admin", never a blank sender. |
| - setCurrentUser(myUserId, session.name || "Admin"); // let apps do per-user logic (e.g. poll "your vote") |
| + // The "New member" fallback (here and on every outbound payload): setup and |
| + // welcome claimers are signed in nameless until Eleven's first question — if |
| + // they send anything before answering, recipients never see a blank sender. |
| + setCurrentUser(myUserId, session.name || "New member"); // let apps do per-user logic (e.g. poll "your vote") |
| if (instanceKey) await decryptRosterLabels(); |
| } |
| @@ -742,6 +907,11 @@ async function decryptRosterLabels() { |
| e.label = await decrypt(instanceKey, e.label_blob); |
| } catch {} |
| } |
| + if (e.avatar_alt_blob) { |
| + try { |
| + e.avatarAlt = await decrypt(instanceKey, e.avatar_alt_blob); |
| + } catch {} |
| + } |
| }), |
| ); |
| } |
| @@ -847,17 +1017,30 @@ async function setupInstanceKey() { |
| instanceKeyB64 = k.b64; |
| await grantInstanceKeyTo([]); |
| } else { |
| - // It exists but we don't hold a copy yet. Nudge any online holder to wrap it to |
| - // us, then poll briefly to pick up the grant — otherwise we'd be stuck on |
| - // "Photo syncing…" until a holder next reboots. |
| + // It exists but we don't hold a copy yet. Nudge any online holder to wrap it |
| + // to us, then poll to pick up the grant — and KEEP re-nudging (on focus, and |
| + // on a slow timer) until it lands: the first holder to see a nudge may come |
| + // online long after the first poll would have expired, and a keyless member |
| + // renders everyone as "Someone" until this succeeds. |
| + const nudge = () => { |
| + fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {}); |
| + pollForInstanceKey(4); |
| + }; |
| + const onFocus = () => (instanceKey ? stopRetry() : nudge()); |
| + const timer = setInterval(() => (instanceKey ? stopRetry() : nudge()), 60000); |
| + const stopRetry = () => { |
| + clearInterval(timer); |
| + removeEventListener("focus", onFocus); |
| + }; |
| + addEventListener("focus", onFocus); |
| fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {}); |
| - pollForInstanceKey(); |
| + pollForInstanceKey(24); |
| } |
| } |
| // After nudging holders, poll for a wrapped instance key; once granted, unwrap it |
| // and re-render avatars. (The open Edit-profile sheet picks it up on next open.) |
| -async function pollForInstanceKey(tries = 6) { |
| +async function pollForInstanceKey(tries = 24) { |
| for (let i = 0; i < tries && !instanceKey; i++) { |
| await new Promise((res) => setTimeout(res, 2500)); |
| if (instanceKey) return; |
| @@ -1355,6 +1538,7 @@ function renderApp() { |
| // A fresh join (set by the join flow) wins — land straight in that new chat. |
| const justJoined = localStorage.getItem("lchat_open_thread"); |
| if (justJoined) localStorage.removeItem("lchat_open_thread"); |
| + pendingJoinThread = justJoined || pendingJoinThread; // the welcome conversation's "Say hi" chip needs it later |
| if (justJoined && threads.some((t) => t.id === justJoined)) { |
| target = justJoined; |
| } else if (deep && threads.some((t) => t.id === deep)) { |
| @@ -1429,7 +1613,16 @@ function renderThreadList() { |
| const app = appByName(appName); |
| const av = document.createElement("span"); |
| av.className = "avatar app-avatar"; |
| - av.textContent = (app && app.glyph) || "▦"; |
| + // An app can ship an avatar image (Eleven wears the app icon); the glyph |
| + // stays the fallback. In-code URLs only — never user content. |
| + if (app && app.avatar) { |
| + av.style.backgroundImage = `url(${app.avatar})`; |
| + av.style.backgroundSize = "cover"; |
| + av.setAttribute("role", "img"); |
| + av.setAttribute("aria-label", (app && app.title) || appName); |
| + } else { |
| + av.textContent = (app && app.glyph) || "▦"; |
| + } |
| row.appendChild(av); |
| } else { |
| let loader = null; |
| @@ -1636,8 +1829,10 @@ function paintHeaderAvatar(id) { |
| const el = document.getElementById("thread-avatar"); |
| if (!el) return; |
| const t = threads.find((x) => x.id === id); |
| + const ownerApp = threadApp[id] && appByName(threadApp[id]); |
| let loader; |
| - if (t && t.kind === "group") loader = () => threadAvatarFor(id); |
| + if (ownerApp && ownerApp.avatar) loader = () => Promise.resolve(ownerApp.avatar); // e.g. Eleven's app icon |
| + else if (t && t.kind === "group") loader = () => threadAvatarFor(id); |
| else { |
| const u = t && t.kind === "self" ? myUserId : t && (t.members || []).find((x) => x !== myUserId); |
| loader = () => avatarFor(u); |
| @@ -2353,9 +2548,19 @@ setChatAppHost({ |
| // chat (no intro). |
| chatIntro: (threadId) => { |
| const owner = threadApp[threadId]; |
| - if (!owner) return null; |
| - const a = appByName(owner); |
| - return (a && a.chatIntro) || null; |
| + if (owner) { |
| + const a = appByName(owner); |
| + return (a && a.chatIntro) || null; |
| + } |
| + // A person-to-person DM gets one too, so a brand-new chat isn't a blank |
| + // void before the first message (or while a typing indicator runs). |
| + const t = threads.find((x) => x.id === threadId); |
| + if (t && t.kind === "dm") { |
| + const other = (t.members || []).find((uid) => uid !== myUserId); |
| + const name = other && rosterLabel(other); |
| + if (name) return `This is the start of your chat with ${name}.`; |
| + } |
| + return null; |
| }, |
| icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY }, |
| // Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted, |
| @@ -2668,7 +2873,7 @@ setSetupBridge({ |
| setMyName: async (name) => { |
| session.name = name.slice(0, 32); |
| localStorage.setItem(SESSION_KEY, JSON.stringify(session)); |
| - setCurrentUser(myUserId, session.name || "Admin"); // emitted events carry the new name |
| + setCurrentUser(myUserId, session.name || "New member"); // emitted events carry the new name |
| const me = document.getElementById("me-avatar"); |
| if (me) paintAvatar(me, session.name, () => avatarFor(myUserId)); |
| await publishMyLabel(); |
| @@ -2744,6 +2949,29 @@ setSetupBridge({ |
| // Skipping the key ceremony starts the banner's escalating reminder schedule |
| // (24h → 72h → 1w) instead of an immediate contradiction of the conversation. |
| deferBackupReminders: () => deferBackupNudges(), |
| + // A joiner's profile photo, sent through the chat (an ordinary E2E |
| + // attachment): decrypt, then re-encrypt under the INSTANCE key as their |
| + // avatar — unlike the server photo, it never leaves the encryption. |
| + adoptProfilePhoto: async (threadId, att, alt) => { |
| + if (!instanceKey) return "nokey"; // still waiting on the space key — not a bad photo |
| + try { |
| + const plain = await loadBlobBytes(att.id, threadId); |
| + if (!plain) return false; |
| + await uploadAvatar(new Blob([plain], { type: att.type || "image/jpeg" }), (alt || "").trim()); |
| + return true; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| + // The freshly-adopted profile photo, decrypted, for the welcome preview |
| + // (cache-busted: it was uploaded moments ago). |
| + myAvatarURL: async () => { |
| + delete avatarURLs[myUserId]; |
| + return avatarFor(myUserId); |
| + }, |
| + // The welcome conversation finished (or was completed enough) — stop |
| + // re-opening it on every load. |
| + markWelcomeDone: () => localStorage.removeItem("lchat_welcome_pending"), |
| // ---- the Admin app's reads/writes (role-gated server-side either way) ---- |
| isAdmin: async () => { |
| try { |
| @@ -2788,10 +3016,10 @@ setSetupBridge({ |
| async function maybeStartSetup(mePromise) { |
| const me = await mePromise; |
| const setupActive = !!me && me.role === "admin" && !me.setup_complete; |
| - // The sidebar backup nudge defers to the setup conversation entirely; once |
| - // setup is done (or for everyone else) it renders on its normal rules — which |
| - // include the escalating 24h/72h/1w schedule a setup-time skip starts. |
| - initBackupBanner(session, { suppress: setupActive }); |
| + // The sidebar backup nudge defers to Eleven's conversations entirely (setup |
| + // AND the joiner welcome — one voice); afterwards it renders on its normal |
| + // rules, which include the escalating 24h/72h/1w schedule a skip starts. |
| + initBackupBanner(session, { suppress: setupActive || !!welcomePending() }); |
| if (!setupActive) return; |
| try { |
| const id = await resolveAppThread("setup"); |
| @@ -2803,7 +3031,7 @@ async function maybeStartSetup(mePromise) { |
| const pinFeed = () => |
| requestAnimationFrame(() => { |
| const feed = document.querySelector(".chatapp-feed"); |
| - if (feed) feed.scrollTop = feed.scrollHeight; |
| + if (feed) feedGlideToBottom(feed); // intent-aware: follow checks stay true mid-glide |
| }); |
| startSetup({ |
| threadId: id, |
| @@ -2816,13 +3044,88 @@ async function maybeStartSetup(mePromise) { |
| } catch {} |
| } |
| +// The joiner's marker, written by the join page when a fresh person claims an |
| +// invite (chat-first, nameless — Eleven asks). Carries the inviter's suggested |
| +// name; cleared by markWelcomeDone when the conversation completes. |
| +function welcomePending() { |
| + try { |
| + return JSON.parse(localStorage.getItem("lchat_welcome_pending") || "null"); |
| + } catch { |
| + return null; |
| + } |
| +} |
| + |
| +// A freshly-joined member's first load: open Eleven's welcome conversation |
| +// before the reveal (the joiner mirror of maybeStartSetup). Runs until the |
| +// conversation completes; each load resumes past the answered steps. |
| +async function maybeStartWelcome() { |
| + const pending = welcomePending(); |
| + if (!pending) return; // the marker is set only by a SUCCESSFUL member-invite |
| + // claim (join.js clears it on failure), and cleared at stepFinish — so a stale |
| + // one can't hijack an add-device sign-in (that path never sets it). |
| + try { |
| + const id = await resolveAppThread("welcome"); |
| + if (!id) return; |
| + await openThread(id); |
| + const pinFeed = () => |
| + requestAnimationFrame(() => { |
| + const feed = document.querySelector(".chatapp-feed"); |
| + if (feed) feedGlideToBottom(feed); // intent-aware: follow checks stay true mid-glide |
| + }); |
| + // The greeting names who runs the space AND who invited them — both are |
| + // encrypted labels, so they need the instance key: a fresh joiner's key |
| + // grant usually lands within a couple of seconds of the boot-time nudge. |
| + // Wait briefly rather than open with "one of its members" when we're |
| + // about to know better. (Everything label-derived reads AFTER this.) |
| + for (let i = 0; i < 12 && !instanceKey; i++) await new Promise((r) => setTimeout(r, 250)); |
| + if (instanceKey) await decryptRosterLabels(); |
| + // The DM the invite created (captured before renderApp consumed its |
| + // marker): its other member is the inviter, for the greeting and the |
| + // "Say hi" chip. |
| + let inviterName = ""; |
| + let openPendingThread = null; |
| + // The captured marker survives one load only — on a mid-welcome reload, |
| + // fall back to the joiner's (sole) person-DM, which is the same thread. |
| + const dm = |
| + (pendingJoinThread && threads.find((t) => t.id === pendingJoinThread)) || |
| + threads.find((t) => t.kind === "dm" && !threadApp[t.id] && (t.members || []).some((uid) => uid !== myUserId)); |
| + if (dm) { |
| + const other = (dm.members || []).find((uid) => uid !== myUserId); |
| + const entry = other && roster.find((x) => x.user_id === other); |
| + inviterName = (entry && entry.label) || ""; |
| + const dmId = dm.id; |
| + openPendingThread = async () => { |
| + await openThreadById(dmId); |
| + // They tapped "Say hi" — put the cursor where the hi goes. |
| + requestAnimationFrame(() => { |
| + const ta = document.querySelector(".chatapp-root .composer textarea"); |
| + if (ta) ta.focus(); |
| + }); |
| + }; |
| + } |
| + const adminEntry = roster.find((e) => !e.is_agent && e.role === "admin"); |
| + startWelcome({ |
| + threadId: id, |
| + suggested: (pending.suggested || "").slice(0, 32), |
| + inviterName, |
| + adminName: (adminEntry && adminEntry.label) || "", |
| + openPendingThread, |
| + typing: (on) => { |
| + setNoticeTyping(id, "Eleven", on); |
| + if (on) pinFeed(); |
| + }, |
| + respond: (spec) => appendNotice(id, spec), |
| + }); |
| + } catch {} |
| +} |
| + |
| // Send a normal chat message (name + text) into a thread, as if typed. Same |
| // envelope + encryption as the composer and the app-event transport; used by the |
| // tour to ask Eleven a question on the member's behalf. |
| async function sendChatText(threadId, text) { |
| const key = threadKeys[threadId]; |
| if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| - const payload = { name: session.name || "Admin", text, ts: Date.now() }; |
| + const payload = { name: session.name || "New member", text, ts: Date.now() }; |
| if (myUserId) payload.uid = myUserId; |
| const blob = await encrypt(key, payload); |
| ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| @@ -2868,7 +3171,7 @@ function sendAndConfirm(threadId, blob) { |
| async function announceJoin(threadId, uids) { |
| const key = threadKeys[threadId]; |
| if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return; |
| - const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true }; |
| + const payload = { name: session.name || "New member", text: `${session.name || "New member"} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true }; |
| if (myUserId) payload.uid = myUserId; |
| const blob = await encrypt(key, payload); |
| ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| @@ -2883,7 +3186,7 @@ async function announceJoin(threadId, uids) { |
| function announceLeave(threadId) { |
| const key = threadKeys[threadId]; |
| if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(); |
| - const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} left`, left: true, ts: Date.now(), action: true }; |
| + const payload = { name: session.name || "New member", text: `${session.name || "New member"} left`, left: true, ts: Date.now(), action: true }; |
| if (myUserId) payload.uid = myUserId; |
| return encrypt(key, payload).then((blob) => sendAndConfirm(threadId, blob)); |
| } |
| @@ -2896,7 +3199,7 @@ function announceLeave(threadId) { |
| async function announceRemove(threadId, uids) { |
| const key = threadKeys[threadId]; |
| if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return; |
| - const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true }; |
| + const payload = { name: session.name || "New member", text: `${session.name || "New member"} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true }; |
| if (myUserId) payload.uid = myUserId; |
| const blob = await encrypt(key, payload); |
| ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| @@ -4038,17 +4341,42 @@ function connectWS() { |
| } catch { |
| return; |
| } |
| + if (msg.type === "joined") { |
| + // Someone claimed an invite: swap any pending-invite card for the live |
| + // DM and pick up the new roster entry — no reload needed on this side. |
| + await loadRoster(); |
| + await loadThreads(); |
| + await refreshNamesUI(); |
| + renderThreadList(); |
| + const t = activeId && threads.find((x) => x.id === activeId); |
| + // openThread(activeId) would no-op (the already-open guard), so refresh |
| + // the pending card directly: the joiner now has a key, so it clears and |
| + // the feed + composer come back. |
| + if (t && (t.members || []).includes(msg.user_id)) updatePendingBanner(t); |
| + return; |
| + } |
| if (msg.type === "instance_key_nudge") { |
| - // A member lacks the instance key. If we hold it, (re-)wrap it to whoever's |
| - // missing it so they can decrypt names + photos (and publish their own). |
| - // Reload the roster first: the nudger is often a brand-new joiner who isn't |
| - // in our cached roster yet, so without this we'd skip the very person asking. |
| - if (instanceKeyB64) { |
| + // A member lacks the instance key. The nudge names WHO needs it, but we |
| + // never wrap to a key handed to us in a raw frame — a compromised server |
| + // could forge one and harvest members' names/photos. Reload the roster and |
| + // wrap only to the public key the server has PUBLISHED for that user (a |
| + // claimed member). The inviter shares the joiner's pending DM, so their |
| + // roster includes the joiner and the grant flows; an unseen/forged nudge |
| + // is simply ignored (the joiner re-nudges until a holder who sees them is |
| + // online). |
| + if (instanceKeyB64 && msg.user_id) { |
| try { |
| await loadRoster(); |
| - const ik = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json(); |
| - await grantInstanceKeyTo(ik.have || []); |
| - await refreshNamesUI(); // the reload may have added the new joiner to our roster |
| + const target = roster.find((e) => e.user_id === msg.user_id && e.public_key); |
| + if (target) { |
| + const wrapped = await wrapThreadKey(instanceKeyB64, target.public_key); |
| + await fetch("/api/instance-key", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, grants: [{ user_id: target.user_id, wrapped }] }), |
| + }); |
| + } |
| + await refreshNamesUI(); // the joiner may be new to our roster too |
| } catch {} |
| } |
| return; |
| @@ -4078,7 +4406,17 @@ function connectWS() { |
| } |
| if (msg.type === "avatar") { |
| const e = roster.find((x) => x.user_id === msg.user_id); |
| - if (e) e.avatar = msg.blob_id; |
| + if (e) { |
| + e.avatar = msg.blob_id; |
| + e.avatarAlt = ""; |
| + if (msg.alt_blob && instanceKey) { |
| + decrypt(instanceKey, msg.alt_blob) |
| + .then((alt) => { |
| + e.avatarAlt = alt; |
| + }) |
| + .catch(() => {}); |
| + } |
| + } |
| delete avatarURLs[msg.user_id]; |
| refreshAvatars(); |
| return; |
| @@ -5461,7 +5799,11 @@ function avatarColor(name) { |
| // Paints initials immediately, then the photo (if any) when its loader resolves. |
| // Returns a promise of the photo URL (or null), so callers can react to it — |
| // e.g. the header avatar wires a tap-to-zoom only when there's a real photo. |
| -function paintAvatar(el, name, loader) { |
| +function paintAvatar(el, name, loader, alt) { |
| + // Avatars are background-image spans, invisible to screen readers without |
| + // this: the member's own (encrypted) photo description wins over their name. |
| + el.setAttribute("role", "img"); |
| + el.setAttribute("aria-label", alt || name || ""); |
| el.style.backgroundImage = ""; |
| el.textContent = ((name || "?").trim().charAt(0) || "?").toUpperCase(); |
| el.style.background = avatarColor(name); |
| @@ -5492,6 +5834,10 @@ function editProfile() { |
| card.appendChild(h); |
| const meAvatar = () => avatarFor(myUserId); |
| + const meAlt = () => { |
| + const e = roster.find((x) => x.user_id === myUserId); |
| + return (e && e.avatarAlt) || ""; |
| + }; |
| const av = avatarEl(session.name, meAvatar); |
| av.classList.add("avatar-lg"); |
| const photoBtn = document.createElement("button"); |
| @@ -5518,7 +5864,7 @@ function editProfile() { |
| photoBtn.disabled = true; |
| photoBtn.textContent = "Uploading…"; |
| try { |
| - await uploadAvatar(f); |
| + await uploadAvatar(f, meAlt()); // keep the existing description on a photo swap |
| delete avatarURLs[myUserId]; |
| paintAvatar(av, session.name, meAvatar); |
| const me = document.getElementById("me-avatar"); |
| @@ -5546,6 +5892,12 @@ function editProfile() { |
| input.value = session.name; |
| input.maxLength = 32; |
| input.addEventListener("input", () => paintAvatar(av, input.value || "?", meAvatar)); |
| + input.addEventListener("keydown", (e) => { |
| + if (e.key === "Enter") { |
| + e.preventDefault(); |
| + save.click(); // Enter saves, like any single-field form |
| + } |
| + }); |
| card.appendChild(label); |
| card.appendChild(input); |
| @@ -5562,8 +5914,13 @@ function editProfile() { |
| if (next && next !== session.name) { |
| session.name = next.slice(0, 32); |
| localStorage.setItem(SESSION_KEY, JSON.stringify(session)); |
| + setCurrentUser(myUserId, session.name); // emitted events carry the new name |
| const me = document.getElementById("me-avatar"); |
| if (me) paintAvatar(me, session.name, meAvatar); |
| + // Publish the (encrypted) label NOW — without this, everyone else keeps |
| + // seeing "Someone" until this browser's next full reload happens to |
| + // republish it. The welcome/setup bridge already does the same. |
| + publishMyLabel(); |
| } |
| backdrop.remove(); |
| }; |
| @@ -5575,7 +5932,7 @@ function editProfile() { |
| // Encrypt a profile photo with the instance key, upload it, and point the user's |
| // avatar at the blob. Downscaled small; readable by every member, never the server. |
| -async function uploadAvatar(file) { |
| +async function uploadAvatar(file, alt = "") { |
| if (!instanceKey) throw new Error("no instance key"); |
| const { bytes } = await processImage(file, 400); |
| if (bytes.length > MAX_UPLOAD) throw new Error("too big"); |
| @@ -5583,13 +5940,23 @@ async function uploadAvatar(file) { |
| const res = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc }); |
| if (!res.ok) throw new Error("upload"); |
| const { id } = await res.json(); |
| - await fetch("/api/avatar", { |
| + // The alt text is user content: it ships encrypted under the instance key, |
| + // like the display name — the server stores an opaque blob. Cap the plaintext |
| + // so the base64 blob stays well under the server's 4096-char limit (else the |
| + // avatar POST 400s and — before this — failed silently). |
| + alt = (alt || "").slice(0, 280); |
| + const altBlob = alt ? await encrypt(instanceKey, alt) : ""; |
| + const av = await fetch("/api/avatar", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, blob_id: id }), |
| + body: JSON.stringify({ token: session.token, blob_id: id, alt_blob: altBlob }), |
| }); |
| + if (!av.ok) throw new Error("avatar save failed"); // don't let the UI show it as kept |
| const me = roster.find((e) => e.user_id === myUserId); |
| - if (me) me.avatar = id; // so avatarFor() picks it up immediately |
| + if (me) { |
| + me.avatar = id; // so avatarFor() picks it up immediately |
| + me.avatarAlt = alt; |
| + } |
| } |
| async function leave() { |
diff --git a/web/static/style.css b/web/static/style.css| index 3efe90e..2de37b9 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -146,10 +146,93 @@ html, body { |
| z-index: 100; |
| display: grid; |
| place-items: center; |
| + align-content: center; |
| + gap: 20px; |
| background: var(--bg); |
| transition: opacity 0.25s ease; |
| } |
| .app-loading.hidden { opacity: 0; pointer-events: none; } |
| +/* The join page's "Plugging in…" flash between claiming and the app. */ |
| +.plugging-in { |
| + min-height: 22px; |
| + color: var(--muted); |
| + font-size: 15px; |
| + text-align: center; |
| + opacity: 0; |
| + transform: translateY(6px); |
| + transition: opacity 0.3s ease, transform 0.3s ease; |
| +} |
| +.plugging-in.show { opacity: 1; transform: none; } |
| +/* The loader's stage dust: a strip of particles along the bottom edge. */ |
| +.amp-dust { |
| + position: absolute; |
| + left: 0; |
| + right: 0; |
| + bottom: 0; |
| + width: 100%; |
| + height: 140px; |
| + pointer-events: none; |
| +} |
| +/* The boot amp knob (labelled 1–11): the pie arc fills and the pointer turns |
| + from 1 towards 10 while loading; on ready it goes to eleven, then glows. |
| + The pot sweeps 300° — 1 at 7 o'clock round to 11 at 5 o'clock. */ |
| +.amp-knob { |
| + width: 92px; |
| + height: 92px; |
| + border-radius: 50%; |
| + position: relative; |
| + /* Hidden until the engine has posed it at 1 (labels built, pointer turned) — |
| + the raw pre-JS knob would flash pointing at nothing, mid-dial. */ |
| + opacity: 0; |
| +} |
| +.amp-knob.ready { animation: amp-in 0.35s ease both; } |
| +/* After eleven: the knob takes its bow — fades out on the still-solid cover |
| + BEFORE the chat is revealed. (Declared after .ready so it wins.) */ |
| +.amp-knob.out { animation: amp-out 0.32s ease both; } |
| +@keyframes amp-in { |
| + from { opacity: 0; } |
| + to { opacity: 1; } |
| +} |
| +@keyframes amp-out { |
| + from { opacity: 1; } |
| + to { opacity: 0; } |
| +} |
| +.amp-fill { |
| + position: absolute; |
| + inset: 14px; |
| + border-radius: 50%; |
| + background: conic-gradient(from 210deg, var(--accent) var(--sweep, 0deg), color-mix(in srgb, var(--border) 55%, transparent) var(--sweep, 0deg) 300deg, transparent 300deg); |
| +} |
| +.amp-body { |
| + position: absolute; |
| + inset: 24px; |
| + border-radius: 50%; |
| + background: var(--panel); |
| + border: 1px solid var(--border); |
| + box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.18); |
| +} |
| +.amp-pointer { |
| + position: absolute; |
| + left: calc(50% - 1.5px); |
| + top: 2px; |
| + width: 3px; |
| + height: 11px; |
| + border-radius: 2px; |
| + background: var(--ink); |
| +} |
| +.amp-num { |
| + position: absolute; |
| + left: 50%; |
| + top: 50%; |
| + font-size: 11px; |
| + font-weight: 700; |
| + color: var(--muted); |
| + transform: translate(-50%, -50%) rotate(var(--a)) translateY(-41px) rotate(calc(var(--a) * -1)); |
| +} |
| +/* Hitting eleven: the 11 lights up, a breath, and the whole cover fades fast |
| + (hideLoading's fade — no theatrics). */ |
| +.amp-knob.pop .amp-num[data-n="11"] { color: var(--accent); } |
| + |
| /* The join page's card yields entirely to the full-screen loader while a claim |
| is in flight (see showLoader/showCard in join.js). */ |
| .card.hidden { display: none; } |
| @@ -687,23 +770,18 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| } |
| .lp-host { font-size: 11px; color: var(--muted); margin-top: 5px; } |
| -/* composer attachment preview — floats over the feed instead of taking its own |
| - band. .composer-float is a zero-height in-flow anchor at the top of the |
| - composer stack; the chip row is absolutely anchored to its bottom, so it |
| - paints upward over the messages and rises with any banners (reply/edit) |
| - that grow the stack. Wrapped rows grow upward too (bottom-anchored). */ |
| -.composer-float { position: relative; height: 0; z-index: 5; } |
| +/* composer attachment preview. .composer-float is the in-flow anchor at the top |
| + of the composer stack; the typeahead drop-up absolutely anchors to it. */ |
| +.composer-float { position: relative; z-index: 5; } |
| +/* The staging strip occupies real layout space (the feed shrinks above it) — |
| + floating it over the feed covered whatever sat at the bottom, including |
| + tappable action chips ("Skip for now" became dead text under a thumbnail). */ |
| .composer-preview { |
| - position: absolute; |
| - bottom: 8px; |
| - left: 16px; |
| - right: 16px; |
| + padding: 8px 16px 0; |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| - pointer-events: none; /* the empty row lets feed clicks/scroll through */ |
| } |
| -.composer-preview > * { pointer-events: auto; } |
| .composer-preview.hidden { display: none; } |
| .preview-spinner { |
| width: 16px; height: 16px; border-radius: 50%; |
| @@ -779,6 +857,9 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| } |
| .action-chip:hover:not(:disabled) { background: var(--bubble-them); } |
| .action-chip:disabled { color: var(--muted); cursor: default; } |
| +/* A spent chip (its step was answered/skipped): a quiet part of the record, |
| + not something that still looks tappable. */ |
| +.action-chip--spent { background: transparent; border-color: transparent; padding-left: 6px; padding-right: 6px; } |
| /* Local notice: a command's private "only visible to you" reply — a client-only |
| feed row (never sent, gone on reload). It lives IN the feed and PERSISTS (no |
| @@ -822,12 +903,19 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| border-radius: 12px; border: 1px solid var(--border); |
| background: color-mix(in srgb, var(--ink) 6%, transparent); |
| } |
| +/* An avatar preview renders as the circle it will actually be. */ |
| +.local-notice-img--round { border-radius: 50%; object-fit: cover; } |
| /* Notices ease in like live messages (see .msg.appear). */ |
| .local-notice.appear { animation: msg-appear 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); } |
| @media (prefers-reduced-motion: reduce) { |
| .local-notice.appear { animation: none; } |
| } |
| .local-notice .chip-row { margin-top: 8px; } |
| +/* A chips-only notice row (the live actions under a persisted bubble): tuck it |
| + right up under the bubble it belongs to instead of floating a full row lower. |
| + (Doubled class so it outranks the base rules above regardless of order.) */ |
| +.local-notice.local-notice--chips { margin-top: 2px; } /* + the feed's 4px gap = a 6px breath */ |
| +.local-notice.local-notice--chips .chip-row { margin-top: 0; } |
| /* A confirmation is loud only for a while: after ~30s it "blinds up" (its height |
| animates down — see collapseNotice) to this compact one-line record |
| (glyph · who · what happened · action). */ |
| @@ -847,7 +935,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .local-notice--compact .lc-action::before { content: "·"; margin-right: 5px; color: var(--muted); font-weight: 400; } |
| /* App-thread action line: a centered, muted /me-style record of an interaction |
| ("added 'milk'") — deliberately unlike a chat bubble. */ |
| -.chatapp-action { align-self: center; text-align: center; color: var(--muted); font-size: 13px; padding: 3px 14px; max-width: 90%; } |
| +.chatapp-action { align-self: center; text-align: center; color: var(--muted); font-size: 13px; padding: 3px 14px; margin: 10px 0; max-width: 90%; } |
| /* The "start of your chat history" intro: a calm, centered explainer pinned at |
| the top of a bot's chat (a persistent first row that scrolls up with history). */ |
| .chatapp-intro { |
| @@ -1402,8 +1490,10 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| width: 72px; height: 72px; border-radius: 18px; object-fit: cover; |
| margin: 0 auto 12px; display: block; |
| } |
| -/* the browser-only caveat on the welcome card: present but quiet */ |
| -.welcome-caveat { font-size: 13px; margin: 12px 0 0; } |
| +/* the browser-only caveat on the welcome card: present but quiet, with clear |
| + air between it and the button it annotates. (.card selectors so this outranks |
| + the generic .card p margin rule.) */ |
| +.card .welcome-caveat { font-size: 13px; color: var(--muted); margin: 20px auto 4px; max-width: 42ch; } |
| .steps { |
| text-align: left; |
| color: var(--muted); |