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/967-friend-bot-mention.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A friend's Bot picked from the `@` menu now inserts the name people read — `@Chief (Bob)`, the same string as its sidebar row — and the sent message draws it as an `@` chip in the bubble and the preview instead of a bare handle. The handle stays the routing key underneath, so the message reaches the same Bot, and a line written before this change keeps its chip. Two friends' Bots with the same name stay apart in the label, and a friend the core does not list yet, offered by Bot Social's own `@` row, inserts that name too. ([#967](https://github.com/ThinkFlowLab/FlyBot/issues/967))
64 changes: 48 additions & 16 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1531,14 +1531,26 @@ function friendRecord(bot) {
const remote = bot?.remote && typeof bot.remote === "object" ? bot.remote : {};
return {
displayName: remote.displayName || remote.name || bot?.name,
ownerName: remote.ownerName || remote.owner || remote.homeName,
ownerName: remote.ownerName || remote.owner || remote.homeName || bot?.ownerName || bot?.owner || bot?.homeName,
handle: remote.handle ?? bot?.handle,
isChief: remote.isChief
isChief: remote.isChief ?? bot?.isChief
};
}
function friendBotTitle(bot) {
return remoteBotTitle(friendRecord(bot));
}
var mentionHandleOf = (record) => String((record?.remote && typeof record.remote === "object" ? record.remote.handle : record?.handle) ?? "").replace(/^@+/, "").toLowerCase();
function remoteMentionLabel(record, others = []) {
const title = friendBotTitle(record);
const key = mentionHandleOf(record);
const clash = (others ?? []).some((other) => {
const otherKey = mentionHandleOf(other);
return otherKey !== "" && otherKey !== key && friendBotTitle(other) === title;
});
if (!clash) return title;
const home = cleanPeerName(String(record?.remote?.home ?? record?.home ?? "").replace(/^@+/, ""), "");
return home ? `${title} · ${home}` : title;
}
function friendBotOwner(bot) {
return remoteOwnerBadge(friendRecord(bot));
}
Expand Down Expand Up @@ -1705,7 +1717,8 @@ function remoteMembers(roster) {
}
function remoteMentionRows(roster, query = "") {
const needle = String(query).trim().toLowerCase().replace(/^@/, "");
return remoteMembers(roster).filter((bot) => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? "").toLowerCase().includes(needle)).map((bot) => ({ remote: bot, text: `${bot.handle} ` }));
const all = remoteMembers(roster);
return all.filter((bot) => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? "").toLowerCase().includes(needle)).map((bot) => ({ remote: bot, text: `@${remoteMentionLabel(bot, all)} ` }));
}
function remoteMembersByHome(roster) {
const homes = /* @__PURE__ */ new Map();
Expand Down Expand Up @@ -2069,10 +2082,11 @@ function mentionTarget(event) {
function nameMentions(text, roster) {
const value = String(text ?? "");
if (!value.includes("@") || roster === null || typeof roster !== "object") return value;
const records = [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []];
const names = /* @__PURE__ */ new Map();
for (const record of [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]) {
for (const record of records) {
const handle = typeof record?.remote === "object" && record.remote !== null ? record.remote.handle : record?.handle;
if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), record.name);
if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), remoteMentionLabel(record, records));
}
const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length);
if (handles.length === 0) return value;
Expand Down Expand Up @@ -2736,7 +2750,7 @@ function registry2() {
globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview;
globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf;
globalThis.__flybot__.inviteCountdown ??= inviteCountdown;
globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner };
globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner, remoteMentionLabel };
return slots;
}
var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options });
Expand Down Expand Up @@ -8975,9 +8989,10 @@ function atSources({ roster, sessions, launched, iconFor }) {
const snapshot = roster.getSnapshot();
const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]);
const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({
// A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). A seat
// carries its own name; a friend's chat is named as its sidebar row and header (#498).
name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t("Everyone"),
// A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the
// second line (#384). A seat carries its own name; a friend's chat is named as its sidebar
// row and header (#498).
name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"),
...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 } : {},
Expand Down Expand Up @@ -17019,13 +17034,30 @@ function contentParts(content) {
}
return { text: texts2.join("\n"), attachments, rest };
}
var normMentionHandle = (handle) => String(handle ?? "").replace(/^@+/, "").toLowerCase();
function remoteMentionableBots(roster, chats = []) {
const known = /* @__PURE__ */ new Map();
const add = (record) => {
const handle = normMentionHandle(record?.remote?.handle ?? record?.handle);
if (handle) known.set(handle, record);
};
for (const record of Array.isArray(roster.remoteBots) ? roster.remoteBots : []) add(record);
for (const record of Array.isArray(chats) ? chats : []) if (typeof record?.remote?.handle === "string") add(record);
const all = [...known.values()];
return all.flatMap((record) => {
const handle = record.remote?.handle ?? record.handle;
const chat = (Array.isArray(chats) ? chats : []).find((bot) => normMentionHandle(bot?.remote?.handle) === normMentionHandle(handle));
const base = { id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: "bot", remote: record, handle };
return [{ ...base, name: String(handle).replace(/^@+/, "") }, { ...base, name: base.mentionLabel }];
});
}
function mentionableBots(roster, sessionId, { fromFriend = false } = {}) {
const room2 = roster.roomsById[sessionId];
if (room2?.origin === "face-to-face") return faceMentionables(room2, roster, fromFriend);
const bots = room2 ? room2.members.map((id) => roster.byId[id]).filter(Boolean) : roster.byId[sessionId] ? roster.bots : [];
const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role }));
const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) }));
return bots.concat(humans, remotes, bots.filter((bot) => typeof bot?.remote?.handle === "string").map((bot) => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ""), mentionLabel: friendBotTitle(bot) })));
return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots));
}
function faceMentionables(room2, roster, fromFriend) {
const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role }));
Expand Down Expand Up @@ -17069,12 +17101,12 @@ function userTextParts(text, bots, referenceLabels = []) {
return parts;
}
function UserText({ text, bots, referenceLabels, skillNames, references, actions }) {
return userTextParts(text, bots, referenceLabels).map((part, index) => part.bot ? (0, import_react41.createElement)(
"button",
{ key: index, type: "button", className: "bt-umention", title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) },
(0, import_react41.createElement)(BotMark, { bot: part.bot, size: 16 }),
`@${part.bot.mentionLabel ?? part.label}`
) : (0, import_react41.createElement)(import_react41.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)));
const chip = (part) => [(0, import_react41.createElement)(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`];
return userTextParts(text, bots, referenceLabels).map((part, index) => {
if (!part.bot) return (0, import_react41.createElement)(import_react41.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references));
const title = `@${part.bot.name}`;
return typeof part.bot.id === "string" ? (0, import_react41.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react41.createElement)("span", { key: index, className: "bt-umention", title }, chip(part));
});
}
function Attachments({ attachments, renderMessageImages }) {
const compact2 = attachments.length > 1;
Expand Down
7 changes: 4 additions & 3 deletions core/src/client/at-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,10 @@ export function atSources({ roster, sessions, launched, iconFor }) {
const snapshot = roster.getSnapshot()
const face = isFaceRoom(snapshot.roomsById?.[session.sessionId])
const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map(row => ({
// A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). A seat
// carries its own name; a friend's chat is named as its sidebar row and header (#498).
name: row.bot?.remote && !face ? (row.bot.kind === 'bot' ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot)) : row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t('Everyone'),
// A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the
// second line (#384). A seat carries its own name; a friend's chat is named as its sidebar
// row and header (#498).
name: row.bot?.remote && !face ? (row.bot.kind === 'bot' ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot)) : row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? row.text.replace(/^@/, '').trimEnd() : t('Everyone'),
...(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 } : {}),
Expand Down
11 changes: 7 additions & 4 deletions core/src/client/flybot-registry.js
Original file line number Diff line number Diff line change
@@ -1,5 +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.
import { remoteMentionLabel } from '../shared/remote-label.js'
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() {
Expand Down Expand Up @@ -132,13 +133,15 @@ export function remoteMembers(roster) {
return Array.isArray(roster?.remoteBots) ? roster.remoteBots.filter(bot => typeof bot?.handle === 'string') : []
}

// "@query" rows for remote members: picking one inserts "@handle ", which the host sends
// through bot.send (so the social plugin's approval still applies).
// "@query" rows for remote members: picking one inserts the name people read ("@Coder (Bob) ",
// #967), which the host matches back to the handle it routes on and sends through bot.send (so the
// social plugin's approval still applies).
export function remoteMentionRows(roster, query = '') {
const needle = String(query).trim().toLowerCase().replace(/^@/, '')
return remoteMembers(roster)
const all = remoteMembers(roster)
return all
.filter(bot => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? '').toLowerCase().includes(needle))
.map(bot => ({ remote: bot, text: `${bot.handle} ` }))
.map(bot => ({ remote: bot, text: `@${remoteMentionLabel(bot, all)} ` }))
}

// Remote members grouped by their home, for the sidebar.
Expand Down
9 changes: 6 additions & 3 deletions core/src/client/flybot-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@
//
// The same registry object also carries the host Bot look so a plugin can draw a Bot
// exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf, and name a friend's Bot
// or its owner the same way: __flybot__.names.friendBotTitle / friendBotOwner (set in registry()).
// or its owner the same way: __flybot__.names.friendBotTitle / friendBotOwner / remoteMentionLabel
// (set in registry()).
//
// Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry).
import { Component, createElement as h, Fragment, useEffect, useState } from 'react'
import { call as callHost } from './api.js'
import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js'
import { friendBotOwner, friendBotTitle, isListedRemoteChat, remoteMentionLabel } from '../shared/remote-label.js'
import { registry as baseRegistry } from './flybot-registry.js'
import { BotAvatar, BotMark, lookOf } from './mark.js'
import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from './room-invite-preview.js'
Expand Down Expand Up @@ -72,7 +73,9 @@ export function registry() {
globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf
globalThis.__flybot__.inviteCountdown ??= inviteCountdown
// The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does.
globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }
// `remoteMentionLabel` is the one name a friend Bot mention wears (#967), so a plugin row for a
// friend the core does not list yet inserts the name the bubble and the preview will chip.
globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner, remoteMentionLabel }
return slots
}

Expand Down
16 changes: 9 additions & 7 deletions core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Text helpers
import { dateLocale, t } from './i18n.js'
import { mentionSpans } from '../shared/mentions.js'
import { remoteMentionLabel } from '../shared/remote-label.js'

export const MENTION_ORIGIN = 'https://bots.local/'

Expand All @@ -28,18 +29,19 @@ export function mentionTarget(event) {
return anchor ? anchor.getAttribute('href').slice(MENTION_ORIGIN.length) : null
}

// A friend's remote Bot is stored and delivered by its handle ("@bot-x.home": what the
// @ menu inserts, what the host matches); the user reads its display name. Previews
// rewrite the handle from the local roster, never from the text, so a remote name
// reaches the UI only through the sanitized roster record. One pass over the text: a
// name that itself contains a handle is never rescanned.
// A friend's remote Bot is stored and delivered by its handle ("@bot-x.home": what older lines
// carry, what the host routes on); the user reads one name for it, the same the @ menu inserts and
// the bubble chips (#967). Previews rewrite the handle from the local roster, never from the text,
// so a remote name reaches the UI only through the sanitized roster record. One pass over the text:
// a name that itself contains a handle is never rescanned.
export function nameMentions(text, roster) {
const value = String(text ?? '')
if (!value.includes('@') || roster === null || typeof roster !== 'object') return value
const records = [...(Array.isArray(roster.remoteBots) ? roster.remoteBots : []), ...(Array.isArray(roster.bots) ? roster.bots : [])]
const names = new Map()
for (const record of [...(Array.isArray(roster.remoteBots) ? roster.remoteBots : []), ...(Array.isArray(roster.bots) ? roster.bots : [])]) {
for (const record of records) {
const handle = typeof record?.remote === 'object' && record.remote !== null ? record.remote.handle : record?.handle
if (typeof handle === 'string' && typeof record?.name === 'string' && record.name !== '') names.set(handle.replace(/^@+/, '').toLowerCase(), record.name)
if (typeof handle === 'string' && typeof record?.name === 'string' && record.name !== '') names.set(handle.replace(/^@+/, '').toLowerCase(), remoteMentionLabel(record, records))
}
const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length)
if (handles.length === 0) return value
Expand Down
Loading
Loading