diff --git a/aesel/src/whistlegraph-thread.mjs b/aesel/src/whistlegraph-thread.mjs index 8e3bb3fc38..42025c234a 100644 --- a/aesel/src/whistlegraph-thread.mjs +++ b/aesel/src/whistlegraph-thread.mjs @@ -1,5 +1,5 @@ // Stable local identity; the server atomically reserves its pronounceable code. -const ledgerText = ledger => ledger?JSON.stringify({format:ledger.format,head:ledger.head,versions:ledger.versions.map(v=>({id:v.id,parent:v.parent,source:v.source,request:v.request??null,createdAt:v.createdAt||'',layers:Number.isInteger(v.layers)?v.layers:0}))}):'null'; +export const ledgerText = ledger => ledger?JSON.stringify({format:ledger.format,head:ledger.head,versions:ledger.versions.map(v=>({id:v.id,parent:v.parent,source:v.source,request:v.request??null,createdAt:v.createdAt||'',layers:Number.isInteger(v.layers)?v.layers:0}))}):'null'; export async function verifyThreadRevision(command,state) { const before=state();if(before.busy)throw Error('Device busy'); const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(before.source)); diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index 27abd38c33..e0b65b6df5 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -16,7 +16,7 @@ import {initializeBasePiece,isBasePiece} from './base-piece.mjs'; import {handleCharacterColors,fetchHandleColors} from '/easel/src/handle-colors.mjs'; import {verifyAccount} from '/easel/src/account-access.mjs'; import {localEdit} from './local-scene.mjs'; -import {WhistlegraphThread,verifyThreadRevision} from '/easel/src/whistlegraph-thread.mjs'; +import {WhistlegraphThread,verifyThreadRevision,ledgerText} from '/easel/src/whistlegraph-thread.mjs'; import {instantPiece} from './instant-piece.mjs'; import {MusicalInputSocket} from '/easel/src/musical-input-socket.mjs'; import {MusicalInputAdvisor} from '/easel/src/musical-input-advisor.mjs'; @@ -70,6 +70,16 @@ let turnHandle='',turnModel='',activeModel='',braincells=null,braincellsError='' // until the wallet grows past that or a turn goes through, so the icon says // so instead of a row that fails quietly each time. let braincellsEmptyAt=null; +// Pieces this handle made on the knot that are not on this phone: a turn run +// from elsewhere, another phone, a stand-in. Listed beside the local ones and +// opened by fetching the thread. +let knotPieces=[]; +async function refreshKnotPieces(){ + if(!token){knotPieces=[];postPieces?.();return;} + try{const r=await fetch('https://aesthetic.computer/api/whistlegraph',{headers:{Authorization:'Bearer '+token},signal:AbortSignal.timeout(10000)});if(!r.ok)return;const value=await r.json();if(Array.isArray(value.threads))knotPieces=value.threads.filter(t=>t&&typeof t.id==='string'&&typeof t.code==='string');} + catch{return;} + postPieces?.(); +} function noteBraincells(message){if(/out of braincells/i.test(String(message||'')))braincellsEmptyAt=braincells&&braincells.unlimited!==true?braincells.remaining+braincells.purchased:0;} function braincellsEmpty(){return braincellsEmptyAt!==null||(!!braincells&&braincells.unlimited!==true&&braincells.remaining+braincells.purchased<=0);} function selectedModel(handle=accountHandle){try{return localStorage.getItem('whistlegraph-model-'+handle)||'';}catch{return '';}} @@ -108,7 +118,7 @@ function accountIdentity(value, notice='', force=false){ if(!account||accountToken!==value)return; accountHandle=account.handle;syncAIConsent(); if(!accountHandle){signIn.textContent='Set handle';nativeSnapshot();return;} - paintHandle(accountHandle);void refreshBraincells(); + paintHandle(accountHandle);void refreshBraincells();void refreshKnotPieces(); const handle=accountHandle,revision=accountConnection.revision; void fetchPersonalAccess(value).then(access=>{if(accountToken===value&&accountConnection.revision===revision){personalAccess=access;nativeSnapshot();}}); void fetchHandleColors('@'+handle).then(colors=>{if(accountToken===value&&accountConnection.revision===revision)paintHandle(handle,colors);}).catch(()=>{}); @@ -958,13 +968,38 @@ if(versions&&!window.__whistlegraphSequence&&!window.__whistlegraphBenchmark&&!w const key=localStorage.key(i);if(!key?.startsWith(ARCHIVE))continue; try{const saved=JSON.parse(localStorage.getItem(key));if(saved?.identity?.id&&saved.identity.id!==thread.identity.id)list.push(pieceSummary(saved.identity.id,saved.identity,saved.ledger,false));}catch{} } + for(const t of knotPieces){ + if(list.some(p=>p.id===t.id||(p.code&&p.code===t.code)))continue; + list.push({id:t.id,code:t.code,utterance:String(t.utterance||'').slice(0,160),versions:Number(t.versions)||0,updatedAt:t.updatedAt||'',current:false,remote:true}); + } return list.sort((a,b)=>(b.current-a.current)||(Date.parse(b.updatedAt)||0)-(Date.parse(a.updatedAt)||0)).slice(0,256); } + // A piece from the knot lands on this phone the way a turn's result does: + // the thread is fetched and mounted whole, the open piece parked first. + async function openKnotPiece(t){ + let cloud; + try{const r=await fetch('https://aesthetic.computer/api/whistlegraph?code='+encodeURIComponent(t.code),{headers:{Authorization:'Bearer '+token},signal:AbortSignal.timeout(15000)});cloud=await r.json();} + catch{phase('Could not reach the knot for that piece');return;} + if(!cloud?.ledger?.versions||cloud.id!==t.id){phase('That piece is not on the knot any more');void refreshKnotPieces();return;} + const head=cloud.ledger.versions.find(v=>v.id===cloud.ledger.head); + const parked=archiveCurrentPiece();if(parked.accepted===false)return; + try{ + localStorage.setItem(storageKey,head?.source||''); + localStorage.setItem(storageKey+'-versions',JSON.stringify(cloud.ledger)); + localStorage.setItem(storageKey+'-thread',JSON.stringify({id:cloud.id,code:cloud.code})); + localStorage.setItem(storageKey+'-cloud-revision',String(cloud.revision)); + localStorage.setItem(storageKey+'-cloud-ledger',ledgerText(cloud.ledger)); + }catch{phase('This phone has no room to open that piece');location.reload();return;} + location.reload(); + } postPieces=()=>post({action:'pieces',pieces:pieceList()}); window.whistlegraphVersionSource=id=>versions?.value.versions.find(v=>v.id===id)?.source??null; window.whistlegraphOpenPiece=id=>{ - if(busy||id===thread.identity.id)return {accepted:false,reason:'busy'}; + if(busy||id===thread.identity.id||id===thread.identity.code)return {accepted:false,reason:'busy'}; let saved;try{saved=JSON.parse(localStorage.getItem(ARCHIVE+id));}catch{} + // A push names the piece by code; so may a knot piece that is not on this phone. + if(!saved){for(let i=0;ik.id===id||k.code===id);if(t){phase('Fetching /'+t.code+' from the knot…');void openKnotPiece(t);return {accepted:true};}} if(!saved?.identity?.id||!saved.ledger){phase('That piece is no longer on this phone');postPieces();return {accepted:false,reason:'notReady'};} const parked=archiveCurrentPiece();if(parked.accepted===false)return parked; try{ diff --git a/apple/whistlegraph/Sources/WhistlegraphApp.swift b/apple/whistlegraph/Sources/WhistlegraphApp.swift index 76850bdc3e..d98423cc7d 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -547,8 +547,9 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand func openFromNotification(_ url: String) { let piece = String(url.drop(while: { $0 == "/" }).prefix(64)) DeviceActionLog.shared.record(.notifications, .presented) - guard !piece.isEmpty, pieces.contains(where: { $0.id == piece }) else { return } - if pieces.contains(where: { $0.id == piece && $0.current }) { return } + // The push names the piece by code. One not on this phone is fetched from the knot. + guard !piece.isEmpty else { return } + if pieces.contains(where: { ($0.id == piece || $0.code == piece) && $0.current }) { return } command("openPiece", piece: piece) } /// Drops the Keychain sign-in and tells the engine, which parks its sockets. diff --git a/apple/whistlegraph/Sources/WhistlegraphHeader.swift b/apple/whistlegraph/Sources/WhistlegraphHeader.swift index 7bc4e8d5e7..c9e42d438f 100644 --- a/apple/whistlegraph/Sources/WhistlegraphHeader.swift +++ b/apple/whistlegraph/Sources/WhistlegraphHeader.swift @@ -10,6 +10,8 @@ struct PieceSummary: Decodable, Identifiable { let versions: Int let updatedAt: String let current: Bool + /// Made on the knot and not yet on this phone; opening fetches it. + var remote: Bool? = nil var date: Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -81,13 +83,15 @@ struct PiecesSheet: View { Text(detail(piece)).font(.footnote).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, alignment: .trailing) if piece.current { Image(systemName: "checkmark").foregroundStyle(.secondary).accessibilityLabel("Open now") } + else if piece.remote == true { Image(systemName: "icloud.and.arrow.down").foregroundStyle(.secondary).accessibilityLabel("On the knot") } } } .disabled(disabled && !piece.current) .accessibilityIdentifier("piece-" + piece.title) // Deleting frees this phone's 5 MB piece store; the open piece stays. .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if !piece.current { + // A knot piece is not in this phone's store; nothing here to delete. + if !piece.current && piece.remote != true { Button(role: .destructive) { ButtonSounds.play(.pop); delete(piece.id) } label: { Label("Delete", systemImage: "trash") } .disabled(disabled).accessibilityIdentifier("piece-delete-" + piece.title) } @@ -105,8 +109,9 @@ struct PiecesSheet: View { } private func detail(_ piece: PieceSummary) -> String { let versions = piece.versions == 1 ? "1 version" : "\(piece.versions) versions" - guard let date = piece.date else { return versions } - return versions + " · " + date.formatted(.relative(presentation: .named)) + let where_ = piece.remote == true ? "on the knot · " : "" + guard let date = piece.date else { return where_ + versions } + return where_ + versions + " · " + date.formatted(.relative(presentation: .named)) } } diff --git a/system/backend/whistlegraph.mjs b/system/backend/whistlegraph.mjs index 5ac9fbd744..524614a8dc 100644 --- a/system/backend/whistlegraph.mjs +++ b/system/backend/whistlegraph.mjs @@ -44,7 +44,9 @@ export function mongoWhistlegraphStore(collection, {name=pronounceableCode}={}) throw Error('Unable to reserve a name'); }, async read(owner,code) {if(!validCode(code))return null;return collection.findOne({owner,codeKey:code.toLowerCase()});}, - async list(owner) {return collection.find({owner},{projection:{owner:0,ledger:0,receipts:0}}).sort({updatedAt:-1}).limit(100).toArray();}, + // A listing carries each thread's shape, not its ledger: the head, how + // many versions were made, and the words of the last one. + async list(owner) {return collection.find({owner},{projection:{code:1,revision:1,updatedAt:1,published:1,head:'$ledger.head',versions:{$subtract:[{$size:{$ifNull:['$ledger.versions',[]]}},1]},lastRequest:{$arrayElemAt:['$ledger.versions.request',-1]},lastAt:{$arrayElemAt:['$ledger.versions.createdAt',-1]}}}).sort({updatedAt:-1}).limit(100).toArray();}, async receipt(owner,id,value) { const receipt=validateReceipt(value); // First write wins; retries are idempotent within the retained history. @@ -95,6 +97,12 @@ export function mongoWhistlegraphStore(collection, {name=pronounceableCode}={}) } }; } +// One row of a listing, as the phone's piece list shows it. +export function threadSummary(row) { + if(!row)return null; + const versions=Math.max(0,Number(row.versions)||0); + return {id:row._id,code:row.code,revision:row.revision,updatedAt:row.lastAt||row.updatedAt,head:row.head??0,versions,utterance:versions>0&&row.lastRequest?String(captionOf(row.lastRequest)).slice(0,160):'',published:row.published===true}; +} export function publicThread(row) { if(!row)return null; const head=row.ledger?.versions.find(v=>v.id===row.ledger.head); diff --git a/system/netlify/functions/whistlegraph.mjs b/system/netlify/functions/whistlegraph.mjs index d016d7cdfb..fca134905e 100644 --- a/system/netlify/functions/whistlegraph.mjs +++ b/system/netlify/functions/whistlegraph.mjs @@ -1,6 +1,6 @@ import {connect} from '../../backend/database.mjs'; import {authenticateMusical} from './easel-musical-jev.mjs'; -import {mongoWhistlegraphStore,publicThread} from '../../backend/whistlegraph.mjs'; +import {mongoWhistlegraphStore,publicThread,threadSummary} from '../../backend/whistlegraph.mjs'; export {authenticateMusical as authenticateWhistlegraph}; let pending; export const whistlegraphStore=()=>pending??=(async()=>{const {db}=await connect();return mongoWhistlegraphStore(db.collection('walkieware-threads'));})().catch(error=>{pending=null;throw error;}); @@ -17,7 +17,7 @@ export async function handler(event) { if(!code||event.queryStringParameters?.receipts!=='1')return reply(400,{error:'Specify a thread code and receipts=1'}); return await store.clearReceipts(owner,code)?reply(200,{cleared:true}):reply(404,{error:'Thread unavailable'}); } - if(!code)return reply(200,{threads:(await store.list(owner)).map(publicThread)}); + if(!code)return reply(200,{threads:(await store.list(owner)).map(threadSummary)}); const row=await store.read(owner,code); return row?reply(200,publicThread(row)):reply(404,{error:'Thread unavailable'}); }catch{return reply(503,{error:'Thread storage unavailable'});}