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/457-face-room-person-first.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Face-to-face rooms are person-first: one tile and one Members row per person (Members counts people, and only people are "(you)"), every Bot named "Bot (person)" including your own, a mention of a friend's Bot stays with that Bot after it leaves, bubble headers show just the name with the handle on hover, and your own typed line keeps your name and colour. Auto-send in a face room is now one switch per Bot, off by default ([#457](https://github.com/ThinkFlowLab/FlyBot/issues/457)).
214 changes: 130 additions & 84 deletions core/client.js

Large diffs are not rendered by default.

21 changes: 9 additions & 12 deletions core/src/client/at-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ export function mentionRows(roster, sessionId, query = '') {
const bots = here.room
? [
...here.room.members.map(id => roster.byId[id]).filter(Boolean)
.map(bot => (face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot)),
.map(bot => (face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot)),
...live.filter(p => p.kind === 'human')
.map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })),
// A remote seat already listed under its handle (a duplicate participant record) is offered once.
...uniqueBy(live.filter(p => p.remote), p => p.handle || p.id)
.map(p => ({
id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle,
...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot'), false) } : {}),
...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot')) } : {}),
})),
]
: listedChatBots(roster).filter(bot => bot.id !== here.self.id)
Expand Down Expand Up @@ -71,6 +71,7 @@ export function mentionRows(roster, sessionId, query = '') {

const isFaceRoom = room => room?.origin === 'face-to-face'
const memberCount = count => t(count === 1 ? '{count} member' : '{count} members', { count })
const botCount = count => (count === 1 ? t('1 Bot') : t('{count} Bots', { count }))
const normHandle = handle => String(handle ?? '').trim().replace(/^@+/, '').toLowerCase()
// A friend's Bot chat in the roster: its remote record (the Bot itself in older rosters).
export const remoteRecord = bot => (bot?.remote ? (bot.remote === true ? bot : bot.remote) : null)
Expand All @@ -88,14 +89,10 @@ const uniqueBy = (list, key) => {
return true
})
}
// In a face-to-face room a Bot is named as the Members panel names it: "Chief (Ann)",
// "Chief (you)" (#229, #305, #384). Only the label changes; the inserted "@Name" stays the
// one the room routes on.
const faceLabel = (person, bot, mine) => {
if (mine) return t('{bot} (you)', { bot })
if (!person) return bot
return `${bot} (${person})`
}
// In a face-to-face room a Bot is named as the Members panel names it, mine and a friend's
// alike: "Chief (Ann)", "Chief (Bob)" (#229, #305, #384, #457); "(you)" is for a person, never a
// Bot. Only the label changes; the inserted "@Name" stays the one the room routes on.
const faceLabel = (person, bot) => (person ? `${bot} (${person})` : bot)

// The chats a message here can reference: every other Bot's chat, then the latest
// conversations outside the team. Group chats stay out: a group's Session holds only
Expand Down Expand Up @@ -146,8 +143,8 @@ export function atSources({ roster, sessions, launched, iconFor }) {
...(row.bot?.role && !face ? { description: row.bot.role } : {}),
...(row.remote ? { description: row.remote.handle } : {}),
...(row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}),
// A face room's local member list holds only this home's Bots, so a count would be wrong (#305).
...(row.room ? { description: face ? t('Every Bot in this room') : memberCount(row.count) } : {}),
// A face room counts the Bots in it, mine and everyone else's (#305, #457).
...(row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {}),
icon: iconFor(row),
section: t('Bots'),
value: row.text,
Expand Down
29 changes: 22 additions & 7 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js'
import { SecretToolCell } from './secret-card.js'
import { dateLocale, t } from './i18n.js'
import { remoteMessageText } from './flybot-registry.js'
import { isListedRemoteChat, remoteBotTitle } from '../shared/remote-label.js'
import { isListedRemoteChat, normRemoteHandle, remoteBotTitle } from '../shared/remote-label.js'
import { faceBotLabel, faceSeatLabel, faceSeats } from './face-seats.js'
import { fileSize } from './flybot-slots.js'
import { HeldReplyActions } from './held-reply.js'
import { call } from './api.js'
Expand Down Expand Up @@ -279,7 +280,8 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq })
if (held === 'held' || isHeldLine(room, text, bot)) return null
const lead = h('button', { type: 'button', className: 'bt-lead', 'aria-label': t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) },
h(BotMark, { bot, size: 22 }))
const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name,
// In a face room my Bot reads as everyone's does, "Chief (Ann)" (#457).
const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, faceBotLabel(room, roster, bot) ?? bot.name,
room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null)
return h('div', { className: 'bt-group-msg' }, name,
h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead }))
Expand All @@ -292,10 +294,16 @@ export function remoteSpeaker(source, roster, sessionId) {
const handle = source.remote.handle
const member = (roster.remoteBots ?? []).find(bot => bot.handle === handle)
const chat = (roster.bots ?? []).find(bot => bot.remote?.handle === handle)
const room = roster.roomsById?.[sessionId]
// In a face-to-face room the Bot's seat carries its owner's look (Bot Social members[]): the
// bubble draws that look, as the Members panel and the waiting tiles do (#182).
const seat = faceSeatLook(roster.roomsById?.[sessionId], handle)
const name = remoteBotTitle({
const seat = faceSeatLook(room, handle)
// ...and its name, as Members and the @ menu name it: "Chief (Bob)", never a stored label plus a
// lowercase id from its handle (#457). A seat that left still names it.
const faceSeat = room?.origin === 'face-to-face'
? faceSeats(room, roster, { left: true }).find(item => !item.mine && normRemoteHandle(item.record?.handle) === normRemoteHandle(handle))
: undefined
const name = faceSeat ? faceSeatLabel(faceSeat) : remoteBotTitle({
displayName: chat?.name ?? member?.name ?? source.senderName ?? handle,
ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner,
handle,
Expand Down Expand Up @@ -327,6 +335,8 @@ function faceSeatLook(room, handle) {
const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '')

export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) {
const room = roster.roomsById?.[sessionId]
const face = room?.origin === 'face-to-face'
const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId)
const files = Array.isArray(source.attachments) ? source.attachments : []
// The friend typed this line themselves (Bot Social attested it; the host set `typedBy`): it is the
Expand All @@ -345,8 +355,11 @@ export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey
? h('button', { type: 'button', className: 'bt-lead', title: handle, 'aria-label': t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark)
: h('span', { className: 'bt-lead', title: handle }, mark)
return h('div', { className: 'bt-group-msg bt-remote-msg', 'data-remote': handle },
h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, name,
h('span', { className: 'bt-remote-handle', title: source.remote.home ?? handle, 'aria-label': t('Friend\'s Bot') }, h(GlobeMark), handle)),
// A face room's header is the one name alone; the handle is on hover only (#457).
face
? h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) }, title: handle }, name)
: h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, name,
h('span', { className: 'bt-remote-handle', title: source.remote.home ?? handle, 'aria-label': t('Friend\'s Bot') }, h(GlobeMark), handle)),
h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }),
files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null)
}
Expand Down Expand Up @@ -848,10 +861,12 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions
if (reply.other) {
return h(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi })
}
// My own line in a face room has my person mark too, as everyone else's (#457).
return h('div', { key: `user-${index}`, className: 'bt-urow', 'data-author': reply.authorId, 'data-author-name': who },
who ? h('span', { className: 'bt-uauthor' }, who) : null,
h('div', { className: 'bt-ububble' },
h(MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: 'compact' })))
h(MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: 'compact' })),
room?.origin === 'face-to-face' ? h('span', { className: 'bt-lead', title: who || undefined }, h(PersonMark, { name: who || undefined })) : null)
}
if (reply.kind === 'failed') {
return h('div', { key: `${reply.botId}-${index}`, className: 'bt-event bt-event-error', role: 'status' },
Expand Down
20 changes: 8 additions & 12 deletions core/src/client/details-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './ma
import { GlobeIcon, Favicon } from './browser-pane.js'
import { useEscape } from './overlay-hooks.js'
import { BotSettingsForm } from './bot-settings.js'
import { GroupSettingsForm, ROOM_MODES, faceSeatLabel, faceSeats, roomSubtitle, shownMode } from './group-settings.js'
import { FacePersonRow, GroupSettingsForm, ROOM_MODES, facePeople, roomSubtitle, shownMode } from './group-settings.js'
import { ModelMenu, modelOf } from './model-menu.js'
import { FolderMenu } from './folder-menu.js'
import { MemoryRow } from './memory-panel.js'
Expand Down Expand Up @@ -149,19 +149,15 @@ function SharedRoomApprovals({ room }) {
h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject')))))
}

// People first, each with the Bot they brought; my own Bot opens its chat (#229).
// One row per person, each with the Bots they brought; my own Bot opens its chat (#229, #457).
// The count is people, not Bots.
function FaceMembers({ room, roster, actions }) {
const seats = faceSeats(room, roster)
const people = facePeople(room, roster)
return h('div', null,
h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: seats.length })),
h('div', { className: 'bt-members', role: 'list' }, seats.map((seat) => {
const label = faceSeatLabel(seat)
const avatar = h(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false })
return h('div', { key: seat.key, role: 'listitem', 'data-flybot': 'face-seat', 'data-mine': seat.mine || undefined },
h('div', { className: 'bt-member' }, seat.local
? h('button', { type: 'button', className: 'bt-member-name', title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => actions.openSession(seat.local.id) }, avatar, h('span', null, label))
: h('span', { className: 'bt-member-name' }, avatar, h('span', null, label))))
})))
h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: people.length })),
h('div', { className: 'bt-members', role: 'list' }, people.map(person => h(FacePersonRow, {
key: person.key, person, className: 'bt-member', onOpenBot: id => actions.openSession(id),
}))))
}

function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) {
Expand Down
66 changes: 54 additions & 12 deletions core/src/client/face-seats.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
// Who sits in a face-to-face room, named one way everywhere: the Members panel, the waiting
// tiles and the @ chips in a bubble (#229, #305). Pure, so the bubble module can use it.
// tiles, the @ menu, the bubble headers and the @ chips in a bubble (#229, #305, #457). Pure, so
// the bubble module can use it.
import { t } from './i18n.js'
import { cleanPeerName, ownerNameOf } from '../shared/remote-label.js'

// A face-to-face room lists people, each with the Bot they brought, named one way for every
// member: "Chief (Bob)", "Chief (you)" (#229, #384). Names come from the participant records
// (server member fields for a friend, the roster for my own Bot), never from message text.
export function faceSeats(room, roster) {
const live = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.kind !== 'human' && p.leftSeq === undefined)
const seats = live.map((p) => {
// A face-to-face room lists people, each with the Bots they brought, and names every Bot one way
// for every member: "Chief (Bob)", my own "Chief (Ann)" too (#229, #384, #457). Names come from
// the participant records (server member fields for a friend, the roster for my own Bot), never
// from message text. `left: true` keeps the seats of people who left, after the live ones, so a
// line sent before a leave still names the Bot it named (#457).
export function faceSeats(room, roster, { left = false } = {}) {
const records = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.kind !== 'human')
const seat = (p) => {
const local = p.remote ? undefined : roster.byId[p.id]
return {
key: p.handle || p.id,
Expand All @@ -18,18 +21,57 @@ export function faceSeats(room, roster) {
bot: local ? local.name : cleanPeerName(p.displayName || p.name, 'Bot'),
mine: Boolean(local),
}
})
}
const seats = records.filter(p => p.leftSeq === undefined).map(seat)
for (const id of room.members ?? []) {
const bot = roster.byId[id]
if (!bot || seats.some(seat => seat.local?.id === id)) continue
if (!bot || seats.some(item => item.local?.id === id)) continue
seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ''), bot: bot.name, mine: true })
}
return seats
if (!left) return seats
const gone = records.filter(p => p.leftSeq !== undefined && !seats.some(item => item.key === (p.handle || p.id))).map(item => ({ ...seat(item), left: true }))
return [...seats, ...gone]
}

// One name per Bot (#384, #305): "Chief (Bob)", and my own "Chief (you)".
// One name per Bot (#384, #305, #457): "Chief (Bob)", and my own the same way, "Chief (Ann)".
// "(you)" belongs to a person, never to a Bot.
export function faceSeatLabel(seat) {
if (seat.mine) return t('{bot} (you)', { bot: seat.bot })
if (!seat.person) return seat.bot
return `${seat.bot} (${seat.person})`
}

// My own Bot as the room names it (#457): "Chief (Ann)", the person being the owner name Bot
// Social stamped on its seat, else my own name. Outside a face room a Bot is its plain name.
export function faceBotLabel(room, roster, bot) {
if (room?.origin !== 'face-to-face' || !bot) return bot?.name
const seat = faceSeats(room, roster, { left: true }).find(item => item.local?.id === bot.id || (item.record?.id !== undefined && item.record.id === bot.id))
return seat ? faceSeatLabel(seat) : bot.name
}

// People are told apart by home, not by display name: two owners may both be "Alex".
export function facePersonKey(seat) {
if (seat.mine) return ':you'
const record = seat.record ?? {}
const home = String(record.ownerHome || record.home || '').trim().toLowerCase()
if (home) return `home:${home}`
const parts = String(record.handle || '').trim().toLowerCase().replace(/^@+/, '').split('.')
if (parts.length >= 3 && parts.at(-1) === 'home') return `home:${parts.slice(-2).join('.')}`
return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}`
}

// The room person-first (#457): one entry per person, mine first, each with the Bots they brought.
export function facePeople(room, roster) {
const people = []
for (const seat of faceSeats(room, roster)) {
const key = facePersonKey(seat)
const known = people.find(person => person.key === key)
if (known) { known.seats.push(seat); continue }
people.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, '') : '') || t('Someone'), mine: seat.mine, seats: [seat] })
}
return [...people.filter(person => person.mine), ...people.filter(person => !person.mine)]
}

// "Ann (you)" for me, "Bob" for anyone else: "(you)" only ever on a person (#457).
export function facePersonLabel(person) {
return person.mine ? t('{name} (you)', { name: person.name }) : person.name
}
Loading
Loading