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/420-friend-line-unread.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A friend's line in one of your Bots' own chats is no longer easy to miss: the Bot's row (and the Main Bot's tile) gets the unread dot until you open that chat, also when the line came while FlyBot was closed, and a sender's first line opens with a quiet note such as `SelfB1238 is talking to Chief through Bot Social`. Add friend now also lists friendships and requests made through a per-Bot invite ([#420](https://github.com/ThinkFlowLab/FlyBot/issues/420)).
52 changes: 50 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,8 @@ var ZH = {
"Reply from": "收到回复:",
"Message from": "收到消息:",
"via {name}": "经由 {name}",
"{name} is talking to {bot} through Bot Social": "{name} 正通过 Bot Social 和 {bot} 对话",
"your Bot": "你的 Bot",
"Stopped by": "叫停者:",
"{count} messages with": "{count} 条消息,来自",
"Routine started": "例行任务开始",
Expand Down Expand Up @@ -3056,9 +3058,10 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default}
.bt-round:active{transform:scale(.94)}
.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto}
.bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px}
.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease}
.bt-tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease}
.bt-tile:hover{background:var(--bt-hover)}
.bt-tile[aria-current=page]{background:var(--bt-active)}
.bt-tile-unread{position:absolute;top:8px;right:12px}
.bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis}
.bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease}
Expand Down Expand Up @@ -5244,6 +5247,12 @@ function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi
files.length ? (0, import_react7.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null
);
}
function FirstContact({ source, roster, sessionId }) {
const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : "";
const who = person || remoteSpeaker(source, roster).name;
const bot = roster.byId[sessionId]?.name ?? t("your Bot");
return (0, import_react7.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react7.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot })));
}
function GlobeMark() {
return (0, import_react7.createElement)(
"svg",
Expand Down Expand Up @@ -5433,7 +5442,8 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us
function triggerBody() {
if (source.form === "remote-chat") return null;
if (source.remote?.handle) {
return (0, import_react7.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi });
const message = (0, import_react7.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi });
return source.remote.firstContact === true ? (0, import_react7.createElement)(import_react7.Fragment, null, (0, import_react7.createElement)(FirstContact, { source, roster, sessionId }), message) : message;
}
if (source.kind === "bot") {
if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react7.createElement)(EarlierParts, { sessionId, roster, actions });
Expand Down Expand Up @@ -6140,6 +6150,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio
},
(0, import_react9.createElement)(BotAvatar, { bot, size: 60, state, main: true }),
(0, import_react9.createElement)("span", { className: "bt-tile-name" }, shown),
// The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar.
unread && !waiting && !active ? (0, import_react9.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null,
bot.role ? (0, import_react9.createElement)("span", { className: "bt-chip" }, bot.role) : null
));
}
Expand Down Expand Up @@ -13075,6 +13087,7 @@ var STORAGE = {
unread: "ds-bot:unread",
reactions: "ds-bot:reactions",
seen: "ds-bot:seen",
friendLines: "ds-bot:friend-lines",
prefs: "ds-bot:prefs",
surface: "ds-bot:surface",
agentSessions: "ds-bot:agent-sessions",
Expand Down Expand Up @@ -15340,6 +15353,22 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random
};
}

// src/client/friend-lines.js
function friendLineNews(bots, known, isOpen, selfId) {
const next = { ...known };
const unread = [];
for (const bot of bots ?? []) {
const at = bot?.friendLineAt;
if (bot?.remote || !Number.isFinite(at)) continue;
if (selfId && bot.ownerId && bot.ownerId !== selfId) continue;
const shown = known?.[bot.id];
if (shown === at) continue;
next[bot.id] = at;
if (known && (shown === void 0 || at > shown) && !isOpen(bot)) unread.push(bot.id);
}
return { known: next, unread };
}

// src/client/surface.js
function groupsFor({ mode, plain = false }) {
return { shell: mode === "bot", conversation: mode === "bot" && !plain };
Expand Down Expand Up @@ -15625,6 +15654,12 @@ function apply(ctx) {
} catch {
seenAt = {};
}
let friendLines = null;
try {
friendLines = JSON.parse(localStorage.getItem(STORAGE.friendLines) ?? "null");
} catch {
friendLines = null;
}
const ui2 = createSource({ details: null, view: "details", renaming: false, tab: null, newChat: null, exchange: null, menu: null, voice: null, palette: null, quote: null, settings: null, remoteBotSettings: null, memory: null, toast: null, signIn: null, renameAgent: null, unread: savedUnread, reactions: savedReactions, visits: {} });
const surface = createSource({ mode: readMode(), plain: false });
let agentSessionCache = /* @__PURE__ */ new Set();
Expand Down Expand Up @@ -15695,6 +15730,18 @@ function apply(ctx) {
}
return { ...state, unread };
});
const noteFriendLines = (value) => {
const visited = new Set(Object.keys(ui2.getSnapshot().visits ?? {}).map(seenKey));
const news = friendLineNews(value.bots, friendLines, (bot) => visited.has(bot.id), value.self?.memberId);
if (JSON.stringify(news.known) !== JSON.stringify(friendLines ?? null)) {
friendLines = news.known;
try {
localStorage.setItem(STORAGE.friendLines, JSON.stringify(friendLines));
} catch {
}
}
for (const id of news.unread) setUnread(id, true);
};
const refreshed = /* @__PURE__ */ new Set();
let pulls = 0;
let applied = 0;
Expand Down Expand Up @@ -15727,6 +15774,7 @@ function apply(ctx) {
if (value.unchanged || value.revision === current.revision && value.modelsKey === current.modelsKey) return;
if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id));
roster.set(indexRoster(value));
noteFriendLines(value);
try {
const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []);
localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids));
Expand Down
12 changes: 11 additions & 1 deletion core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey
files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null)
}

// A sender's first line in a Bot's own chat (#420): one quiet line says who is talking to the Bot
// and how, so the line is not read as one of my own messages.
export function FirstContact({ source, roster, sessionId }) {
const person = typeof source.remote.typedBy === 'string' ? source.remote.typedBy : ''
const who = person || remoteSpeaker(source, roster).name
const bot = roster.byId[sessionId]?.name ?? t('your Bot')
return h('div', { className: 'bt-event', 'data-first-contact': source.remote.handle }, h('span', null, t('{name} is talking to {bot} through Bot Social', { name: who, bot })))
}

function GlobeMark() {
return h('svg', { width: 10, height: 10, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true },
h('circle', { cx: 12, cy: 12, r: 9 }), h('path', { d: 'M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18' }))
Expand Down Expand Up @@ -468,7 +477,8 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl
// A friend's message in its remote chat: the reply below shows it (remote-chat.js).
if (source.form === 'remote-chat') return null
if (source.remote?.handle) {
return h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi })
const message = h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi })
return source.remote.firstContact === true ? h(Fragment, null, h(FirstContact, { source, roster, sessionId }), message) : message
}
if (source.kind === 'bot') {
// A part opens with its handoff package, so the earlier parts go right above it.
Expand Down
24 changes: 24 additions & 0 deletions core/src/client/friend-lines.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// A friend's line in one of my Bots' own chats (#420): it lands among my own messages, so DSH's
// "finished while you were away" mark is not enough (that chat may be the one on screen, or the
// line came while this window was closed). The host stamps the Bot with `friendLineAt`; this
// window keeps the last stamp it has shown per Bot and marks a Bot unread when a newer one
// arrives while its chat is not open. Stamps are compared with stamps, never with this
// browser's clock. A browser with no record yet takes everything already there as shown.

// `known`: the saved record, or null when this browser has none yet. `isOpen(bot)`: its chat is on
// screen now. Returns the record to save and the Bots to mark unread.
export function friendLineNews(bots, known, isOpen, selfId) {
const next = { ...known }
const unread = []
for (const bot of bots ?? []) {
const at = bot?.friendLineAt
if (bot?.remote || !Number.isFinite(at)) continue
// Another member's Bot shared in a group is theirs to read.
if (selfId && bot.ownerId && bot.ownerId !== selfId) continue
const shown = known?.[bot.id]
if (shown === at) continue
next[bot.id] = at
if (known && (shown === undefined || at > shown) && !isOpen(bot)) unread.push(bot.id)
}
return { known: next, unread }
}
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,8 @@ const ZH = {
'Reply from': '收到回复:',
'Message from': '收到消息:',
'via {name}': '经由 {name}',
'{name} is talking to {bot} through Bot Social': '{name} 正通过 Bot Social 和 {bot} 对话',
'your Bot': '你的 Bot',
'Stopped by': '叫停者:',
'{count} messages with': '{count} 条消息,来自',
'Routine started': '例行任务开始',
Expand Down
16 changes: 16 additions & 0 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { FOLDER_CSS } from './folder-menu.js'
import { USAGE_CSS } from './usage-page.js'
import { SCHEDULE_CSS } from './schedules.js'
import { STORAGE, migrateStorage } from './storage.js'
import { friendLineNews } from './friend-lines.js'
import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js'
import { installLocale, language } from './i18n.js'
import { noteClientError, noteNavigation, quietly } from './diagnostics.js'
Expand Down Expand Up @@ -64,6 +65,9 @@ export function apply(ctx) {
const SEEN_KEY = STORAGE.seen
let seenAt = {}
try { seenAt = JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') ?? {} } catch { seenAt = {} }
// Per Bot, the last friend's-line stamp this browser has shown (friend-lines.js); null: no record yet.
let friendLines = null
try { friendLines = JSON.parse(localStorage.getItem(STORAGE.friendLines) ?? 'null') } catch { friendLines = null }
// `details` is the panel's subject (a Session id); `view` picks details or the
// subject Bot's browser inside it.
const ui = createSource({ details: null, view: 'details', renaming: false, tab: null, newChat: null, exchange: null, menu: null, voice: null, palette: null, quote: null, settings: null, remoteBotSettings: null, memory: null, toast: null, signIn: null, renameAgent: null, unread: savedUnread, reactions: savedReactions, visits: {} })
Expand Down Expand Up @@ -139,6 +143,17 @@ export function apply(ctx) {
try { localStorage.setItem(UNREAD_KEY, JSON.stringify(unread)) } catch { /* storage unavailable */ }
return { ...state, unread }
})
// A friend's line in a Bot's chat that is not on screen marks the Bot unread, like Mark as Unread,
// and opening the chat clears it (#420).
const noteFriendLines = (value) => {
const visited = new Set(Object.keys(ui.getSnapshot().visits ?? {}).map(seenKey))
const news = friendLineNews(value.bots, friendLines, bot => visited.has(bot.id), value.self?.memberId)
if (JSON.stringify(news.known) !== JSON.stringify(friendLines ?? null)) {
friendLines = news.known
try { localStorage.setItem(STORAGE.friendLines, JSON.stringify(friendLines)) } catch { /* storage unavailable */ }
}
for (const id of news.unread) setUnread(id, true)
}
const refreshed = new Set()
// Pulls can overlap (the poll and a navigation); an older answer never wins.
let pulls = 0
Expand Down Expand Up @@ -166,6 +181,7 @@ export function apply(ctx) {
// On a browser with no visit record yet, everything already there counts as seen.
if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map(entry => entry.id))
roster.set(indexRoster(value))
noteFriendLines(value)
try {
const ids = (value.agents ?? []).flatMap(agent => agent.sessions ?? [])
localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids))
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio
},
h(BotAvatar, { bot, size: 60, state, main: true }),
h('span', { className: 'bt-tile-name' }, shown),
// The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar.
unread && !waiting && !active ? h('span', { className: 'bt-unread bt-tile-unread', 'aria-hidden': true }) : null,
bot.role ? h('span', { className: 'bt-chip' }, bot.role) : null))
}
const unreadMark = wide && waiting && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true })
Expand Down
1 change: 1 addition & 0 deletions core/src/client/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const STORAGE = {
unread: 'ds-bot:unread',
reactions: 'ds-bot:reactions',
seen: 'ds-bot:seen',
friendLines: 'ds-bot:friend-lines',
prefs: 'ds-bot:prefs',
surface: 'ds-bot:surface',
agentSessions: 'ds-bot:agent-sessions',
Expand Down
3 changes: 2 additions & 1 deletion core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,10 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default}
.bt-round:active{transform:scale(.94)}
.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto}
.bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px}
.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease}
.bt-tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease}
.bt-tile:hover{background:var(--bt-hover)}
.bt-tile[aria-current=page]{background:var(--bt-active)}
.bt-tile-unread{position:absolute;top:8px;right:12px}
.bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis}
.bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease}
Expand Down
24 changes: 23 additions & 1 deletion core/src/host/delivery.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,26 @@ export function install(rt) {
return said === OWNER || said === SCHEDULED ? OWNER : OTHER
}

// A friend's line in a Bot's own chat (#420): `friendLineAt` on the Bot tells every window that a
// line it has not shown yet arrived (its row turns unread), and the senders seen so far, kept apart
// from the roster, tell the first line from a sender. A group line is the group's, not the Bot's.
const CONTACTS_KEPT = 200
const noteFriendLine = (to, sessionId, roomId, handle) => {
if (to === undefined || roomId || (sessionId !== undefined && sessionId !== rt.chatOf(to.id))) return false
const key = String(handle)
const contacts = (state.remoteContacts ??= {})[to.id] ??= {}
const first = contacts[key] === undefined
const now = Date.now()
if (first) {
contacts[key] = now
const keys = Object.keys(contacts)
if (keys.length > CONTACTS_KEPT) for (const old of keys.slice(0, keys.length - CONTACTS_KEPT)) delete contacts[old]
}
to.friendLineAt = now
void rt.save().catch(() => {})
return first
}

// Roles recorded as team exchanges.
const EXCHANGE_ROLES = new Set(['request', 'reply', 'stopped'])

Expand All @@ -116,6 +136,7 @@ export function install(rt) {
const fromName = from?.name ?? (givenName === undefined ? 'You' : untrusted ? cleanName(givenName) : givenName)
const room = rt.roomOf(roomId)
const origin = originOf({ from, to, fromId, remote, given: givenOrigin })
const firstContact = remote?.handle && !deferred && !resumed && !retried && !resent ? noteFriendLine(to, sessionId, roomId, remote.handle) : false
// Session events reject `undefined` members, so optional fields are omitted instead.
const source = {
kind: SOURCE_KIND,
Expand All @@ -137,7 +158,8 @@ export function install(rt) {
...(fromRoomId ? { senderRoomId: fromRoomId } : {}),
// A remote member's message (FlyBot): the chat draws it as that member's bubble.
// `typedBy`: the plugin has the server's attestation that this friend typed the line themselves.
...(remote?.handle ? { remote: { handle: String(remote.handle), ...(remote.home ? { home: String(remote.home) } : {}), ...(remote.typedBy ? { typedBy: cleanName(remote.typedBy) } : {}) } } : {}),
// `firstContact`: this sender's first line in the Bot's own chat; the chat names who is talking (#420).
...(remote?.handle ? { remote: { handle: String(remote.handle), ...(remote.home ? { home: String(remote.home) } : {}), ...(remote.typedBy ? { typedBy: cleanName(remote.typedBy) } : {}), ...(firstContact ? { firstContact: true } : {}) } } : {}),
// A remote member's files, as references the chat shows as chips (fetched on demand).
...(remote?.handle && Array.isArray(attachments) && attachments.length ? { attachments: attachments.map(file => ({ id: String(file.id ?? ''), name: String(file.name), size: Number(file.size), mime: String(file.mime ?? '') })) } : {}),
}
Expand Down
Loading
Loading