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/939-ticket-seat-lines.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A group joined through an invite link now shows the other home's messages in the conversation, on both sides, including the host's recent lines from before the guest joined ([#939](https://github.com/ThinkFlowLab/FlyBot/issues/939)). The member count includes the remote Bots the roster already lists.
2,823 changes: 1,415 additions & 1,408 deletions core/client.js

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions core/src/client/group-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,19 @@ export function FaceLead({ room, roster, pick }) {
error ? h('div', { className: 'bt-set-hint', role: 'alert' }, error) : null)
}

// Humans, this home's Bots, and remote Bots still seated. The same people the Members card lists.
export function seatedCount(room) {
const live = room?.presentedParticipants ?? room?.participants ?? []
const humans = live.filter(p => p.kind === 'human' && p.leftSeq === undefined).length
const remotes = live.filter(p => p.remote && p.leftSeq === undefined).length
return humans + remotes + (room?.members?.length ?? 0)
}

export function roomSubtitle(room, roster) {
if (room.origin === 'face-to-face') return faceRoomCount(room, roster)
const admin = roster.byId[room.admin]
return [t('{count} members', { count: room.members.length }), admin ? t('Admin {name}', { name: admin.name }) : t('No admin')].join(' · ')
const count = seatedCount(room)
return [t(count === 1 ? '{count} member' : '{count} members', { count }), admin ? t('Admin {name}', { name: admin.name }) : t('No admin')].join(' · ')
}

// A group chat's settings as grouped cards: its name and notice, who answers, its
Expand Down Expand Up @@ -214,7 +223,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
: null,
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.')
members.length + humans.length + remotes.length < 2 ? t('Add a Bot first: a group chat needs at least two.')
: admin ? t('{name} leads this group: it can call members with @Name, change the group, and post here.', { name: admin.name })
: t('No admin yet. An admin can call members with @Name, change the group, and post here.'))),
// #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
Expand Down
3 changes: 2 additions & 1 deletion core/src/client/palette.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useEscape } from './overlay-hooks.js'
import { QUICK_EDGES, useFlip, useGlide } from './motion.js'
import { t } from './i18n.js'
import { isListedRemoteChat, pickerBotLabel } from '../shared/remote-label.js'
import { seatedCount } from './group-settings.js'

// Search is a command palette: a 560px dialog, a search header, and 49px
// rows (24px avatar, title, subtitle). Bots and groups match on name and role; past
Expand Down Expand Up @@ -56,7 +57,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) {
run: async () => { if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); actions.openSession(bot.id) },
})),
...roster.rooms.filter(room => matchesName(room.name)).map(room => ({
key: room.id, avatar: h(RoomAvatar, { room, roster, size: 24 }), title: room.name, subtitle: t(room.members.length === 1 ? '{count} member' : '{count} members', { count: room.members.length }),
key: room.id, avatar: h(RoomAvatar, { room, roster, size: 24 }), title: room.name, subtitle: t(seatedCount(room) === 1 ? '{count} member' : '{count} members', { count: seatedCount(room) }),
badge: room.hidden ? t('Hidden') : undefined,
run: async () => { if (room.hidden) await actions.setFlags(room.id, { hidden: false }); actions.openSession(room.id) },
})),
Expand Down
4 changes: 2 additions & 2 deletions core/src/client/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js'
import { useEscape } from './overlay-hooks.js'
import { BotSettingsForm, SwitchRow } from './bot-settings.js'
import { Segmented, useGlide } from './motion.js'
import { GroupSettingsForm, NewGroupForm } from './group-settings.js'
import { GroupSettingsForm, NewGroupForm, seatedCount } from './group-settings.js'
import { CreateBot } from './create-bot.js'
import { KeyIcon, SecretsPage } from './secret-card.js'
import { UsagePage } from './usage-page.js'
Expand Down Expand Up @@ -158,7 +158,7 @@ function GroupsPage({ roster, actions, run }) {
h('span', { className: 'bt-set-bot' },
h(RoomAvatar, { room, roster, size: 28 }),
h('span', { className: 'bt-row-title' }, room.name),
h('span', { className: 'bt-tag' }, t('{count} members', { count: room.members.length })),
h('span', { className: 'bt-tag' }, t(seatedCount(room) === 1 ? '{count} member' : '{count} members', { count: seatedCount(room) })),
admin ? h('span', { className: 'bt-tag bt-tag-admin' }, h(AdminStar), admin.name) : null,
room.hidden ? h('span', { className: 'bt-tag' }, t('Hidden')) : null),
h('span', { className: 'bt-chevron' }, h(ChevronRightIcon)))
Expand Down
36 changes: 34 additions & 2 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { failureLine } from './turns.js'
import { currentParticipants, isFaceRoom } from './room-membership.js'
import { FACE_LEAD_NOTE, FACE_LEAD_ROUTE, faceLeadIsRemote, faceMemberNote, faceRoster, isFaceLead } from './face-lead.js'
import { faceMentions } from './face-mentions.js'
import { cleanName, stripSessionChips } from './delivery.js'
import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js'
import { createDshRoomParticipant } from './room-participant.js'

const ROOM_LOG_MAX = 60
Expand Down Expand Up @@ -624,7 +624,7 @@ export function install(rt) {
// stopped is an event of the round.
const event = item => item.failed || item.resumed || item.stopped || item.notice || item.botId === 'face-notice'
// A post a running round already showed and logged when it arrived (#455) is not logged again.
for (const post of posts.filter(item => !event(item) && !item.shown)) {
for (const post of posts.filter(item => !event(item) && !item.shown && !item.displayOnly)) {
// Image readings go into the log only; `@` names are picked from what the user typed.
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
// A face-room line the send path already logged and sent is not logged or sent again (#287).
Expand Down Expand Up @@ -738,6 +738,10 @@ export function install(rt) {
if (typeof reply !== 'string' || reply.trim() === '') return reply
const posted = appendRoom(room, rt.authoredRoomLine?.(room, { who: member.name, botId, authorId: botId, authorKind: 'bot', text: reply }) ?? { who: member.name, botId, text: reply })
void rt.save().catch(() => {})
// The reply is in the log and on its way back. The pane is this room's Session, which that append does not touch.
await rt.showSharedLine?.(room, {
who: member.name, text: reply, local: true, senderSessionId: botId, remoteSeq: posted.seq, via: 'local-reply',
}).catch(error => rt.warn('ds-bot: a reply in %s was not shown: %s', room.name, error))
return { seq: posted.seq, text: reply }
}

Expand Down Expand Up @@ -843,6 +847,8 @@ export function install(rt) {
}

function openingSpeakers(room, opener) {
// A line already in the log, posted so the pane can draw it. `@` in it is not a new call.
if (opener.displayOnly === true) return []
const everyone = room.members.map(rt.botOf).filter(Boolean)
const members = everyone.filter(bot => bot.id !== opener.botId)
const admin = members.find(bot => rt.isAdminOf(room, bot.id))
Expand Down Expand Up @@ -914,6 +920,15 @@ export function install(rt) {
function showInRound(room, sender, text, extra) {
const round = [...rt.roomProgress.values()].findLast(entry => entry.roomId === room.id)
if (round === undefined || text === '') return false
// Already logged by the share path. Draw it in this round; the later followup is marked shown.
if (extra.displayOnly === true) {
if (extra.remote?.handle) {
round.replies.push({ kind: 'remote', remote: true, botId: sender.id, handle: extra.remote.handle, name: sender.name, text, time: Date.now() })
} else if (rt.botOf(sender.id)) {
round.replies.push({ botId: sender.id, name: sender.name, color: rt.botOf(sender.id)?.color, text, time: Date.now() })
}
return true
}
if (extra.human) {
round.replies.push(personReply(extra.human, text))
return true
Expand Down Expand Up @@ -971,6 +986,23 @@ export function install(rt) {
}
rt.postToRoom = postToRoom

// A shared-room or ticket-seat line is already in room.log. Put it in the Session so the pane
// draws it, without logging it again (that would fan it out) and without opening a round.
rt.showSharedLine = async function showSharedLine(room, spec = {}) {
const text = unwrapUntrusted(String(spec.text ?? '')).trim()
if (!room || text === '') return
const local = spec.local === true
const handle = typeof spec.remote?.handle === 'string' ? spec.remote.handle : undefined
const sender = {
id: spec.senderSessionId || handle || 'shared',
name: cleanName(spec.who || 'member'),
}
await postToRoom(room, sender, text, {
displayOnly: true,
...(local || !handle ? {} : { remote: { handle, ...(typeof spec.remote.typedBy === 'string' && spec.remote.typedBy ? { typedBy: spec.remote.typedBy } : {}) } }),
})
}

// A member the round moved on from (it went on working, went quiet or waited for an
// approval) posts what its turn ended with. The member that asked for it and anyone
// it names answer; past the late round limit nobody does, so late posts cannot keep a
Expand Down
117 changes: 114 additions & 3 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
import { randomBytes } from 'node:crypto'
import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js'
import { encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { HISTORY_MAX, HISTORY_TEXT, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js'
import { cleanName, fenceUntrusted, isFencedUntrusted, unwrapUntrusted } from './delivery.js'
import { hasAnyGrant, mayShareRoom } from './reach.js'
Expand Down Expand Up @@ -1061,6 +1061,99 @@ export function install(rt) {
return text
}

// Chat lines the join copies to a guest. Events (joined, left, notices) are not messages.
// A handle is this home's Bot Social identity for a local Bot, or the seat a remote line came from.
function shareHistory(room) {
const lines = (room.log ?? []).filter(line => !line.kind && line.text)
return lines.slice(-HISTORY_MAX).map((line) => {
const botId = line.authorKind === 'bot' ? (line.authorId ?? line.botId) : undefined
const carried = typeof line.via === 'string' && line.via.startsWith('@') ? line.via : undefined
const claimed = typeof line.authorId === 'string' && line.authorId.startsWith('@') ? line.authorId : undefined
const own = botId ? state.bots?.[botId]?.fields?.social?.member?.handle : undefined
const handle = carried || claimed || own
return {
who: cleanName(line.who || 'member'),
text: seatText(line).slice(0, HISTORY_TEXT),
seq: line.seq ?? 0,
...(line.authorKind === 'human' || line.authorKind === 'bot' ? { authorKind: line.authorKind } : {}),
...(line.authorId ? { authorId: String(line.authorId).slice(0, 120) } : {}),
...(typeof handle === 'string' && handle.startsWith('@') ? { handle } : {}),
}
})
}

// One show per remote line. A redelivery still appends (a second turn is already refused);
// it must not draw a second bubble.
const SHOWN_KEPT = 200
function claimShow(room, key) {
if (!key) return true
room.shownShare ??= []
if (room.shownShare.includes(key)) return false
room.shownShare = [...room.shownShare, key].slice(-SHOWN_KEPT)
return true
}

// The conversation pane is the room Session. `room.log` only feeds the sidebar preview.
// Post the line there once, display-only, so it is not logged again and opens no Bot round.
async function showShareLine(room, spec) {
if (!room || typeof rt.showSharedLine !== 'function') return
const text = unwrapUntrusted(String(spec.text ?? '')).trim()
if (!text) return
const seq = Number.isFinite(Number(spec.remoteSeq)) ? Number(spec.remoteSeq) : undefined
const key = seq === undefined ? undefined : `${spec.via || 'line'}:${seq}`
if (!claimShow(room, key)) return
try {
await rt.showSharedLine(room, { ...spec, text })
} catch (error) {
rt.warn?.('ds-bot: a shared line was not shown in %s: %s', room.name, error)
}
}

// History is stranger-written, like the notice: only this mirror's room, only its inviter.
function historyOf(data, room, event) {
if (data?.op !== 'join' || !Array.isArray(data.history)) return undefined
const bound = room?.sharedFrom ?? {}
const same = (a, b) => {
const left = String(a ?? '').trim().toLowerCase()
return left !== '' && left === String(b ?? '').trim().toLowerCase()
}
if (bound.remoteRoomId && !same(data.roomId, bound.remoteRoomId)) return undefined
if (bound.inviterHome && !same(event?.fromHome, bound.inviterHome)) return undefined
if (bound.inviterHandle && !same(event?.fromHandle, bound.inviterHandle)) return undefined
return data.history.slice(-HISTORY_MAX).filter(entry => entry && entry.who && entry.text)
}

// Copy those lines into a mirror that has no chat lines yet. No turn: they were already said.
async function applyShareHistory(room, entries, event, data) {
if (!room || !Array.isArray(entries) || entries.length === 0) return
if ((room.log ?? []).some(line => !line.kind && line.text)) return
const fromHandle = String(event?.fromHandle ?? '')
for (const entry of entries) {
const who = cleanPeerName(entry.who, 'a friend')
const human = entry.authorKind !== 'bot'
const handle = typeof entry.handle === 'string' && entry.handle.startsWith('@') ? entry.handle : fromHandle
const authorId = human
? `history#${String(entry.authorId || 'person').slice(0, 40)}`
: String(handle || entry.authorId || who).slice(0, 120)
append(room, rt.authoredRoomLine(room, {
who,
text: String(entry.text).slice(0, HISTORY_TEXT),
authorId,
authorKind: human ? 'human' : 'bot',
remote: true,
...(human ? {} : { botId: authorId }),
}))
await showShareLine(room, {
who,
text: entry.text,
remoteSeq: entry.seq,
via: `${data.roomId}:${fromHandle}`,
senderSessionId: handle || fromHandle,
remote: { handle: handle || fromHandle, ...(human ? { typedBy: who } : {}) },
})
}
}

// #904: one remote participant per roster entry. A roster is the whole list, so an entry it drops is a
// Bot the host no longer keeps here: it leaves this mirror's list, and nothing of it stays addressable.
function applyRoster(room, entries) {
Expand Down Expand Up @@ -1161,7 +1254,7 @@ export function install(rt) {
invitedHandle: remote.handle,
seq: room.seq ?? 0,
ticket: remote.ticket,
...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '' } : {}),
...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}),
...seat.from,
}, 'room-share', seat.botId, room.id)
continue
Expand All @@ -1173,7 +1266,7 @@ export function install(rt) {
name: room.name,
invitedHandle: remote.handle,
seq: room.seq ?? 0,
...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '' } : {}),
...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}),
...from,
}, 'room-share')
}
Expand Down Expand Up @@ -1679,6 +1772,14 @@ export function install(rt) {
ownerName: ownerNameSafe(seat),
kind: data.kind === 'message' ? undefined : data.kind,
}))
await showShareLine(room, {
who,
text: data.text || '',
remoteSeq: data.seq,
via: seat.handle,
senderSessionId: seat.handle,
remote: { handle: seat.handle, ...(human ? { typedBy: who } : {}) },
})
await rt.save?.()
// The other homes in the room see it too; the home it came from already has it.
void rt.fanoutRoomLine?.(room, posted)
Expand Down Expand Up @@ -2163,6 +2264,7 @@ export function install(rt) {
if (typeof notice === 'string') room.notice = notice
syncMembers(room)
}
if (data.op === 'join') await applyShareHistory(room, historyOf(data, room, event), event, data)
let called = []
if (data.op === 'line') {
const who = cleanPeerName(data.who || transportName, transportName)
Expand All @@ -2182,6 +2284,15 @@ export function install(rt) {
...(author.authorKind === 'bot' ? { botId: author.authorId } : {}),
kind: data.kind === 'message' ? undefined : data.kind,
}))
const speaker = author.authorKind === 'bot' && String(author.authorId).startsWith('@') ? author.authorId : fromHandle
await showShareLine(room, {
who,
text: data.text || '',
remoteSeq: data.seq,
via: `${data.roomId}:${fromHandle}`,
senderSessionId: speaker || fromHandle,
remote: { handle: speaker || fromHandle, ...(author.authorKind !== 'bot' ? { typedBy: who } : {}) },
})
// #825: the line names Bots, and one of them is mine. The remote home resolved the call where
// mentions are resolved, so this side never reads @names out of the text.
const named = localCalls(room, data.calls)
Expand Down
2 changes: 2 additions & 0 deletions core/src/host/turns.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,8 @@ export function install(rt) {
...(source.human ? { human: source.human } : {}),
// Shown (and logged) by the round that was running when it arrived (groups.js, #455).
...(source.shown === true ? { shown: true } : {}),
// Already in the room log; the relay must not append it again or call the Bots it names.
...(source.displayOnly === true ? { displayOnly: true } : {}),
// The round that was running already called the members this line names (#932).
...(source.called === true ? { called: true } : {}),
...(source.plain ? { plain: true } : {}),
Expand Down
Loading
Loading