Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/876-members-invite-link.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A local group’s Members card has an **Invite by link** row: make a link, show it as a QR code, copy it, watch its countdown, or withdraw it. Someone who is not a friend can paste or scan that link in the Face-to-face dialog, see the group first, and join it with the Bots they pick.
1 change: 1 addition & 0 deletions changelog.d/884-link-room-live-fixes.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a group joined by link, the guest can write too, under their own name, and lines arrive as written. The guest’s Bot no longer drops out of the inviter’s group after the first message. A ticket the guest never claimed opens nothing.
14 changes: 13 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1539,7 +1539,7 @@ function faceAliases(participant, name) {
}

// src/client/flybot-registry.js
var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"];
var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.room.members", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"];
function createRegistry() {
const entries2 = new Map(SLOTS.map((name) => [name, []]));
const listeners2 = /* @__PURE__ */ new Set();
Expand Down Expand Up @@ -2261,6 +2261,7 @@ var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload);
function registry2() {
const slots = registry();
globalThis.__flybot__.call ??= call2;
globalThis.__flybot__.host ??= call;
globalThis.__flybot__.BotMark ??= BotMark;
globalThis.__flybot__.BotAvatar ??= BotAvatar;
globalThis.__flybot__.lookOf ??= lookOf;
Expand Down Expand Up @@ -2353,6 +2354,15 @@ function FlyBotRoomOverview({ room: room2, roster, actions }) {
(0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } })
)));
}
function FlyBotRoomMembers({ room: room2, roster, actions }) {
const entries2 = useSlot("flybot.room.members");
if (entries2.length === 0) return null;
return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)(
"div",
{ key: entry.id, className: "bt-flybot-room-members", "data-flybot": entry.id },
(0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2, host: call } })
)));
}
function FlyBotAccountHeader({ actions }) {
const entries2 = useSlot("flybot.account.header");
if (entries2.length === 0) return null;
Expand Down Expand Up @@ -12556,6 +12566,8 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
)),
(0, import_react30.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel"))
] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react30.createElement)(PlusIcon), t("Add member")) : null,
// #876: a plugin's rows for this room (Invite by link), only in a group that lives here.
room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react30.createElement)(FlyBotRoomMembers, { room: room2, roster, actions }),
(0, import_react30.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react30.createElement)(
"span",
{ className: "bt-set-hint" },
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/flybot-registry.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The FlyBot slot registry, free of React so the "@" menu and tests can use it.
// See flybot-slots.js for what each slot carries.
export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']
export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']

export function createRegistry() {
const entries = new Map(SLOTS.map(name => [name, []]))
Expand Down
12 changes: 12 additions & 0 deletions core/src/client/flybot-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
// `inline: true` with `policy`: instead, the buttons drawn on a held reply's own bubble in the
// chat (#418), component({ held, decide, call, actions }); held = { id, by, status, to, toName, from }
// flybot.room.overview { id, order, component({ room, roster, actions, call }) } top of a group's overview
// flybot.room.members { id, order, component({ room, roster, actions, call, host }) } rows at the foot of a local group's
// Members card (#876: Invite by link); `host(endpoint, payload)` calls a core endpoint, which `call` cannot
// flybot.session.dock { id, order, policy?, component({ sessionId, roster, room, bot, actions, call, claim, t }) } above the composer;
// a dock that draws the cards of one approval policy sets `policy` and calls claim(ids) with the
// approval ids it shows: the overlay then draws no second card for them (#260)
Expand Down Expand Up @@ -50,6 +52,8 @@ export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payloa
export function registry() {
const slots = baseRegistry()
globalThis.__flybot__.call ??= call
// A core endpoint by its own name (`room-invite-mint`), for a plugin row in a core card (#876).
globalThis.__flybot__.host ??= callHost
// Classic-script plugins cannot import mark.js; they render the same Bot look
// the sidebar / session list / chat header use via these host components.
globalThis.__flybot__.BotMark ??= BotMark
Expand Down Expand Up @@ -159,6 +163,14 @@ export function FlyBotRoomOverview({ room, roster, actions }) {
h(Safe, { entry, props: { room, roster, actions, call } }))))
}

// Rows a plugin adds at the foot of a local group's Members card (#876: Invite by link).
export function FlyBotRoomMembers({ room, roster, actions }) {
const entries = useSlot('flybot.room.members')
if (entries.length === 0) return null
return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-room-members', 'data-flybot': entry.id },
h(Safe, { entry, props: { room, roster, actions, call, host: callHost } }))))
}

export function FlyBotAccountHeader({ actions }) {
const entries = useSlot('flybot.account.header')
if (entries.length === 0) return null
Expand Down
3 changes: 3 additions & 0 deletions core/src/client/group-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Segmented } from './motion.js'
import { FolderMenu } from './folder-menu.js'
import { t } from './i18n.js'
import { StandingApprovalsCard } from './standing-approvals.js'
import { FlyBotRoomMembers } from './flybot-slots.js'
import { remoteBotTitle } from '../shared/remote-label.js'

export const ROOM_MODES = [
Expand Down Expand Up @@ -205,6 +206,8 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
: outside.length + outsidePeople.length + outsideRemotes.length > 0
? h('button', { type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => setAdding(true) }, h(PlusIcon), t('Add member'))
: null,
// #876: a plugin's rows for this room (Invite by link), only in a group that lives here.
room.origin === 'face-to-face' || room.origin === 'inbound-share' ? null : h(FlyBotRoomMembers, { room, roster, actions }),
h('div', { className: 'bt-set-row bt-gs-note' }, h('span', { className: 'bt-set-hint' },
// The host checks for two members on every change, even a rename.
members.length < 2 ? t('Add a Bot first: a group chat needs at least two.')
Expand Down
38 changes: 33 additions & 5 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle,
import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js'
import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js'
import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js'
import { cleanName, fenceUntrusted, isFencedUntrusted, unwrapUntrusted } from './delivery.js'
import { hasAnyGrant, mayShareRoom } from './reach.js'
import { mentionSpans } from '../shared/mentions.js'

Expand Down Expand Up @@ -912,6 +912,17 @@ export function install(rt) {
}
}

// A line as written, for a link's seat (#884): the far home fences it once with `who`, so a fence
// stored here is opened first, never nested (a nested one reads as markup to the person).
// Who a person's own line is said by on a link's seat: a name they gave stays; this home's own
// placeholder (`You`, or the bare member id) becomes the owner's Bot Social name.
const seatSayer = (line, who, botId) => {
if (line.authorKind !== 'human' || line.remote === true) return who
if (who !== 'You' && who !== cleanName(String(line.authorId ?? ''))) return who
return cleanPeerName(state.bots?.[botId]?.fields?.social?.member?.ownerName, who)
}
const seatText = line => (!line.text ? `[${cleanName(line.kind || 'event')}]` : isFencedUntrusted(line.text) ? unwrapUntrusted(line.text) : String(line.text))

// #884: a seat a ticket admitted is reached from the Bot its link named, under that room's scope only,
// and is told about the room by that Bot.
const isTicketSeat = p => p?.remote === true && p.admittedBy === 'ticket'
Expand Down Expand Up @@ -1547,17 +1558,21 @@ export function install(rt) {
const seatedAuthor = room.sharedFrom?.ticket && author && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote && p.id === author) ? author : undefined
const sender = seatedAuthor ?? room.sharedFrom?.localBotId ?? shareSpeaker(room)
if (!sender) return
// A link's room (#884): the owner's own line is said under their Bot Social name, never this
// home's "You", and the text goes as written: the inviter's home fences it once, on arrival.
const ticketed = Boolean(room.sharedFrom?.ticket)
const sayer = ticketed ? seatSayer(line, who, sender) : who
const result = await sendShareTo(room, { handle: inviter, id: inviter }, {
op: 'line',
roomId: room.sharedFrom?.remoteRoomId,
name: room.name,
invitedHandle: inviter,
seq: line.seq,
who,
who: sayer,
kind: line.kind || 'message',
authorKind: line.authorKind,
authorId: line.authorId,
text: `${who}: ${body}`,
text: ticketed ? seatText(line) : `${who}: ${body}`,
hop: Number(line.hop) || ((Number(room.hop) || 0) + 1),
calls: callsIn(room, line.text),
...shareInviter(room),
Expand Down Expand Up @@ -1588,11 +1603,11 @@ export function install(rt) {
name: room.name,
invitedHandle: remote.handle,
seq: line.seq,
who,
who: seatSayer(line, who, seat.botId),
kind: line.kind || 'message',
authorKind: line.authorKind,
authorId: line.authorId,
text: `${who}: ${body}`,
text: seatText(line),
hop: Number(line.hop) || (line.authorKind === 'bot' && line.remote !== true ? (Number(room.hop) || 0) + 1 : 0),
calls: callsIn(room, line.text),
...seat.from,
Expand Down Expand Up @@ -1775,6 +1790,14 @@ export function install(rt) {
}
}
const ticket = typeof data.ticket === 'string' && TICKET_SHAPE.test(data.ticket) ? data.ticket : undefined
// #884: a join that says it comes through a link is one this home claimed: that inviter, that room,
// and a Bot the claim brought. A ticket nobody here claimed opens nothing.
if (ticket && data.op === 'join') {
const claim = state.roomClaims?.[ticket]
const same = (a, b) => String(a ?? '').toLowerCase() === String(b ?? '').toLowerCase() && String(a ?? '') !== ''
if (!claim || !same(claim.inviterHandle, event.fromHandle) || String(claim.roomId ?? '') !== String(data.roomId ?? '')
|| (localBotId && !(claim.bots ?? []).includes(localBotId))) return { handled: true, refused: 'unclaimed' }
}
if (data.op === 'leave') {
if (!room) return { handled: true, room: undefined }
// #884: a seat of a room that holds several Bots of mine ends for the Bot it names; the room
Expand Down Expand Up @@ -1917,6 +1940,11 @@ export function install(rt) {
inviteNotice: true,
maxHops: Number.isFinite(Number(draft.maxHops)) ? Math.max(0, Math.floor(Number(draft.maxHops))) : DEFAULT_MAX_HOPS,
participants: [
// A link admits the person with their Bots (#878): the owner can write in the room it opened.
...(typeof draft.ticket === 'string' && draft.ticket ? [{
kind: 'human', id: ownerId, role: 'member', displayName: displayNameFor(state, ownerId, 'You'),
joinedSeq: draft.joinedSeq ?? 0, joinedAt: draft.invitedAt ?? at,
}] : []),
...(localBotId ? [{
kind: 'bot',
id: localBotId,
Expand Down
2 changes: 1 addition & 1 deletion core/test/face-people.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function load(entry) {
require: (name) => {
const platform = {
react: {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }),
useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment',
},
Expand Down
2 changes: 1 addition & 1 deletion core/test/face-person-first.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function load(entry) {
require: (name) => {
const platform = {
react: {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }),
useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment',
},
Expand Down
2 changes: 1 addition & 1 deletion core/test/flybot-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ test('the client registry orders entries, replaces by id, and notifies', () => {
// The replaced entry stays: disposing the old one does not remove its successor.
assert.deepEqual(slots.entries('flybot.sidebar.section').map(e => e.id), ['b', 'a'])
assert.throws(() => slots.register('nope', { id: 'x' }), /unknown slot/)
assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'])
assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'])
})

test('new-chat action rows sit after New group chat and skip a failing plugin', () => {
Expand Down
2 changes: 1 addition & 1 deletion core/test/group-bot-label.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async function load(entry) {
require: (name) => {
const platform = {
react: {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }),
useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment',
},
Expand Down
60 changes: 60 additions & 0 deletions core/test/room-members-slot.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'

// #876: the Members card of a group that lives here draws a plugin's rows (the social plugin's
// Invite by link), and hands them `host` for core endpoints, which the plugin's `call` cannot reach.

async function loadSlots() {
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../src/client/flybot-slots.js', import.meta.url))],
bundle: true, format: 'cjs', platform: 'node', write: false,
external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'],
})
const module = { exports: {} }
const sandbox = {
module, exports: module.exports, setTimeout, clearTimeout, console,
require: (name) => {
const platform = {
react: {
Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }),
useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment',
},
'react-dom': { createPortal: node => node },
'@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text },
}
if (!(name in platform)) throw new Error(`no platform module ${name}`)
return platform[name]
},
}
sandbox.globalThis = sandbox
runInNewContext(bundle.outputFiles[0].text, sandbox)
return { ...module.exports, sandbox }
}

test('#876: a plugin row in the Members card gets the room and a host call for core endpoints', async () => {
const { FlyBotRoomMembers, registry, sandbox } = await loadSlots()
const slots = registry()
assert.equal(typeof sandbox.__flybot__.host, 'function', 'the registry also carries the core call')
const Row = () => null
slots.register('flybot.room.members', { id: 'social-room-invite', order: 50, component: Row })
const room = { id: 'r1', name: 'Trip plan' }
const tree = FlyBotRoomMembers({ room, roster: {}, actions: {} })
const wrap = tree.children[0]
assert.equal(wrap.props['data-flybot'], 'social-room-invite')
const safe = wrap.children[0]
assert.equal(safe.props.entry.component, Row)
assert.equal(safe.props.props.room, room)
assert.equal(typeof safe.props.props.host, 'function')
assert.equal(typeof safe.props.props.call, 'function')
})

test('#876: the Members card draws the plugin rows only in a group that lives here', () => {
const source = readFileSync(new URL('../src/client/group-settings.js', import.meta.url), 'utf8')
assert.match(source, /room\.origin === 'face-to-face' \|\| room\.origin === 'inbound-share' \? null : h\(FlyBotRoomMembers, \{ room, roster, actions \}\)/,
'a face room has its own invite card and a shared room is not ours to invite to')
})
Loading
Loading