eleven / messenger Public

vault: post a group action line when a secret is added or removed (#510)

* vault: post a group action line when a secret is added or removed

Vault stays a pure takeover on its personal thread (no Chat view ever
grows there), but when it's added to a group chat, an add/remove now
posts an unnamed action line ("Ciara added a secret to the Vault") so
members notice vault activity without opening it. A new groupChatOnly
flag pairs with chatMessage to split "projects into a group's chat
feed" from "wants its own Chat view" — appWantsChatView respects it,
foldGroupAppEventBubble doesn't need to know about it.

Co-Authored-By: Claude <noreply@anthropic.com>

* vault: say "You" for your own action, and fix the blank sidebar preview

Two follow-ups on the group action line:

- foldGroupAppEventBubble (shell.js) now substitutes "You" for the
  acting user's own event, matching membershipLine's convention for
  join/leave lines — applies to every app that projects into a
  group's chat feed (vault, todo, remind, groupapps), not just vault.

- loadThreads() was computing the sidebar "last message" preview with
  previewText() unconditionally, even for typed app events with no
  .text — producing "Name: " with nothing after the colon. It now
  routes app-typed payloads through the owning app's preview() hook,
  same as the live-message path already did.

- vault.js gains a preview() hook (add/remove/change, "You"-aware via
  a new currentUserId() export) so its own rows — personal or group —
  show "🔒 You added a secret to the Vault" instead of falling back to
  the (now-fixed) blank previewText output.

Co-Authored-By: Claude <noreply@anthropic.com>

* apps/README: document the "You" attribution split and the preview() gotcha

Two things this vault work surfaced that the next app author should
know up front, not rediscover:

- chatMessage action lines get "You" substitution for free from the
  host (foldGroupAppEventBubble); preview() doesn't, and has to check
  event.uid against the new currentUserId() export itself.
- Before this session, an app-typed event with no preview() handling
  fell through to a raw "Name: " chat-message formatter (no .text ->
  empty line after the colon) instead of staying silent. Now fixed at
  the host level (both the live and cold-load paths), but it changes
  the contract: every owned event type should either get a preview
  line or be silenced on purpose, not left to a "reasonable" fallback
  that no longer exists.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs/building-apps.md: document currentUserId() for a "You"-aware preview()

Mirrors the same addition to web/static/apps/README.md: preview()
gets no ctx, so an LLM building a custom app via /api/app-guide had
no documented way to say "You" for the reader's own event. The
export was already usable (check-app.mjs only restricts import
sources, not specific names from apps.js/format.js) — this just
surfaces it, with the allowed-imports table entry to match.

The other lesson from the same session (groupCapable/groupChatOnly's
host-level "You" attribution for group action lines) does NOT apply
here: this guide never teaches groupCapable/group-sharing at all
(scope is "personal"/"shared" only) — left for a separate call on
whether admin-installed custom apps should be taught that surface.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs/building-apps.md: teach groupCapable/groupChatOnly for custom apps

Extends the LLM-facing app-building guide (served at /api/app-guide)
with the group-sharing model previously only documented internally
(web/static/apps/README.md): groupCapable, the automatic actor/"You"
attribution on chatMessage action lines projected into a group,
chatMessage's { group: true } second argument, and groupChatOnly for
apps (like Vault) that want group activity noticed without their own
personal thread growing a Chat view.

Nothing here required a change to tools/check-app.mjs: it only
restricts import sources, not defineApp's field names, so groupCapable
apps were already installable via the self-serve custom-app path —
this guide just hadn't caught up to teach it.

Also clarifies the existing "personal or shared?" interview question,
which conflated scope: "shared" (Poll's always-on ambient panel) with
"a tool a group can share" (groupCapable) — the same "shared is not
shareable" trap called out in the internal README.

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam d8425b89868c442205b5609b2730b525ca593308 parent 24737d6
7 files changed, +245 −15
  • changelog/2026-07-09-vault-group-activity-line.md +4 −0
  • docs/building-apps.md +83 −5
  • test/apps.test.mjs +41 −0
  • web/static/apps.js +13 −1
  • web/static/apps/README.md +43 −1
  • web/static/apps/vault.js +45 −5
  • web/static/shell.js +16 −3
diff --git a/changelog/2026-07-09-vault-group-activity-line.md b/changelog/2026-07-09-vault-group-activity-line.md
new file mode 100644
index 0000000..afab56e
--- /dev/null
+++ b/changelog/2026-07-09-vault-group-activity-line.md
@@ -0,0 +1,4 @@
+🤖 In a group chat that has Vault added, adding or removing a secret now
+posts a quiet note in the conversation — "Ciara added a secret to the Vault" —
+so members notice vault activity without opening it. The secret's name and
+value are never shown; your own personal Vault is unaffected.
diff --git a/docs/building-apps.md b/docs/building-apps.md
index b35cffd..d582fd8 100644
--- a/docs/building-apps.md
+++ b/docs/building-apps.md
@@ -86,10 +86,19 @@ finished file against each rule:
Before writing any code, ask the person who wants the app:
-1. **Personal or shared?** Is this *their own private tool* (a to-do list,
- bookmarks — `scope: "personal"`, its own thread in the sidebar, full-pane)
- or a *group tool everyone in a chat uses together* (a poll, a scoreboard —
- `scope: "shared"`, a panel above the chat)?
+1. **Personal, ambient panel, or group-shareable?** Three different things,
+ easy to conflate:
+ - *Personal* (`scope: "personal"`) — their own private tool (a to-do list,
+ bookmarks), its own thread in the sidebar, full-pane.
+ - *Ambient panel* (`scope: "shared"`) — always present above every chat, no
+ install (a poll, a scoreboard for that one conversation).
+ - *Group-shareable* — a `scope: "personal"` app with `groupCapable: true`
+ ALSO added to a specific group chat, where one shared instance runs for
+ that group (see "Group-scoped apps" below). 🤖 **The trap**: someone
+ asking for "a group tool everyone in the chat uses together" usually
+ means THIS, not `scope: "shared"` — `"shared"` is the always-on panel
+ model (Poll), not a "shareable" one. If the ask sounds like "a to-do list
+ / tracker / list the group manages together," reach for `groupCapable`.
2. **Takeover or companion?** For a personal app: should its surface *be* the
whole thread (takeover — the default; right for boards, games, dashboards),
or should the thread also offer a Chat view where the app's actions read as
@@ -126,11 +135,13 @@ export const app = defineApp({ /* fields below */ });
| `title` | ✓ | Human name (sidebar, header). |
| `glyph` | ✓ | One emoji/character used as the app's avatar. |
| `scope` | ✓ | `"personal"` or `"shared"` — see the interview above. |
+| `groupCapable` | | `true` — this `"personal"` app can ALSO be added to a group chat, where one shared instance runs for everyone. See "Group-scoped apps" below. |
+| `groupChatOnly`| | `true` — pairs with `chatMessage`: the app still projects into a GROUP's chat feed when added there, but your OWN personal thread never grows a Chat view from it. See "Group-scoped apps" below. |
| `initialState` | ✓ | `() => state`. A **factory**, called to seed each thread's empty state. |
| `events` | ✓ | `{ "<type>": (state, event, meta) => state }`. The fold. |
| `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }` — `desc`/`args` feed the `/` typeahead. |
| `render` | | `(state, el, ctx) => void`. Draws the surface. |
-| `chatMessage` | | `(event) => { text, action? } \| null`. Opting in makes the app a **companion**: projects each event into a Chat view (a bot bubble, or a `/me`-style action line via `{ text, action: true }`). No `chatMessage` → the app **takes over** the thread's full surface. |
+| `chatMessage` | | `(event, opts?) => { text, action? } \| null`. Opting in makes the app a **companion**: projects each event into a Chat view (a bot bubble, or a `/me`-style action line via `{ text, action: true }`). No `chatMessage` → the app **takes over** the thread's full surface. `opts.group` is `true` when called for a GROUP's own chat feed — see "Group-scoped apps" below. |
| `chatIntro` | | The empty-state intro line shown the first time the companion Chat view opens. |
| `messageAction`| | `(msg) => { label, glyph, run(ctx) } \| null`. Adds an item to the chat message menu. |
| `renderEvent` | | `(event, meta) => Node \| null`. An inline line in the message feed. |
@@ -457,6 +468,11 @@ Both views fold from the same event log — one source of truth — so an action
in one shows in the other. No `chatMessage` → the app takes over the whole
thread surface (the default).
+🤖 If your app is `groupCapable` and gets added to a GROUP, `chatMessage` is
+also how its events show up in THAT group's own chat feed — see "Group-scoped
+apps" below for what changes (attribution, the `opts.group` argument, and
+`groupChatOnly`).
+
### `preview(event)` / `unread(event)`
How your events appear in the chat list: `preview` returns the sidebar
@@ -464,6 +480,67 @@ preview line (or `null` to leave the existing preview), `unread` says whether
a live event lights the thread's unread dot. Both default to silent — opt in
only for events a user would want surfaced (a new poll: yes; every vote: no).
+🤖 `preview` gets no ctx — it's a pure `event => string`. If you want it to say
+“You” for the reader's own event (rather than always naming the actor), import
+`currentUserId` and compare it to `event.uid`:
+
+```js
+import { defineApp, currentUserId } from "../apps.js";
+preview(e) {
+ const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone";
+ return `${who} added “${e.text}”`;
+}
+```
+
+## Group-scoped apps: personal AND shared with a group
+
+🤖 A `scope: "personal"` app with `groupCapable: true` can, IN ADDITION to
+everyone's own private instance, be **added to a specific group chat**
+(members do this from that chat's ⋮ menu → "Add apps" — no code of yours makes
+that happen, it's host UI). Once added, **one shared instance runs for that
+whole group**: every member's `ctx.emit` posts into the GROUP's own encrypted
+event log instead of their personal thread, and every member folds the same
+state. Nothing about your `render`, `events`, or `commands` changes — `ctx` is
+just bound to whichever thread it's acting in, so the exact same code renders
+a to-do list whether it's yours alone or the group's shared one.
+
+This is a different mechanism from `scope: "shared"` (the ambient panel, e.g.
+Poll — always present, no install). Don't reach for `groupCapable` AND
+`scope: "shared"` together; pick one shape (see the interview above).
+
+What changes once your app can be group-instanced:
+
+- **Any group member's event is now a first-class input**, not just yours.
+ Hard constraint #11 ("validate in the reducer, never trust the emitter") is
+ no longer a defensive nicety — a malformed or adversarial event from ANY
+ member of the group must fold to a no-op, because it can now come from
+ someone other than you.
+- **`chatMessage` action lines get attributed automatically.** When a group
+ has your app added, an `{ text, action: true }` projection is shown prefixed
+ with the actor's name, or **“You”** when the reader is the one who acted —
+ both entirely automatic. Write `text` as if it always follows a name
+ (`added “buy milk”`, never `Ciara added “buy milk”`) — the host supplies
+ the name/“You”, so baking one in yourself produces a doubled-up line.
+- **`chatMessage` receives a second argument in a group: `chatMessage(event,
+ { group: true })`.** Use it only if wording needs to change for many
+ simultaneous readers (e.g. a reminder's `"you asked me"` phrasing only makes
+ sense one-to-one, so a group projection drops it). Ignore the second
+ argument if your wording doesn't depend on it.
+- **`groupChatOnly: true` splits "projects into a group's feed" from "wants
+ its own Chat view."** By default, implementing `chatMessage` makes ANY
+ instance of your app — personal or group — a companion (Chat view + toggle).
+ Set `groupChatOnly: true` alongside `chatMessage` to keep your app's own
+ personal thread a pure takeover (no Chat view, ever), while STILL projecting
+ an attributed line into a group's feed when the app is added there. Reach
+ for this when your personal surface shouldn't grow a second (redundant, or
+ privacy-sensitive) view of its own events, but a group that shares an
+ instance should still notice activity without opening the app — e.g. a
+ shared secrets vault that announces "Ciara added a secret" to the group
+ without ever listing secret names in anyone's Chat view.
+- **`preview`/`unread` work exactly the same** for a group instance as a
+ personal one — no group-specific argument, just whatever `event.name` /
+ `event.uid` the event already carries.
+
## Allowed imports
Import **only** from these two modules, and only these exports.
@@ -479,6 +556,7 @@ From `"../apps.js"`:
| `partitionByGrace(items, doneAtOf, now, graceMs?)` | split items into `{ active, grace, archived }` by a completion timestamp — for "linger then tuck away" UI |
| `DONE_GRACE_MS` | the standard grace-period length (ms) |
| `keyedList(el, items, opts)` | incremental DOM reconciliation for `incremental: true` surfaces |
+| `currentUserId()` | the reader's own user id — for a "You" check in `preview` (see above) or `render` |
(`apps.js` exports other functions — `setEmitTransport`, `foldEvent`,
`runCommand`, … — those are host/framework wiring. An app must not call them.)
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index edee8a7..a08a1a9 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -62,6 +62,7 @@ import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run
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, threadItems } from "../web/static/apps/chat.js"; // the chat app
+import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault)
// Each app's fold is synthesised from its `events` map by defineApp — the same
// (state, event) → state reducer the framework folds with. Test it directly.
@@ -367,6 +368,46 @@ await test("appWantsChatView: chatMessage opts into companion; its absence is ta
assert.equal(appWantsChatView("no-such-app"), false); // unknown name → not companion
});
+await test("appWantsChatView: groupChatOnly keeps a chatMessage app a personal takeover", () => {
+ // Vault implements chatMessage (to project add/remove into a GROUP's chat
+ // feed — see below) but is groupChatOnly, so its OWN thread must stay a pure
+ // takeover: no Chat view, no header toggle, ever.
+ assert.equal(typeof vaultApp.chatMessage, "function");
+ assert.equal(vaultApp.groupChatOnly, true);
+ assert.equal(appWantsChatView("vault"), false);
+});
+
+await test("vault: chatMessage projects add/remove only in a GROUP, never names the secret", () => {
+ const add = { type: "vault.add", id: "s1", aid: "a1", label: "GITHUB_TOKEN", value: "hunter2" };
+ const remove = { type: "vault.remove", id: "s1", aid: "a2" };
+ const change = { type: "vault.change", id: "s1", aid: "a3", value: "newvalue" };
+ // No { group: true } — a personal-thread event (or a call with no opts at
+ // all) → silent, exactly like an app with no chatMessage.
+ assert.equal(vaultApp.chatMessage(add), null);
+ assert.equal(vaultApp.chatMessage(add, {}), null);
+ assert.equal(vaultApp.chatMessage(remove), null);
+ // In a group: add/remove get an unnamed action line…
+ const groupAdd = vaultApp.chatMessage(add, { group: true });
+ assert.equal(groupAdd.action, true);
+ assert.equal(groupAdd.text, "added a secret to the Vault");
+ assert.doesNotMatch(groupAdd.text, /GITHUB_TOKEN|hunter2/);
+ const groupRemove = vaultApp.chatMessage(remove, { group: true });
+ assert.equal(groupRemove.action, true);
+ assert.equal(groupRemove.text, "removed a secret from the Vault");
+ // …but a value CHANGE (nothing new to notice, same name) stays silent.
+ assert.equal(vaultApp.chatMessage(change, { group: true }), null);
+});
+
+await test("vault: preview names the actor ('You' for the reader's own event), never the secret", () => {
+ // setCurrentUser("u1") above makes u1 "the reader" for the rest of this file.
+ assert.equal(vaultApp.preview({ type: "vault.add", uid: "u1", name: "James" }), "🔒 You added a secret to the Vault");
+ assert.equal(vaultApp.preview({ type: "vault.remove", uid: "u2", name: "Ciara" }), "🔒 Ciara removed a secret from the Vault");
+ assert.equal(vaultApp.preview({ type: "vault.change", uid: "u1", name: "James" }), "🔒 You updated a secret in the Vault");
+ // No uid (a legacy/malformed event) falls back to the sender's name, not "You".
+ assert.equal(vaultApp.preview({ type: "vault.add", name: "James" }), "🔒 James added a secret to the Vault");
+ assert.equal(vaultApp.preview({ type: "vault.other" }), null);
+});
+
await test("defineApp reconciles the legacy scope enum with the personal/listed flags (#405)", () => {
const mk = (spec) => defineApp({ title: "x", glyph: "y", events: {}, initialState: () => ({}), render() {}, ...spec });
// Legacy scope declarations derive concrete flags…
diff --git a/web/static/apps.js b/web/static/apps.js
index 3b27653..d2074ea 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -325,9 +325,15 @@ export function personalApps() {
// its render() is the whole thread, no chat view and no toggle (the original
// full-pane model). "Wants a chat view" and "knows how to project into chat" are
// the same fact, so companion stays opt-in and never mandatory — see CLAUDE.md.
+//
+// 🤖 `groupChatOnly: true` splits that fact in two: the app still implements
+// chatMessage (so foldGroupAppEventBubble can project its events into a GROUP
+// chat that has it added — see shell.js), but its personal thread stays a pure
+// takeover. For a personal-instance secrets/notes tool that wants group activity
+// visible without ever growing a Chat view of its own private thread (the Vault).
export function appWantsChatView(name) {
const app = appByName(name);
- return !!(app && typeof app.chatMessage === "function");
+ return !!(app && typeof app.chatMessage === "function" && !app.groupChatOnly);
}
// appDefaultView is the view a companion app-thread opens in when the user
@@ -729,6 +735,12 @@ export function setCurrentUser(uid, name) {
currentUser = uid;
currentUserName = name || null;
}
+// 🤖 currentUserId lets a `preview(event)` hook say "You" for the reader's own
+// event — preview hooks take no ctx (they're pure `event => string`), so this
+// is the one narrow door to "who's reading" outside a full ctx (see vault.js).
+export function currentUserId() {
+ return currentUser;
+}
let resolveAppThread = null; // (appName) → Promise<threadId>
export function setAppThreadResolver(fn) {
resolveAppThread = fn;
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index 0caacc1..f22b065 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -176,7 +176,13 @@ Once you've picked a shape, copy the closest working app rather than starting co
A shape decision worth making deliberately: **a takeover secrets/notes tool should
usually NOT implement `chatMessage`**, so its events don't spill names into the
-chat feed, previews, and push notifications (a secrets vault is best built this way).
+chat feed, previews, and push notifications. 🤖 When a group still needs to *see
+that something happened* (without becoming a companion itself), pair
+`chatMessage` with **`groupChatOnly: true`**: the personal thread stays a pure
+takeover (no Chat view ever grows on it — `appWantsChatView` ignores it), while
+a group that's added the app still gets an attributed action line via
+`foldGroupAppEventBubble`. `vault.js` uses this for add/remove, deliberately
+without naming the secret in the line.
## `defineApp(spec)` — the fields
@@ -187,6 +193,7 @@ chat feed, previews, and push notifications (a secrets vault is best built this
| `glyph` | ✓ | One emoji/character used as the app's avatar. |
| `personal` | | `true` if the app may have a personal, self-thread instance (Apps menu, personal-command routing, `defaultCommand`). Defaults to `true`. |
| `groupCapable` | | `true` if the app can ALSO be added to a group chat (⋮ → Add apps), where one shared instance runs for everyone. Orthogonal to `personal`. See "Group-scoped apps". |
+| `groupChatOnly`| | 🤖 Pairs with a `chatMessage` hook: the app still projects into a GROUP chat's feed, but its own personal thread stays a takeover (never grows a Chat view). See "Companion projection" above; `vault.js` is the example. |
| `listed` | | `false` for a host-mounted app that is never user-instantiable (the chat feed, `setup`, the `groupapps` registry). Defaults to `true`. |
| `scope` | | **Deprecated** — being dissolved into the flags above (#405). Still accepted: `"personal"`/`"shared"`/`"system"` derive the flags, and vice-versa. See "Instance & discovery flags". |
| `requiresService`| | A connected-service name (e.g. `"dnsimple"`). The app is hidden from New chat until that service is linked — how a service "installs" its companion app. See `docs/connected-services.md`. |
@@ -274,6 +281,13 @@ model; it's the existing one pointed at a different thread:
host adds the prefix. In a group it's called with `{ group: true }` as a
second argument, for wording that must differ when many people read it
(Reminders drops "you asked me" there) — apps that don't care ignore it.
+ 🤖 The host also substitutes **“You”** for the reader's own action
+ (`foldGroupAppEventBubble` in `shell.js`, matching `membershipLine`'s
+ join/leave convention) — this is automatic and free; write your `text` as if
+ it always follows a name (`added “buy milk”`), never bake in “You” yourself,
+ or a bystander reading someone else's event will see “You added “buy milk””.
+ This attribution is unique to `chatMessage` — a `preview` hook (below) gets
+ no such help and must do its own “You” check.
- **@-mention runs the default command.** "@To-do buy milk" typed in the
conversation runs the app's `defaultCommand` with the rest of the text — the
event is what lands, shown as an action line, and the app joins the `@`
@@ -701,6 +715,34 @@ lights the thread's unread dot. Both default to silent (the old behaviour);
poll opts in — a new poll previews as `📊 <question>` and marks unread, while
votes stay quiet. Push wording for app events is the `notice` field (Voice).
+🤖 `preview` gets no `{ group }`-style hint and no ctx — it's a pure
+`event => string`, called for BOTH your personal thread's row and a group's.
+Two things worth knowing before you write one (learned adding vault.js's):
+
+- **It runs your event through, not a generic formatter.** Before `preview`
+ existed for a given type, the host fell back to treating the raw typed event
+ as if it were a plain chat message (reading `.text`) — which a typed event
+ doesn't have, so a group row read as the sender's name, a colon, and nothing
+ (`"Ciara: "`). This is fixed at the host level (both the live path and the
+ cold-load path in `shell.js`'s `loadThreads` now call `preview()` for any
+ typed event, updating the row only when it returns non-null) — but it means
+ **any event type your reducer owns should either have a `preview` line or
+ stay silent on purpose**; there's no longer a "raw" fallback to fall into by
+ accident, so don't leave a type unhandled by assuming something reasonable
+ will show.
+- **"You" is on you.** Unlike `chatMessage` (attributed by the host, see
+ above), `preview` has to do its own "your own event" check — import
+ `currentUserId` from `../apps.js` and compare it to `event.uid`:
+ ```js
+ import { defineApp, currentUserId } from "../apps.js";
+ preview(e) {
+ const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone";
+ return `${who} did the thing`;
+ }
+ ```
+ `currentUserId()` exists for exactly this — a narrow door to "who's reading"
+ for the pure hooks (`preview`, and any future one) that don't get a `ctx`.
+
### Embedded (native shell) mode
The iOS/macOS shell renders one thread per webview and draws its own composer,
diff --git a/web/static/apps/vault.js b/web/static/apps/vault.js
index d7f9cc9..30ba916 100644
--- a/web/static/apps/vault.js
+++ b/web/static/apps/vault.js
@@ -12,10 +12,20 @@
// member folds the same vault. Nothing about render() changes — the same
// surface, ctx bound to whichever thread it's acting in.
//
-// It is a TAKEOVER app (no `chatMessage`): the vault IS the thread's surface, and
-// its events deliberately DON'T project into the chat feed — a secrets tool must
-// not spill "Ana added GITHUB_TOKEN" into the conversation, its previews, or push
-// notifications. The audit trail lives inside the vault surface instead.
+// It is a TAKEOVER app (`groupChatOnly: true`, not a bare `chatMessage`): the
+// vault IS the thread's surface — your personal Vault never grows a Chat view,
+// and no secret's NAME ever spills into the conversation, a sidebar preview, or
+// push. 🤖 Two places still surface UNNAMED activity, "you"-aware (same as a
+// membership line — see `format.js`'s `membershipLine`):
+// - The chat list's "last message" preview (`preview` below) — "You added a
+// secret to the Vault" / "Ciara updated a secret" — for the vault's own
+// thread (personal or group), so the row doesn't sit blank.
+// - When the vault is added to a GROUP, an add/remove also posts the same
+// unnamed line into that group's chat feed (`chatMessage`'s `{ group }`
+// form), so members notice without opening it.
+// The name (which — unlike the value — every member can already see inside the
+// vault) never lands in either place. The full audit trail, WITH names, still
+// lives only inside the vault surface.
//
// Two ideas beyond a plain list:
// - Locked by default. A value renders masked (••••••) and only reveals when you
@@ -30,7 +40,7 @@
//
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
-import { defineApp, addItem, updateItem, removeItem } from "../apps.js";
+import { defineApp, addItem, updateItem, removeItem, currentUserId } from "../apps.js";
// How long a revealed value stays visible before it re-locks itself — short, so a
// vault left open on screen doesn't sit unmasked. Clicking it again re-arms it.
@@ -81,6 +91,7 @@ export const app = defineApp({
glyph: "🔒",
scope: "personal",
groupCapable: true, // can also be added to a group chat, one shared instance for all
+ groupChatOnly: true, // chatMessage below projects into a GROUP's chat feed only — never a Chat view of the personal thread (see apps/README.md "Companion projection")
initialState: () => ({ items: [], log: [] }),
events: {
"vault.add": (s, e) => {
@@ -138,6 +149,35 @@ export const app = defineApp({
},
},
+ // 🤖 chatMessage + `groupChatOnly: true` (above) is a GROUP-ONLY projection:
+ // `opts.group` is only set when the host folds a GROUP/DM chat's own feed
+ // (foldGroupAppEventBubble in shell.js), never for the personal thread's
+ // events — so this returns null there, same as having no chatMessage at all
+ // (see apps/README.md "Companion projection"). Deliberately unnamed and
+ // deliberately narrow: an ADD or REMOVE is worth a group noticing ("someone
+ // touched the vault"), but the secret's name stays out of the conversation,
+ // its previews, and push — and a value CHANGE (no new/gone item, same name)
+ // stays silent, since there's nothing new for a passerby to learn from it.
+ chatMessage(e, opts) {
+ if (!opts || !opts.group) return null;
+ if (e.type === "vault.add") return { text: "added a secret to the Vault", action: true };
+ if (e.type === "vault.remove") return { text: "removed a secret from the Vault", action: true };
+ return null;
+ },
+
+ // 🤖 The sidebar's "last message" line, for both a personal Vault thread and
+ // a group's — same unnamed shape as chatMessage above (never the secret's
+ // name), but always on (not `groupChatOnly`-gated: a preview line doesn't
+ // grow a Chat view, it's just text on a row you already see). "You" for your
+ // own action, same idea as the group action line's actor substitution.
+ preview(e) {
+ const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone";
+ if (e.type === "vault.add") return `🔒 ${who} added a secret to the Vault`;
+ if (e.type === "vault.remove") return `🔒 ${who} removed a secret from the Vault`;
+ if (e.type === "vault.change") return `🔒 ${who} updated a secret in the Vault`;
+ return null;
+ },
+
// This app's CSS travels with it — the framework injects it once. It reads the
// theme's CSS variables (var(--accent) etc.) so it follows the chosen theme.
styles: `
diff --git a/web/static/shell.js b/web/static/shell.js
index b8086b7..da80462 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1248,7 +1248,17 @@ async function loadThreads() {
if (t.last_blob && threadKeys[t.id]) {
try {
const payload = await decrypt(threadKeys[t.id], t.last_blob);
- threadPreview[t.id] = previewText(t, payload);
+ // A typed app event (e.g. vault.add) has no .text — previewText would
+ // read it as an empty chat message and render "Name: " with nothing
+ // after the colon. Route it through the OWNING APP's preview hook
+ // instead (same as the live path below), leaving the row's stale
+ // preview in place when the app declares none (silent, not blank).
+ if (isAppType(payload.type)) {
+ const pv = appPreview(payload);
+ if (pv) threadPreview[t.id] = pv;
+ } else {
+ threadPreview[t.id] = previewText(t, payload);
+ }
// Seed the unread dot from the synced watermark, applying the same
// gates as a live frame: your own last message never lights it (you
// sent it, or replying elsewhere means you read it), and app events
@@ -3978,7 +3988,9 @@ function foldAppEventBubble(threadId, ownerApp, event, meta) {
// projection leads with the actor ("Ciara added “buy milk”" — a group has many
// hands, unlike a personal app-thread where every act is yours); a message
// projection (a reminder firing) stays a bubble in the app's own voice. Apps
-// with no chatMessage stay silent in the feed, as everywhere.
+// with no chatMessage stay silent in the feed, as everywhere. The actor reads
+// "You" for the reader's own action, same as a membership line (membershipLine
+// in format.js) — an app never needs to know who's reading to get this right.
function foldGroupAppEventBubble(threadId, app, event, meta) {
if (!app || typeof app.chatMessage !== "function") return;
let line = null;
@@ -3992,11 +4004,12 @@ function foldGroupAppEventBubble(threadId, app, event, meta) {
if (!text) return;
const ts = event.ts || (meta && meta.ts) || Date.now();
const action = typeof line === "object" && !!line.action;
+ const actorName = event.uid && event.uid === myUserId ? "You" : event.name || "Someone";
foldHostEvent(
threadId,
"chat",
action
- ? { type: "chat.msg", name: event.name || "", text: `${event.name || "Someone"} ${text}`, uid: event.uid || "", ts, action: true }
+ ? { type: "chat.msg", name: event.name || "", text: `${actorName} ${text}`, uid: event.uid || "", ts, action: true }
: { type: "chat.msg", name: app.title || app.name, text, uid: "app:" + app.name, ts },
meta,
);