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/182-face-room-looks.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Face-to-face rooms: a Bot now looks the same on every home and in every place (waiting tiles, chat bubbles, Members, sidebar). A Bot with no look of its own sends the look its home generates for it to Bot Social, before it joins, so the other home no longer invents a different one; your own Bot in the waiting dialog uses its roster look right after joining; a friend's Bot speaks in the room with the look its owner gave it. Other people show their initial on a color picked from their name instead of a grey silhouette ([#182](https://github.com/ThinkFlowLab/FlyBot/issues/182)).
36 changes: 29 additions & 7 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1905,7 +1905,18 @@ function StarIcon({ size = 8 }) {
(0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" })
);
}
function PersonMark({ size = 22 }) {
var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase();
var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`);
function PersonMark({ size = 22, name }) {
const initial = personInitial(name);
if (initial) {
return (0, import_react.createElement)("span", {
className: "bt-person-mark bt-person-initial",
"data-initial": initial,
style: { width: size, height: size, background: inkFill(personColor(name)), fontSize: Math.max(9, Math.round(size * 0.46)) },
"aria-hidden": true
}, initial);
}
return (0, import_react.createElement)(
"span",
{ className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true },
Expand Down Expand Up @@ -3791,6 +3802,7 @@ var CONVERSATION_CSS = `
.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1}
.bt-think[open] .bt-think-caret{transform:rotate(90deg)}
.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))}
.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none}
.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))}
.bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))}
.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)}
Expand Down Expand Up @@ -5261,7 +5273,7 @@ function HeldReply({ held, roster, sessionId, actions, closing, children }) {
);
}
function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) {
const lead = (0, import_react8.createElement)("span", { className: "bt-lead", title: name }, (0, import_react8.createElement)(PersonMark));
const lead = (0, import_react8.createElement)("span", { className: "bt-lead", title: name }, (0, import_react8.createElement)(PersonMark, { name }));
return (0, import_react8.createElement)(
"div",
{ className: "bt-group-msg bt-person-msg", "data-person": name },
Expand Down Expand Up @@ -5349,17 +5361,18 @@ function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi,
(0, import_react8.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead })
);
}
function remoteSpeaker(source, roster) {
function remoteSpeaker(source, roster, sessionId) {
const handle = source.remote.handle;
const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle);
const chat = (roster.bots ?? []).find((bot2) => bot2.remote?.handle === handle);
const seat = faceSeatLook(roster.roomsById?.[sessionId], handle);
const name = remoteBotTitle({
displayName: chat?.name ?? member?.name ?? source.senderName ?? handle,
ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner,
handle,
isChief: member?.isChief ?? chat?.remote?.isChief
});
const bot = chat ?? lookBotForRemote({
const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({
handle,
displayName: name,
color: member?.color,
Expand All @@ -5368,9 +5381,18 @@ function remoteSpeaker(source, roster) {
const carrier = cleanCarrier(chat?.name ?? member?.name);
return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 };
}
function faceSeatLook(room2, handle) {
if (room2?.origin !== "face-to-face" || !handle) return void 0;
const seats = (room2.participants ?? []).filter((p) => p?.remote && (p.handle === handle || p.id === handle));
const seat = seats.find((p) => p.leftSeq === void 0) ?? seats.at(-1);
const look = {};
if (typeof seat?.color === "string" && seat.color) look.color = seat.color;
if (seat?.avatar && typeof seat.avatar === "object") look.avatar = seat.avatar;
return Object.keys(look).length ? look : void 0;
}
var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : "";
function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) {
const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster);
const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId);
const files = Array.isArray(source.attachments) ? source.attachments : [];
const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : "";
if (person) {
Expand All @@ -5384,7 +5406,7 @@ function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi
person,
carrier ? (0, import_react8.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null
),
(0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react8.createElement)("span", { className: "bt-lead", title: person }, (0, import_react8.createElement)(PersonMark)) }),
(0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react8.createElement)("span", { className: "bt-lead", title: person }, (0, import_react8.createElement)(PersonMark, { name: person })) }),
files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null
);
}
Expand Down Expand Up @@ -15896,7 +15918,7 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma
"data-author-kind": author?.authorKind,
"data-author-name": author?.name
},
person ? (0, import_react39.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react39.createElement)(PersonMark)) : null,
person ? (0, import_react39.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react39.createElement)(PersonMark, { name: author.name })) : null,
(0, import_react39.createElement)(
"div",
{ className: "bt-ustack" },
Expand Down
24 changes: 19 additions & 5 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ function HeldReply({ held, roster, sessionId, actions, closing, children }) {
// line in a face-to-face room: the person's name and a person mark, not the Bot's (the host marks
// the turn, remote-chat.js / turns.js, or posts it as a person line, hooks.js postHuman).
function PersonMessage({ name, texts, roster, selfId, actions, sessionId, msgKey, useUi }) {
const lead = h('span', { className: 'bt-lead', title: name }, h(PersonMark))
const lead = h('span', { className: 'bt-lead', title: name }, h(PersonMark, { name }))
return h('div', { className: 'bt-group-msg bt-person-msg', 'data-person': name },
h('span', { className: 'bt-group-name' }, name),
h(Bubbles, { texts, roster, selfId, actions, sessionId, msgKey, useUi, lead }))
Expand Down Expand Up @@ -288,10 +288,13 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq })
// A friend's remote Bot speaking in this chat, laid out like any chat partner: its own
// avatar (the one its sidebar row shows), its name, a small globe and its @handle. The
// words are shown as data; the Bot reading them still gets them fenced as untrusted.
export function remoteSpeaker(source, roster) {
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)
// 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({
displayName: chat?.name ?? member?.name ?? source.senderName ?? handle,
ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner,
Expand All @@ -300,7 +303,7 @@ export function remoteSpeaker(source, roster) {
})
// The sidebar's Bot record when there is one; else a synthetic one whose id seeds the
// same handle-derived look the record would get (characters.js remoteSeed).
const bot = chat ?? lookBotForRemote({
const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({
handle, displayName: name, color: member?.color,
avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined),
}, roster)
Expand All @@ -310,10 +313,21 @@ export function remoteSpeaker(source, roster) {
return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : undefined }
}

// The look a face room's remote seat holds for this handle (the live seat first), or undefined.
function faceSeatLook(room, handle) {
if (room?.origin !== 'face-to-face' || !handle) return undefined
const seats = (room.participants ?? []).filter(p => p?.remote && (p.handle === handle || p.id === handle))
const seat = seats.find(p => p.leftSeq === undefined) ?? seats.at(-1)
const look = {}
if (typeof seat?.color === 'string' && seat.color) look.color = seat.color
if (seat?.avatar && typeof seat.avatar === 'object') look.avatar = seat.avatar
return Object.keys(look).length ? look : undefined
}

const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '')

export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) {
const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster)
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
// person's, with their name and a person mark, and the Bot that carried it only named as the route.
Expand All @@ -323,7 +337,7 @@ export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey
// One short header: the person, and the Bot that carried the line only when it is known (#275).
h('span', { className: 'bt-group-name', title: handle }, person,
carrier ? h('span', { className: 'bt-remote-handle' }, t('via {name}', { name: carrier })) : null),
h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark)) }),
h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark, { name: person })) }),
files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null)
}
const mark = h(BotMark, { bot, size: 22 })
Expand Down
18 changes: 16 additions & 2 deletions core/src/client/mark.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createElement as h, useEffect, useRef, useState } from 'react'
import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js'
import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup, defaultColor } from './characters.js'
import { inkFill } from './inks.js'
import { t } from './i18n.js'

export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote } from './characters.js'
Expand Down Expand Up @@ -87,7 +88,20 @@ function StarIcon({ size = 8 }) {
}
// The small star in a disc that marks a group admin, beside a name.
// A person, not a Bot: a friend's own attested line, or another person in a face-to-face room.
export function PersonMark({ size = 22 }) {
// With a name it is that person's initial on a color picked from the name, so each person looks
// the same on every home and two people are told apart (#182); without one, a plain silhouette.
export const personInitial = name => (Array.from(String(name ?? '').trim())[0] ?? '').toUpperCase()
export const personColor = name => defaultColor(`person:${String(name ?? '').trim().toLowerCase()}`)
export function PersonMark({ size = 22, name }) {
const initial = personInitial(name)
if (initial) {
return h('span', {
className: 'bt-person-mark bt-person-initial',
'data-initial': initial,
style: { width: size, height: size, background: inkFill(personColor(name)), fontSize: Math.max(9, Math.round(size * 0.46)) },
'aria-hidden': true,
}, initial)
}
return h('span', { className: 'bt-person-mark', style: { width: size, height: size }, 'aria-hidden': true },
h('svg', { width: size * 0.64, height: size * 0.64, viewBox: '0 0 24 24', fill: 'currentColor' },
h('circle', { cx: 12, cy: 8.5, r: 4.2 }), h('path', { d: 'M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z' })))
Expand Down
1 change: 1 addition & 0 deletions core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,7 @@ export const CONVERSATION_CSS = `
.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1}
.bt-think[open] .bt-think-caret{transform:rotate(90deg)}
.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))}
.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none}
.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))}
.bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))}
.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)}
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/user-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes
'data-author-kind': author?.authorKind,
'data-author-name': author?.name,
},
person ? h('span', { className: 'bt-lead', title: author.name }, h(PersonMark)) : null,
person ? h('span', { className: 'bt-lead', title: author.name }, h(PersonMark, { name: author.name })) : null,
h('div', { className: 'bt-ustack' },
author && (incoming || multiHuman) ? h('span', { className: 'bt-uauthor' }, author.name, author.ownerName ? h('span', { className: 'bt-chip' }, t('Friend\'s Bot')) : null) : null,
attachments.length > 0 ? h(Attachments, { attachments, renderMessageImages }) : null,
Expand Down
8 changes: 5 additions & 3 deletions core/test/face-people.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ test('another person\'s face-room line is their own received bubble with name an
const [person] = find(drawn, node => node.props?.['data-person'] !== undefined)
assert.ok(person, 'drawn as a person')
assert.equal(person.props['data-person'], 'SelfA0333')
assert.equal(find(person, node => node.props?.className === 'bt-person-mark').length, 1, 'with an avatar')
assert.equal(find(person, node => /\bbt-person-mark\b/.test(node.props?.className ?? '')).length, 1, 'with an avatar')
// Their initial, not a grey silhouette (#182).
assert.equal(find(person, node => node.props?.['data-initial'] === 'S').length, 1)
assert.equal(find(person, node => node.props?.className === 'bt-group-name' && node.children.includes('SelfA0333')).length, 1)
assert.equal(find(person, node => node.props?.className === 'bt-bubble').length, 1, 'a received bubble')
assert.equal(find(drawn, node => node.props?.className === 'bt-ubody').length, 0, 'not the sent-bubble style')
Expand Down Expand Up @@ -105,9 +107,9 @@ test('another home member\'s line shows their person mark and a received surface
const cell = UserMessageCell({ node, sessionId: 'g1', useRoster: select => select(roster), actions: {} })
const drawn = expand(cell, ['PersonMark'])
assert.match(drawn.props.className, /bt-umsg-other/)
assert.equal(find(drawn, n => n.props?.className === 'bt-person-mark').length, 1)
assert.equal(find(drawn, n => /\bbt-person-mark\b/.test(n.props?.className ?? '')).length, 1)
const mine = UserMessageCell({ node: { ...node, data: { ...node.data, source: { ...node.data.source, authorId: 'm1' } } }, sessionId: 'g1', useRoster: select => select(roster), actions: {} })
assert.equal(find(expand(mine, ['PersonMark']), n => n.props?.className === 'bt-person-mark').length, 0, 'my own line has no mark')
assert.equal(find(expand(mine, ['PersonMark']), n => /\bbt-person-mark\b/.test(n.props?.className ?? '')).length, 0, 'my own line has no mark')
assert.match(USER_MESSAGE_CSS, /\.bt-umsg-other \.bt-ubody\{background:var\(--bt-bubble\);color:var\(--bt-ink\)\}/)
// The bundle carries the same rule.
assert.match(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /\.bt-umsg-other \.bt-ubody\{background:var\(--bt-bubble\)/)
Expand Down
Loading
Loading