Repository navigation
fix(social): accepting an invite shows the shared Bot at once and never grants ours back; live People with access (#228) #240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Accepting a Bot invite: the shared Bot shows in your list with the success message instead of a few seconds later, and the Bot you talk through is never stored as granted back to the inviter, not even for a moment. **Talk through** appears only when you have more than one Bot, and its tooltip says plainly that Bot is not shared. An open **People with access** page now shows a friend who accepts without reopening it. ([#228](https://github.com/ThinkFlowLab/FlyBot/issues/228)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1324,7 +1324,9 @@ window.__ModuleLoader__.load({ | |
| h('div', { className: 'fs-line' }, | ||
| h('input', { className: 'fs-input', value: code, placeholder: 'Paste invite link', 'aria-label': 'Invite link or code', onChange: e => setCode(e.target.value) }), | ||
| h('button', { type: 'submit', className: 'fs-btn', disabled: busy || !code.trim() }, 'Accept invite')), | ||
| mine.length ? h('label', { className: 'fs-line fs-hint fs-join-as', 'data-flybot': 'join-as', title: 'Your friend only sees this Bot unless you invite them to another.' }, | ||
| // Accepting grants only the invited Bot to you. The Bot you talk through is not shared, so | ||
| // there is nothing to pick with one Bot (#228). | ||
| mine.length > 1 ? h('label', { className: 'fs-line fs-hint fs-join-as', 'data-flybot': 'join-as', title: "Talks for you. Not shared: your friend can't use it unless you invite them to it." }, | ||
| h('span', { className: 'fs-join-as-label', 'data-flybot': 'talk-through' }, 'Talk through '), | ||
| h('select', { className: 'fs-input', value: joiner, 'aria-label': 'Which of your Bots talks', onChange: e => setJoiner(e.target.value) }, | ||
| mine.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))) : null), | ||
|
|
@@ -1372,6 +1374,7 @@ window.__ModuleLoader__.load({ | |
| return Boolean(status.value?.connected && status.value.bots.some(bot => bot.id === botId)) | ||
| } | ||
|
|
||
| const ACCESS_POLL_MS = 5000 | ||
| const ACCESS_NOTE = 'One person per grant. Revoke anyone without affecting the rest. Default: nobody, Chief included.' | ||
| function ManageAccess({ bot }) { | ||
| const [view, setView] = useState(null) | ||
|
|
@@ -1380,11 +1383,19 @@ window.__ModuleLoader__.load({ | |
| const load = async () => { | ||
| if (!bot?.id) return | ||
| try { | ||
| setView(await call('social/access', { botId: bot.id })) | ||
| const next = await call('social/access', { botId: bot.id }) | ||
| setView(previous => (JSON.stringify(previous) === JSON.stringify(next) ? previous : next)) | ||
| setError('') | ||
| } catch (e) { setError(errorText(e)) } | ||
| } | ||
| useEffect(() => { void load() }, [bot?.id]) | ||
| // A friend who accepts while this page is open shows up without reopening it (#228). | ||
| useEffect(() => { | ||
| void load() | ||
| if (typeof setInterval !== 'function') return undefined | ||
| const timer = setInterval(() => { if (typeof document === 'undefined' || !document.hidden) void load() }, ACCESS_POLL_MS) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
While People with access is open with no changes, this 5-second tick still calls Useful? React with 👍 / 👎. |
||
| timer?.unref?.() | ||
| return () => clearInterval(timer) | ||
| }, [bot?.id]) | ||
| if (!bot?.id) return null | ||
| const revoke = async (grant) => { | ||
| setBusy(grant.id) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,9 +81,9 @@ | |
| const TEXTUAL = new Set(['text/plain', 'text/markdown', 'text/csv', 'text/tab-separated-values', 'application/json']) | ||
| const sizeLabel = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`) | ||
| // Remote strings that reach model-visible text: one line, no brackets or markup, capped. | ||
| export const cleanText = (value, fallback = '') => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || fallback | ||
| // Owner-only draft body: keep punctuation the friend will see; strip controls and cap. | ||
| export const draftText = (value) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 800) | ||
| // Owner cards never show the FLYBOT-FACE-HUMAN wire envelope (#159). | ||
| export function approvalDraftText(value) { | ||
| const raw = draftText(value) | ||
|
|
@@ -136,7 +136,7 @@ | |
| } | ||
| const SEEN_KEPT = 1000 | ||
| const CREDENTIAL_NAME = /(^id_(rsa|dsa|ecdsa|ed25519)|\.(pem|key|p12|pfx|kdbx|keychain)$|credential|secret|token|password|passwd|^flybot-social.*\.json$|^\.?env$|^\.netrc$|^known_hosts$|^authorized_keys$)/i | ||
| const safeName = name => String(name ?? 'file').replace(/[\u0000-\u001f\u007f/\\:]/g, '_').replace(/^\.+/, '_').slice(0, 120) || 'file' | ||
| // The text Copy puts on the clipboard for a per-Bot invite: what to do with the link, for a | ||
| // person in FlyBot or for their Grok bot. | ||
| export function inviteText({ handle, name, home, url, expiresAt }) { | ||
|
|
@@ -575,13 +575,13 @@ | |
| if (result?.unavailable) return | ||
| } catch (error) { | ||
| if (/invalid_avatar|invalid_color|invalid_request/i.test(`${error?.code ?? ''} ${error?.message ?? ''}`)) { | ||
| settings.lookSynced = { ...(settings.lookSynced ?? {}), [bot.id]: fingerprint } | ||
| saveSettings() | ||
| return | ||
| } | ||
| throw error | ||
| } | ||
| settings.lookSynced = { ...(settings.lookSynced ?? {}), [bot.id]: fingerprint } | ||
| saveSettings() | ||
| } | ||
| async function syncLocalLooks() { | ||
|
|
@@ -593,6 +593,9 @@ | |
| // ------------------------------------------------------------ remote roster | ||
| let remote = { homes: [] } | ||
| let fetchedAt = 0 | ||
| // Counts roster fetches: a late grant re-filter of an older fetch must not overwrite a newer | ||
| // roster, even when both landed in the same millisecond (#228). | ||
| let rosterFetches = 0 | ||
| // Handles we declined this session. Not persisted (that is the old-server local hide). | ||
| // Keeps a stale grant re-filter or a mock/lagging list_remote_bots from putting the | ||
| // Bot back before the server drops it. Cleared when the handle is absent from a | ||
|
|
@@ -788,12 +791,12 @@ | |
| throw error | ||
| } | ||
| fetchedAt = now() | ||
| const rosterAt = fetchedAt | ||
| const rosterAt = ++rosterFetches | ||
| if (home) void syncServerAutoApprove((result?.homes ?? [])) | ||
| // Grant listing is N HTTP calls; do not block Friends' Bots on it. Re-filter | ||
| // once grants arrive so reverse-friends of our invites drop (#138). | ||
| void syncOutgoingGrants().then(() => { | ||
| if (fetchedAt !== rosterAt || !home || !result) return | ||
| if (rosterFetches !== rosterAt || !home || !result) return | ||
| const mapped = mapRemoteHomes(result.homes) | ||
| forgetDeclinedIfGone(mapped) | ||
| applyRemoteRoster({ homes: mapped.map(entry => ({ ...entry, bots: entry.bots.filter(bot => keepGrantedRemote(bot)) })) }, { source, ok: true }) | ||
|
|
@@ -940,9 +943,11 @@ | |
| return rooms | ||
| } | ||
|
|
||
| async function syncOutgoingGrants() { | ||
| // `only`: one Bot's grants (an open People with access refreshes just that Bot). | ||
| async function syncOutgoingGrants({ only } = {}) { | ||
| if (typeof flybot.grants?.set !== 'function') return | ||
| const bots = flybot.bot.list().filter(bot => { | ||
| if (only && bot.id !== only) return false | ||
| const member = memberOf(bot.id) | ||
| return member && !member.roomOnly | ||
| }) | ||
|
|
@@ -1490,6 +1495,10 @@ | |
| } | ||
| const offered = joined.bot ?? joined.inviter?.bot ?? joined.offered_bot | ||
| const offeredHandle = offered?.handle ? norm(offered.handle) : '' | ||
| // Mark the shared Bot inbound before the first refresh: the grant sync that refresh starts | ||
| // must already see the talk-through grant on our Bot as talk-through, or it stores it as us | ||
| // sharing that Bot back and the re-filter drops the shared Bot until the next refresh (#228). | ||
| if (offeredHandle) rememberInbound(offeredHandle) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Useful? React with 👍 / 👎. |
||
| if (offeredHandle) unhideDeclined([offeredHandle]) | ||
| const unhide = revealHiddenIfPresent(settings, offeredHandle ? [offeredHandle] : []) | ||
| if (unhide !== settings) { settings = unhide; saveSettings() } | ||
|
|
@@ -1565,7 +1574,7 @@ | |
| disposers.push(flybot.api.handle('social/access', async ({ botId } = {}) => { | ||
| const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : undefined | ||
| if (!bot) throw new Error('Choose one of your Bots') | ||
| await syncOutgoingGrants().catch(() => {}) | ||
| await syncOutgoingGrants({ only: bot.id }).catch(() => {}) | ||
| const grants = (flybot.grants?.of?.(bot.id) ?? []).filter(grant => !grant.roomId) | ||
| const rooms = roomsUsing(bot.id) | ||
| return { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a
social/accessrequest takes longer than 5 seconds (the MCP timeout is 30 seconds), every interval tick starts anotherloadwhile the prior request is pending. Each backend handler performssyncOutgoingGrants({ only })and writesflybot.grants, so responses that finish out of order can overwrite a newer accept/revoke snapshot and then regress this component throughsetView; an outage can also accumulate six concurrent requests. Use a single-flight guard or schedule the next poll only after the previous load settles.Useful? React with 👍 / 👎.