From f5aa836a6bd66dd90576c02f4621d0ec72ee0042 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 9 Oct 2026 11:27:58 -0700 Subject: [PATCH] mime.ac: a public feed of published Whistlegraph mimes, in the app and over the API A thread its owner publishes becomes readable by anyone. The store gains publish/readPublished/feed; the handle is stamped at publish time so the feed needs no account lookup, and unpublishing keeps the row private again. GET /api/mime-feed lists published mimes newest first (code, handle, the words it was asked for, head source, version count), paged by publishedAt, with receipts, diagnostics and the ledger left out. POST /api/whistlegraph-publish flips the flag for the owner. publicThread now reports published so the phone can show the switch. In the app, Explore mimes opens a full-screen vertical pager: one AC runtime behind transparent cards renders whichever mime is in view, each card shows the handle, caption, /code and version count, Make your own leads to sign-in or back to the workspace, and Report mails the desk. It sits on the signed-out screen and in Your Pieces, where a Publish switch puts the open piece on mime.ac once it has a made version. --- .../Sources/DeviceActionLog.swift | 2 +- apple/whistlegraph/Sources/MimeFeed.swift | 224 ++++++++++++++++++ .../Sources/WhistlegraphAccountEntry.swift | 4 + .../Sources/WhistlegraphHeader.swift | 22 +- .../Whistlegraph.xcodeproj/project.pbxproj | 4 + system/backend/whistlegraph-feed.test.mjs | 75 ++++++ system/backend/whistlegraph.mjs | 40 +++- system/netlify/functions/mime-feed.mjs | 18 ++ .../functions/whistlegraph-publish.mjs | 24 ++ 9 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 apple/whistlegraph/Sources/MimeFeed.swift create mode 100644 system/backend/whistlegraph-feed.test.mjs create mode 100644 system/netlify/functions/mime-feed.mjs create mode 100644 system/netlify/functions/whistlegraph-publish.mjs diff --git a/apple/whistlegraph/Sources/DeviceActionLog.swift b/apple/whistlegraph/Sources/DeviceActionLog.swift index e9a6c12006..6803fa278f 100644 --- a/apple/whistlegraph/Sources/DeviceActionLog.swift +++ b/apple/whistlegraph/Sources/DeviceActionLog.swift @@ -8,7 +8,7 @@ final class DeviceActionLog: @unchecked Sendable { case typeEdit, typeSend, talkBegin, talkEnd, talkLatch, talkCancel, speech case accountToken, accountIdentity, signIn, signOut, consent, consentBridge case workspace, snapshot, inference, credits, storeCatalog, storePurchase, storeDelivery - case drawing, preview, story, share, deletion, logExport, logClear, source, projection, notifications + case drawing, preview, story, share, deletion, logExport, logClear, source, projection, notifications, feed, publish } enum Outcome: String, Codable { case requested, started, ended, ready, succeeded, failed, cancelled, denied diff --git a/apple/whistlegraph/Sources/MimeFeed.swift b/apple/whistlegraph/Sources/MimeFeed.swift new file mode 100644 index 0000000000..32a21a35e3 --- /dev/null +++ b/apple/whistlegraph/Sources/MimeFeed.swift @@ -0,0 +1,224 @@ +import SwiftUI +import WebKit + +// mime.ac in the app: a vertical, swipeable feed of mimes people published. +// One AC runtime renders whichever card is in view; the cards carry the +// handle and the words it was asked for. Exploring needs no account. + +struct Mime: Decodable, Identifiable, Equatable { + let code: String + let handle: String + let caption: String + let source: String + let versions: Int + let publishedAt: String? + var id: String { code } +} + +@MainActor final class MimeFeedModel: ObservableObject { + @Published var mimes: [Mime] = [] + @Published var loading = false + @Published var error = "" + private var next: String? + private var exhausted = false + static let api = URL(string: "https://aesthetic.computer/api/mime-feed")! + func load(more: Bool = false) async { + guard !loading, !(more && exhausted) else { return } + loading = true; defer { loading = false } + var parts = URLComponents(url: Self.api, resolvingAgainstBaseURL: false)! + parts.queryItems = [URLQueryItem(name: "limit", value: "20")] + if more, let next { parts.queryItems?.append(URLQueryItem(name: "before", value: next)) } + struct Page: Decodable { let mimes: [Mime]; let next: String? } + do { + let (data, response) = try await URLSession.shared.data(for: URLRequest(url: parts.url!, timeoutInterval: 20)) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) } + let page = try JSONDecoder().decode(Page.self, from: data) + if more { mimes += page.mimes.filter { mime in !mimes.contains(where: { $0.code == mime.code }) } } else { mimes = page.mimes } + next = page.next; exhausted = page.mimes.isEmpty || page.next == nil + error = "" + DeviceActionLog.shared.record(.feed, .ready, [.version: mimes.count]) + } catch { + self.error = mimes.isEmpty ? "The feed could not load. Check your connection and try again." : "" + DeviceActionLog.shared.recordError(.feed, error) + } + } +} + +/// The one AC runtime behind the feed. It loads once; each card in view hands +/// it a source. Touches go to the pager, not the piece. +struct MimeRuntime: UIViewRepresentable { + let source: String + static let url = URL(string: "https://aesthetic.computer/wipe?noauth=true&noplot=true&nogap=true&nolabel=true&preview=walkieware")! + final class Coordinator: NSObject, WKNavigationDelegate { + var source = "" + func render(_ view: WKWebView) { + guard let data = try? JSONSerialization.data(withJSONObject: [source]), let json = String(data: data, encoding: .utf8) else { return } + view.evaluateJavaScript("window.__mimeRender?.(\(json)[0]);", completionHandler: nil) + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { render(webView) } + } + func makeCoordinator() -> Coordinator { Coordinator() } + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.allowsInlineMediaPlayback = true + config.mediaTypesRequiringUserActionForPlayback = [] + config.userContentController.addUserScript(WKUserScript(source: Self.script, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + let view = WKWebView(frame: .zero, configuration: config) + view.isOpaque = false; view.backgroundColor = .black + view.scrollView.isScrollEnabled = false + view.isUserInteractionEnabled = false + view.navigationDelegate = context.coordinator + #if DEBUG + view.isInspectable = true + #endif + view.load(URLRequest(url: Self.url)) + return view + } + func updateUIView(_ view: WKWebView, context: Context) { + guard context.coordinator.source != source else { return } + context.coordinator.source = source + context.coordinator.render(view) + } + static let script = """ + (() => { + let pending = null, ready = false; + const flush = () => { + if (!ready || pending == null) return; + const source = pending; pending = null; + window.AC?.startAudio?.(); + window.acSEND({type:'dropped:piece', content:{name:'mime', source, search:'noauth=true&noplot=true&nogap=true&nolabel=true', isKidLisp:false}}); + }; + window.__mimeRender = source => { pending = source; flush(); }; + const poll = setInterval(() => { + if (!window.preloaded || !window.acSEND) return; + clearInterval(poll); ready = true; flush(); + }, 100); + })(); + """ +} + +struct MimeFeedView: View { + let makeYourOwn: () -> Void + @StateObject private var model = MimeFeedModel() + @State private var current: String? + @Environment(\.dismiss) private var dismiss + private var active: Mime? { model.mimes.first { $0.code == current } ?? model.mimes.first } + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + if let active { MimeRuntime(source: active.source).ignoresSafeArea() } + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(model.mimes) { mime in + MimeCard(mime: mime, makeYourOwn: makeYourOwn) + .containerRelativeFrame(.vertical) + .id(mime.code) + .onAppear { if mime == model.mimes.last { Task { await model.load(more: true) } } } + } + }.scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollPosition(id: $current) + .scrollIndicators(.hidden) + .ignoresSafeArea() + .accessibilityIdentifier("mime-feed") + if model.mimes.isEmpty { + VStack(spacing: 12) { + if model.loading { ProgressView().tint(.white) } + else if !model.error.isEmpty { + Text(model.error).multilineTextAlignment(.center) + Button("Try again") { Task { await model.load() } }.buttonStyle(.bordered).tint(.white) + } else { + Text("Nothing published yet.").font(.custom("ComicRelief-Regular", size: 20, relativeTo: .title3)) + Text("Make a piece and switch on mime.ac in Your Pieces.").multilineTextAlignment(.center).font(.footnote) + } + }.foregroundStyle(.white).padding(32) + } + VStack { + HStack { + Button { ButtonSounds.play(.pop); dismiss() } label: { + Image(systemName: "xmark").font(.system(size: 20, weight: .bold)).frame(width: 44, height: 44) + }.foregroundStyle(.white).accessibilityLabel("Close feed").accessibilityIdentifier("mime-feed-close") + Spacer() + ComicTitle(text: "mime.ac", size: 22) + }.padding(.horizontal, 8) + Spacer() + } + } + .task { await model.load() } + .onChange(of: current) { _, _ in ButtonSounds.play(.tick) } + .preferredColorScheme(.dark) + } +} + +struct MimeCard: View { + let mime: Mime + let makeYourOwn: () -> Void + var body: some View { + VStack { + Spacer() + VStack(alignment: .leading, spacing: 6) { + if !mime.handle.isEmpty { ComicTitle(text: mime.handle, size: 22) } + Text(mime.caption.isEmpty ? "/" + mime.code : mime.caption) + .font(.custom("ComicRelief-Regular", size: 20, relativeTo: .title3)).foregroundStyle(.white) + Text("/\(mime.code) · v\(mime.versions)").font(.footnote).foregroundStyle(.white.opacity(0.7)) + HStack { + Button(action: makeYourOwn) { + Text("Make your own").font(.custom("ComicRelief-Bold", size: 18, relativeTo: .body)).foregroundStyle(.black) + }.buttonStyle(.borderedProminent).tint(Color(red: 0.40, green: 0.83, blue: 0.95)) + .accessibilityIdentifier("mime-make-your-own") + Spacer() + // Apple asks hosted content to be reportable; mail is the channel until there is a desk. + Link("Report", destination: URL(string: "mailto:mail@aesthetic.computer?subject=Report%20mime%20/" + mime.code)!) + .font(.footnote).foregroundStyle(.white.opacity(0.7)) + }.padding(.top, 6) + } + .padding(20).padding(.bottom, 28) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LinearGradient(colors: [.clear, .black.opacity(0.75)], startPoint: .top, endPoint: .bottom)) + } + .contentShape(Rectangle()) + .accessibilityIdentifier("mime-" + mime.code) + } +} + +/// Publishing the open piece to mime.ac, from Your Pieces. The thread row on +/// the server carries the flag; the phone reads it when the sheet opens. +@MainActor final class MimePublishModel: ObservableObject { + @Published var published: Bool? = nil + @Published var busy = false + @Published var notice = "" + private var code = "" + private struct ThreadState: Decodable { let published: Bool? } + func load(code: String, account: WhistlegraphAccount) async { + self.code = code; published = nil; notice = "" + guard !code.isEmpty, let token = try? await account.token() else { return } + var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/whistlegraph?code=\(code)")!, timeoutInterval: 10) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + guard let (data, response) = try? await URLSession.shared.data(for: request), + (response as? HTTPURLResponse)?.statusCode == 200, + let thread = try? JSONDecoder().decode(ThreadState.self, from: data) else { return } + published = thread.published ?? false + } + func set(_ value: Bool, account: WhistlegraphAccount) async { + guard !busy, !code.isEmpty else { return } + busy = true; defer { busy = false } + guard let token = try? await account.token() else { notice = "Sign in again to publish."; return } + var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/whistlegraph-publish")!, timeoutInterval: 15) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["code": code, "published": value]) + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) } + struct Reply: Decodable { let published: Bool } + published = try JSONDecoder().decode(Reply.self, from: data).published + notice = published == true ? "On mime.ac now." : "Off mime.ac." + DeviceActionLog.shared.record(.publish, value ? .enabled : .disabled) + } catch { + notice = "Could not change that right now. Try again." + DeviceActionLog.shared.recordError(.publish, error) + } + } +} diff --git a/apple/whistlegraph/Sources/WhistlegraphAccountEntry.swift b/apple/whistlegraph/Sources/WhistlegraphAccountEntry.swift index 3a57a52f6c..97798eaafa 100644 --- a/apple/whistlegraph/Sources/WhistlegraphAccountEntry.swift +++ b/apple/whistlegraph/Sources/WhistlegraphAccountEntry.swift @@ -6,6 +6,7 @@ struct WhistlegraphAccountEntry: View { @ObservedObject var session: WhistlegraphSession @Environment(\.colorScheme) private var scheme @Environment(\.openURL) private var openURL + @State private var showingFeed = false private func action(_ title: String, signUp: Bool = false, perform: @escaping () -> Void) -> some View { Button(action: perform) { @@ -36,6 +37,8 @@ struct WhistlegraphAccountEntry: View { action("Log in") { session.signIn() }.accessibilityIdentifier("account-entry-login") action("I'm new", signUp: true) { session.signIn(signUp: true) }.accessibilityIdentifier("account-entry-signup") } + // Exploring needs no account: the feed is the front door. + action("Explore mimes") { ButtonSounds.play(.play); showingFeed = true }.accessibilityIdentifier("account-entry-explore") // Logging in is the AI permission (App Review 5.1.2(i)); the policy names who sees what. Text("By continuing, what you say, type and draw goes to AI helpers. [Privacy](https://aesthetic.computer/privacy-policy.html)") .font(.custom("ComicRelief-Regular", size: 14, relativeTo: .footnote)) @@ -58,6 +61,7 @@ struct WhistlegraphAccountEntry: View { } .foregroundStyle(theme.foreground).background(theme.background.ignoresSafeArea()) .accessibilityIdentifier("account-entry") + .fullScreenCover(isPresented: $showingFeed) { MimeFeedView { showingFeed = false; session.signIn() } } } } diff --git a/apple/whistlegraph/Sources/WhistlegraphHeader.swift b/apple/whistlegraph/Sources/WhistlegraphHeader.swift index f661c4d78a..7bc4e8d5e7 100644 --- a/apple/whistlegraph/Sources/WhistlegraphHeader.swift +++ b/apple/whistlegraph/Sources/WhistlegraphHeader.swift @@ -28,7 +28,10 @@ struct PiecesSheet: View { let newPiece: () -> Void var delete: (String) -> Void = { _ in } var mintSession: WhistlegraphSession? = nil + var session: WhistlegraphSession? = nil @Environment(\.dismiss) private var dismiss + @StateObject private var publishing = MimePublishModel() + @State private var showingFeed = false var body: some View { NavigationStack { List { @@ -47,6 +50,21 @@ struct PiecesSheet: View { } } #endif + Section("mime.ac") { + Button { ButtonSounds.play(.play); showingFeed = true } label: { + Label("Explore mimes", systemImage: "play.rectangle.on.rectangle") + }.accessibilityIdentifier("pieces-explore") + if let session, let current = pieces.first(where: { $0.current }), !current.code.isEmpty { + Toggle(isOn: Binding(get: { publishing.published == true }, + set: { value in Task { await publishing.set(value, account: session.account) } })) { + Label("Publish /" + current.code, systemImage: "antenna.radiowaves.left.and.right") + } + .disabled(publishing.published == nil || publishing.busy || current.versions == 0) + .accessibilityIdentifier("pieces-publish") + if current.versions == 0 { Text("Make a first version to publish.").font(.footnote).foregroundStyle(.secondary) } + else if !publishing.notice.isEmpty { Text(publishing.notice).font(.footnote).foregroundStyle(.secondary) } + } + } Section(pieces.count == 1 ? "Your piece" : "Your pieces") { ForEach(pieces) { piece in Button { @@ -80,6 +98,8 @@ struct PiecesSheet: View { .navigationTitle("Pieces") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + .task { if let session, let current = pieces.first(where: { $0.current }) { await publishing.load(code: current.code, account: session.account) } } + .fullScreenCover(isPresented: $showingFeed) { MimeFeedView { showingFeed = false; dismiss() } } } .presentationDetents([.medium, .large]) } @@ -190,7 +210,7 @@ struct IdentityHeader: View { disabled: session.snapshot.busy || session.capturePhase != .idle, open: { session.command("openPiece", piece: $0) }, newPiece: { session.command("newPiece") }, - delete: { session.command("deletePiece", piece: $0) }, mintSession: session) + delete: { session.command("deletePiece", piece: $0) }, mintSession: session, session: session) } .sheet(isPresented: $showingAccount) { AccountSheet(handle: session.snapshot.handle, colors: session.snapshot.colors, appearance: $appearance, diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index be3c6b412b..40decff3be 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 14FFBE48767E2576693FB7B3 /* MusicalInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D233DBB6F87184A91C13D41 /* MusicalInput.swift */; }; 15EE658816EEA4B8BE6AAF5A /* StoryVoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49854384EFBA11ABDE4F70C6 /* StoryVoice.swift */; }; 19717448D7598ADAABBF739E /* HeaderSheetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F98F7D3CE551FC6458D1F37 /* HeaderSheetsTests.swift */; }; + 1B101CD76675E76E3CADEF17 /* MimeFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF9805E6F3EA99293F95943 /* MimeFeed.swift */; }; 24D22223CEEE7F360CEAEADC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FCE365B860DBD53E42DB2824 /* PrivacyInfo.xcprivacy */; }; 26D07334E2DEBA6705671BB0 /* WhistlegraphApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5F188938A81E704BB7F9F9 /* WhistlegraphApp.swift */; }; 275555C74507A6F62B0F037B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF5F023E15041FF61F9FD358 /* Assets.xcassets */; }; @@ -156,6 +157,7 @@ B7A25F803FAC121BFECC1AD7 /* SpeechCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechCapture.swift; sourceTree = ""; }; B8B4655A00B6DC810F7203D1 /* StoryCardsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryCardsTests.swift; sourceTree = ""; }; BA5F188938A81E704BB7F9F9 /* WhistlegraphApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphApp.swift; sourceTree = ""; }; + BDF9805E6F3EA99293F95943 /* MimeFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MimeFeed.swift; sourceTree = ""; }; C534DE3AC6E813F8BDFE5244 /* WhistlegraphDeleteAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphDeleteAccount.swift; sourceTree = ""; }; CB5E6AF3AF51BAA89BAC231E /* RecordedTranscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordedTranscription.swift; sourceTree = ""; }; CC1EFF8BAE7678E320E3DCA0 /* RecordingTrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingTrim.swift; sourceTree = ""; }; @@ -210,6 +212,7 @@ 325762318FCE1CB2986C5B1F /* DeviceRegistry.swift */, 35643029A3E82EEDEAC8DED6 /* InputButtonLabels.swift */, 7848DB9FED6B955BC193F650 /* LiveTranscription.swift */, + BDF9805E6F3EA99293F95943 /* MimeFeed.swift */, 5D233DBB6F87184A91C13D41 /* MusicalInput.swift */, 5B20A9A8612C57068C67239A /* NativeScreenFixture.swift */, 4BBA0E45689010CB41A6A8FF /* NetworkBenchmark.swift */, @@ -431,6 +434,7 @@ 4ABB5050941AD905930711B7 /* DeviceRegistry.swift in Sources */, 123228FADA6696AB354306C7 /* InputButtonLabels.swift in Sources */, 5D29324F2AF09B53070D7644 /* LiveTranscription.swift in Sources */, + 1B101CD76675E76E3CADEF17 /* MimeFeed.swift in Sources */, 14FFBE48767E2576693FB7B3 /* MusicalInput.swift in Sources */, B35A82E6CD62E5D1D9460EE3 /* NativeScreenFixture.swift in Sources */, 77EC5FCFB7F9BF4A6BD4F94B /* NativeSignIn.swift in Sources */, diff --git a/system/backend/whistlegraph-feed.test.mjs b/system/backend/whistlegraph-feed.test.mjs new file mode 100644 index 0000000000..4f1351872e --- /dev/null +++ b/system/backend/whistlegraph-feed.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mongoWhistlegraphStore,captionOf,feedEntry,publicThread} from './whistlegraph.mjs'; + +// The smallest Mongo a feed needs: find with sort/limit/projection, findOne, updateOne, insertOne. +function collection() { + const rows=[]; + const matches=(row,query)=>Object.entries(query).every(([key,value])=>{ + const actual=key.includes('.')?key.split('.').reduce((o,k)=>o?.[k],row):row[key]; + if(value&&typeof value==='object'&&!Array.isArray(value)){ + if('$gt' in value&&!(actual>value.$gt))return false; + if('$lt' in value&&!(actual{}, + insertOne:async row=>{rows.push({...row});}, + findOne:async query=>rows.find(r=>matches(r,query))||null, + updateOne:async(query,update)=>{const row=rows.find(r=>matches(r,query));if(row){Object.assign(row,update.$set||{});return {modifiedCount:1};}return {modifiedCount:0};}, + find(query,{projection={}}={}){let out=rows.filter(r=>matches(r,query));const chain={ + sort(spec){const [[key,dir]]=Object.entries(spec);out=out.slice().sort((a,b)=>(a[key]>b[key]?1:a[key]out.map(r=>Object.fromEntries(Object.entries(r).filter(([k])=>projection[k]!==0))), + };return chain;}, + }; +} +const ledger=(n,request)=>({format:1,head:n,versions:Array.from({length:n+1},(_,id)=>({id,parent:id?id-1:null,source:'export function paint({wipe}){wipe('+id+');}',request:id?request:null,createdAt:'2026-10-09T00:00:0'+id+'Z',layers:0}))}); + +test('captions read like the phone: transcript, Drawing/Sound, typed text, never INPUT DATA',()=>{ + assert.equal(captionOf(null),'Starting piece'); + assert.equal(captionOf('make a moon'),'make a moon'); + assert.equal(captionOf('Interpret this combined request.\nINPUT DATA:\n{"transcript":"a pink circle · 2.5 seconds","drawing":{}}'),'a pink circle'); + assert.equal(captionOf('Interpret this.\nINPUT DATA:\n{"transcript":"","drawing":{"strokes":[]}}'),'Drawing'); + assert.equal(captionOf('Interpret this.\nINPUT DATA:\n{"transcript":"","sound":{}}'),'Sound'); + assert.equal(captionOf('x'.repeat(400)).length,160); +}); + +test('only published threads with a made version feed, newest first, paged by publishedAt, with private parts left out',async()=>{ + const c=collection(),store=mongoWhistlegraphStore(c,{name:(()=>{let i=0;return()=>'wgfeed'+String.fromCharCode(97+i++);})()}); + const ids=[1,2,3,4].map(n=>'11111111-1111-4111-8111-11111111111'+n); + const rows=[]; + for(const id of ids)rows.push(await store.open('alice',id)); + await store.save('alice',ids[0],0,ledger(2,'a moon')); + await store.save('alice',ids[1],0,ledger(1,'a sun')); + await store.save('alice',ids[2],0,ledger(0,null)); // never asked for anything + await store.save('alice',ids[3],0,ledger(3,'a comet')); + assert.equal(await store.publish('bob',rows[0].code,true,'@bob'),null,'only the owner publishes'); + assert.equal(await store.publish('alice','nope',true,'@alice'),null); + c.rows[0].publishedAt=undefined; + const first=await store.publish('alice',rows[0].code,true,'@alice'); + assert.equal(first.published,true);assert.equal(first.handle,'@alice');assert.ok(first.publishedAt); + c.rows.find(r=>r._id===ids[0]).publishedAt='2026-10-09T10:00:00Z'; + await store.publish('alice',rows[1].code,true,'@alice');c.rows.find(r=>r._id===ids[1]).publishedAt='2026-10-09T11:00:00Z'; + await store.publish('alice',rows[2].code,true,'@alice');c.rows.find(r=>r._id===ids[2]).publishedAt='2026-10-09T12:00:00Z'; + await store.publish('alice',rows[3].code,true,'not-a-handle');c.rows.find(r=>r._id===ids[3]).publishedAt='2026-10-09T13:00:00Z'; + await store.receipt('alice',ids[3],{id:'r1',requestID:'q',parent:3,parentHash:'h',path:'compiled',model:'m',status:'completed',createdAt:'now'}).catch(()=>{}); + const feed=(await store.feed({limit:10})).map(feedEntry); + assert.deepEqual(feed.map(m=>m.caption),['a comet','a sun','a moon'],'the never-asked thread stays out'); + assert.deepEqual(feed.map(m=>m.versions),[3,1,2]); + assert.equal(feed[0].handle,'','a bad handle is not stamped'); + assert.equal(feed[1].handle,'@alice'); + assert.match(feed[0].source,/wipe\(3\)/); + for(const m of feed){assert.equal('receipts' in m,false);assert.equal('ledger' in m,false);assert.equal('owner' in m,false);} + const page=(await store.feed({before:'2026-10-09T12:30:00Z',limit:1})).map(feedEntry); + assert.deepEqual(page.map(m=>m.caption),['a sun']); + assert.equal((await store.readPublished(rows[0].code)).code,rows[0].code); + await store.publish('alice',rows[0].code,false); + assert.equal(await store.readPublished(rows[0].code),null,'unpublished is private again'); + assert.equal(publicThread(c.rows.find(r=>r._id===ids[1])).published,true); + assert.equal(publicThread(c.rows.find(r=>r._id===ids[0])).published,false); + assert.equal((await store.feed({limit:10})).length,2); +}); diff --git a/system/backend/whistlegraph.mjs b/system/backend/whistlegraph.mjs index 80ef6c73b6..5ac9fbd744 100644 --- a/system/backend/whistlegraph.mjs +++ b/system/backend/whistlegraph.mjs @@ -62,6 +62,26 @@ export function mongoWhistlegraphStore(collection, {name=pronounceableCode}={}) await collection.updateOne({_id:row._id,owner},{$unset:{receipts:''}});return true; }, async state(owner,id,state){await collection.updateOne({_id:id,owner},{$set:{diagnostics:state}});}, + // mime.ac: a thread its owner published is readable by anyone, by code or + // in the feed. The handle is stamped at publish time so the feed never + // needs an account lookup; unpublishing keeps the row and its history. + async publish(owner,code,published,handle) { + if(!validCode(code))return null; + const row=await collection.findOne({owner,codeKey:code.toLowerCase()}); + if(!row)return null; + const set=published?{published:true,publishedAt:row.publishedAt||new Date().toISOString(),handle:typeof handle==='string'&&handle.startsWith('@')?handle:(row.handle||'')}:{published:false}; + await collection.updateOne({_id:row._id,owner},{$set:set}); + return {...row,...set}; + }, + async readPublished(code) { + if(!validCode(code))return null; + return collection.findOne({codeKey:code.toLowerCase(),published:true},{projection:{receipts:0,diagnostics:0}}); + }, + async feed({before,limit=20}={}) { + const query={published:true,'ledger.head':{$gt:0}}; + if(typeof before==='string'&&before)query.publishedAt={$lt:before}; + return collection.find(query,{projection:{receipts:0,diagnostics:0}}).sort({publishedAt:-1}).limit(Math.min(50,Math.max(1,Number(limit)|0||20))).toArray(); + }, async save(owner,id,revision,ledger) { ledger=validateLedger(ledger); const previous=await collection.findOne({_id:id,owner,revision}); @@ -78,5 +98,23 @@ export function mongoWhistlegraphStore(collection, {name=pronounceableCode}={}) export function publicThread(row) { if(!row)return null; const head=row.ledger?.versions.find(v=>v.id===row.ledger.head); - return {id:row._id,code:row.code,revision:row.revision,ledger:row.ledger,head:head?.id,sourceHash:head?sourceHash(head.source):null,updatedAt:row.updatedAt,diagnostics:row.diagnostics,receipts:row.receipts}; + return {id:row._id,code:row.code,revision:row.revision,ledger:row.ledger,head:head?.id,sourceHash:head?sourceHash(head.source):null,updatedAt:row.updatedAt,diagnostics:row.diagnostics,receipts:row.receipts,published:row.published===true}; +} +// The words a version was asked for, as the phone shows them: the transcript +// of a spoken or mixed request, "Drawing"/"Sound" for chalk or sound alone, +// the typed text otherwise. Never the INPUT DATA block. +export function captionOf(request) { + if(request==null)return 'Starting piece'; + const raw=String(request); + let data=null;try{data=JSON.parse(raw.split('\nINPUT DATA:\n')[1]);}catch{} + const text=data?(data.transcript||(data.drawing?'Drawing':'Sound')):raw.split('\nINPUT DATA:\n')[0]; + return String(text||'Starting piece').replace(/ · [\d.]+ seconds$/,'').trim().slice(0,160); +} +// What the feed shows for one published mime: enough to render it and say +// who made it. Receipts, diagnostics and the full ledger stay private. +export function feedEntry(row) { + if(!row)return null; + const versions=row.ledger?.versions||[]; + const head=versions.find(v=>v.id===row.ledger?.head),made=versions.filter(v=>v.id>0),last=made.at(-1); + return {code:row.code,handle:row.handle||'',caption:last?captionOf(last.request):'',source:head?.source||'',versions:made.length,publishedAt:row.publishedAt||null,updatedAt:row.updatedAt}; } diff --git a/system/netlify/functions/mime-feed.mjs b/system/netlify/functions/mime-feed.mjs new file mode 100644 index 0000000000..5e062052d9 --- /dev/null +++ b/system/netlify/functions/mime-feed.mjs @@ -0,0 +1,18 @@ +// mime.ac: the public feed of published Whistlegraph mimes, newest first. +// GET /api/mime-feed?limit=20&before= → {mimes:[…], next} +// GET /api/mime-feed?code=wgXxxxx → one published mime +// Only threads their owner published appear; nothing here needs an account. +import {whistlegraphStore} from './whistlegraph.mjs'; +import {feedEntry} from '../../backend/whistlegraph.mjs'; +export async function handler(event) { + const headers={'Content-Type':'application/json','Cache-Control':'public, max-age=30','Access-Control-Allow-Origin':'*'}; + const reply=(statusCode,value)=>({statusCode,headers,body:JSON.stringify(value)}); + if(event.httpMethod==='OPTIONS')return reply(204,{}); + if(event.httpMethod!=='GET')return reply(405,{error:'Use GET'}); + try { + const store=await whistlegraphStore(),q=event.queryStringParameters||{}; + if(q.code){const row=await store.readPublished(q.code);return row?reply(200,feedEntry(row)):reply(404,{error:'No such mime'});} + const rows=await store.feed({before:q.before,limit:Number(q.limit)||20}); + return reply(200,{mimes:rows.map(feedEntry),next:rows.length?rows.at(-1).publishedAt:null}); + }catch{return reply(503,{error:'Feed unavailable'});} +} diff --git a/system/netlify/functions/whistlegraph-publish.mjs b/system/netlify/functions/whistlegraph-publish.mjs new file mode 100644 index 0000000000..2afd7380a6 --- /dev/null +++ b/system/netlify/functions/whistlegraph-publish.mjs @@ -0,0 +1,24 @@ +// Publish or unpublish one of your mimes on the mime.ac feed. +// POST /api/whistlegraph-publish Authorization: Bearer +// {code:'wgXxxxx', published:true|false} → {published, mime} +import {whistlegraphStore} from './whistlegraph.mjs'; +import {authenticateMusical} from './easel-musical-jev.mjs'; +import {getHandleOrEmail} from '../../backend/authorization.mjs'; +import {feedEntry} from '../../backend/whistlegraph.mjs'; +export async function handler(event) { + const headers={'Content-Type':'application/json','Cache-Control':'no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization, Content-Type','Access-Control-Allow-Methods':'POST, OPTIONS'}; + const reply=(statusCode,value)=>({statusCode,headers,body:JSON.stringify(value)}); + if(event.httpMethod==='OPTIONS')return reply(204,{}); + if(event.httpMethod!=='POST')return reply(405,{error:'Use POST'}); + try { + const owner=await authenticateMusical(event.headers||{}); + if(!owner)return reply(401,{error:'Sign in to your AC account'}); + let body;try{body=JSON.parse(event.body||'{}');}catch{return reply(400,{error:'Send JSON'});} + if(typeof body.code!=='string'||typeof body.published!=='boolean')return reply(400,{error:'Specify code and published'}); + const store=await whistlegraphStore(); + const handle=body.published?await getHandleOrEmail(owner):undefined; + const row=await store.publish(owner,body.code,body.published,handle); + if(!row)return reply(404,{error:'Thread unavailable'}); + return reply(200,{published:row.published===true,mime:row.published?feedEntry(row):null}); + }catch{return reply(503,{error:'Thread storage unavailable'});} +} -- 2.51.2