From 41f7f9899524c6b140a74f2b4038a6683cc0549f Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 23 Apr 2026 18:56:33 -0700 Subject: [PATCH] tell: one-way user notifications + ios 1.1 (2) - register-push-token endpoint + ios FCM token bridge (Swift -> WebView -> /api/register-push-token) - tell endpoint sends push directly to recipient tokens (fast path, not topics); stores in `tells` collection - `tell @handle text` prompt command patterned after `mood` - delete-erase-and-forget-me cascades push-tokens + tells - ios: bump Firebase SPM 10.19 -> 11.15, drop removed FirebaseAnalyticsSwift, bump MARKETING 1.0 -> 1.1 / BUILD 1 -> 2 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../project.pbxproj | 26 +- .../xcshareddata/swiftpm/Package.resolved | 254 +++++++++--------- .../aesthetic_computerApp.swift | 38 ++- .../functions/delete-erase-and-forget-me.mjs | 8 + .../netlify/functions/register-push-token.mjs | 66 +++++ system/netlify/functions/tell.mjs | 156 +++++++++++ system/public/aesthetic.computer/bios.mjs | 69 +++++ system/public/aesthetic.computer/boot.mjs | 8 +- .../aesthetic.computer/disks/prompt.mjs | 35 +++ 9 files changed, 518 insertions(+), 142 deletions(-) create mode 100644 system/netlify/functions/register-push-token.mjs create mode 100644 system/netlify/functions/tell.mjs diff --git a/apple/aesthetic.computer.xcodeproj/project.pbxproj b/apple/aesthetic.computer.xcodeproj/project.pbxproj index 7a820651c9..8aa08fe581 100644 --- a/apple/aesthetic.computer.xcodeproj/project.pbxproj +++ b/apple/aesthetic.computer.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 41F5CDCE2B2931B000F7FF87 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 41F5CDCD2B2931B000F7FF87 /* Preview Assets.xcassets */; }; 41F5CDD62B29333300F7FF87 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDD52B29333300F7FF87 /* FirebaseAnalytics */; }; 41F5CDD82B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDD72B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion */; }; - 41F5CDDA2B29333300F7FF87 /* FirebaseAnalyticsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDD92B29333300F7FF87 /* FirebaseAnalyticsSwift */; }; 41F5CDDC2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */; }; 41F5CDDE2B29333300F7FF87 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDDD2B29333300F7FF87 /* FirebaseAppCheck */; }; 41F5CDE12B29348800F7FF87 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDE02B29348800F7FF87 /* FirebaseMessaging */; }; @@ -73,7 +72,6 @@ buildActionMask = 2147483647; files = ( 41F5CDE12B29348800F7FF87 /* FirebaseMessaging in Frameworks */, - 41F5CDDA2B29333300F7FF87 /* FirebaseAnalyticsSwift in Frameworks */, 41F5CDD62B29333300F7FF87 /* FirebaseAnalytics in Frameworks */, 41F5CDD82B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */, 41F5CDDE2B29333300F7FF87 /* FirebaseAppCheck in Frameworks */, @@ -174,7 +172,6 @@ packageProductDependencies = ( 41F5CDD52B29333300F7FF87 /* FirebaseAnalytics */, 41F5CDD72B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion */, - 41F5CDD92B29333300F7FF87 /* FirebaseAnalyticsSwift */, 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */, 41F5CDDD2B29333300F7FF87 /* FirebaseAppCheck */, 41F5CDE02B29348800F7FF87 /* FirebaseMessaging */, @@ -429,7 +426,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_ENTITLEMENTS[sdk=*]" = aesthetic.computer/aesthetic.computer.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\""; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_PREVIEWS = YES; @@ -447,7 +444,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -464,7 +461,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "aesthetic.computer/aesthetic.computer-release.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\""; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_PREVIEWS = YES; @@ -482,7 +479,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -496,7 +493,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FB5948YR3S; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = aesthetic/Info.plist; @@ -508,7 +505,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer.aesthetic; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -523,7 +520,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FB5948YR3S; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = aesthetic/Info.plist; @@ -535,7 +532,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = aesthetic.computer.aesthetic; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -583,7 +580,7 @@ repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 10.19.0; + minimumVersion = 11.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -599,11 +596,6 @@ package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseAnalyticsOnDeviceConversion; }; - 41F5CDD92B29333300F7FF87 /* FirebaseAnalyticsSwift */ = { - isa = XCSwiftPackageProductDependency; - package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalyticsSwift; - }; 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */ = { isa = XCSwiftPackageProductDependency; package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/apple/aesthetic.computer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/aesthetic.computer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 17ce01b5bf..b5623d9e60 100644 --- a/apple/aesthetic.computer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/aesthetic.computer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,122 +1,132 @@ -{ - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c", - "version" : "1.2022062300.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "5746b2d35c91c50581590ed97abe4c06b5037274", - "version" : "10.18.0" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "d9bcd141c3e4ad48a9500e6faeebb073f43cbcbd", - "version" : "10.19.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "6b332152355c372ace9966d8ee76ed191f97025e", - "version" : "10.17.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "a732a4b47f59e4f725a2ea10f0c77e93a7131117", - "version" : "9.3.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "bc27fad73504f3d4af235de451f02ee22586ebd3", - "version" : "7.12.1" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "a673bc2937fbe886dd1f99c401b01b6d977a9c98", - "version" : "1.49.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "115f75e43851774934d695449a4836123c3246e1", - "version" : "3.2.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", - "version" : "100.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "9d108e9112aa1d65ce508facf804674546116d9c", - "version" : "1.22.3" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692", - "version" : "2.30909.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e", - "version" : "2.3.1" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "65e8f29b2d63c4e38e736b25c27b83e012159be8", - "version" : "1.25.2" - } - } - ], - "version" : 2 -} +{ + "originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5", + "version" : "11.15.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", + "version" : "2.3.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "45ce435e9406d3c674dd249a042b932bee006f60", + "version" : "11.15.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c756a29784521063b6a1202907e2cc47f41b667c", + "version" : "4.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "9d108e9112aa1d65ce508facf804674546116d9c", + "version" : "1.22.3" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "65e8f29b2d63c4e38e736b25c27b83e012159be8", + "version" : "1.25.2" + } + } + ], + "version" : 3 +} diff --git a/apple/aesthetic.computer/aesthetic_computerApp.swift b/apple/aesthetic.computer/aesthetic_computerApp.swift index 55a8f3a47a..71b245f072 100644 --- a/apple/aesthetic.computer/aesthetic_computerApp.swift +++ b/apple/aesthetic.computer/aesthetic_computerApp.swift @@ -182,7 +182,41 @@ extension AppDelegate: MessagingDelegate { object: nil, userInfo: dataDict ) - // TODO: If necessary, send token to your application server. - // Note: This callback is fired at each app startup and whenever a new token is generated. + // Hand the token to the WebView so the AC runtime can POST it to + // /api/register-push-token against the logged-in user. + // This callback is fired at each app startup and whenever a new token is generated. + guard let token = fcmToken, !token.isEmpty else { return } + deliverPushTokenToWebView(token) + } + + private func deliverPushTokenToWebView(_ token: String, attempt: Int = 0) { + // Retry a few times so we don't lose the token if the WebView hasn't + // finished loading AC's bios.mjs yet (iOSReceivePushToken lives there). + let escaped = token.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + let script = "window.iOSReceivePushToken && window.iOSReceivePushToken('\(escaped)', 'ios');" + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard let webView = self.appWebView else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.deliverPushTokenToWebView(token, attempt: attempt + 1) + } + } + return + } + webView.evaluateJavaScript(script) { _, error in + if let error = error { + print("๐Ÿ“ฑ ๐Ÿ”” Failed to hand token to WebView: \(error)") + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.deliverPushTokenToWebView(token, attempt: attempt + 1) + } + } + } else { + print("๐Ÿ“ฑ ๐Ÿ”” Handed FCM token to WebView (attempt \(attempt)).") + } + } + } } } diff --git a/system/netlify/functions/delete-erase-and-forget-me.mjs b/system/netlify/functions/delete-erase-and-forget-me.mjs index 23aeaa02e3..fb196095bb 100644 --- a/system/netlify/functions/delete-erase-and-forget-me.mjs +++ b/system/netlify/functions/delete-erase-and-forget-me.mjs @@ -104,6 +104,14 @@ export async function handler(event, context) { await database.db.collection("moods").deleteMany({ user: sub }); console.log("๐Ÿง  Deleted moods."); + await database.db.collection("push-tokens").deleteMany({ user: sub }); + console.log("๐Ÿ”” Deleted push tokens."); + + await database.db + .collection("tells") + .deleteMany({ $or: [{ to: sub }, { from: sub }] }); + console.log("๐Ÿ“ฃ Deleted tells (sent + received)."); + await database.db.collection("verifications").deleteOne({ _id: sub }); console.log("๐Ÿง Deleted verification count."); diff --git a/system/netlify/functions/register-push-token.mjs b/system/netlify/functions/register-push-token.mjs new file mode 100644 index 0000000000..4768d5a624 --- /dev/null +++ b/system/netlify/functions/register-push-token.mjs @@ -0,0 +1,66 @@ +// register-push-token, 26.04.23 +// Stores a device's push-notification token against the logged-in user so the +// backend can deliver targeted notifications (fast, per-device) instead of +// topic broadcasts. Used by the iOS app (FCM) and eventually web/android. + +// POST /api/register-push-token +// body: { token: string, platform: "ios" | "android" | "web", remove?: true } +// headers: Authorization: Bearer +// behavior: +// - remove=true โ†’ delete this (user, token) pair (logout) +// - otherwise โ†’ upsert { user: sub, token, platform, updatedAt } +// and delete any other user's binding to the same token +// (account-switch on the same device). + +import { authorize } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; + +export async function handler(event) { + if (event.httpMethod !== "POST") { + return respond(405, { message: "Method Not Allowed" }); + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return respond(400, { message: "Invalid JSON body" }); + } + + const { token, platform, remove } = body; + if (typeof token !== "string" || token.length < 16 || token.length > 4096) { + return respond(400, { message: "Invalid token" }); + } + if (!remove && !["ios", "android", "web"].includes(platform)) { + return respond(400, { message: "Invalid platform" }); + } + + const user = await authorize(event.headers); + if (!user?.sub) return respond(401, { message: "Unauthorized" }); + + const database = await connect(); + try { + const collection = database.db.collection("push-tokens"); + await collection.createIndex({ user: 1, token: 1 }, { unique: true }); + await collection.createIndex({ token: 1 }); + + if (remove) { + const result = await collection.deleteOne({ user: user.sub, token }); + return respond(200, { status: "removed", deleted: result.deletedCount }); + } + + await collection.deleteMany({ token, user: { $ne: user.sub } }); + await collection.updateOne( + { user: user.sub, token }, + { $set: { user: user.sub, token, platform, updatedAt: new Date() } }, + { upsert: true }, + ); + return respond(200, { status: "registered" }); + } catch (err) { + console.error("๐Ÿ”ด register-push-token error:", err); + return respond(500, { message: err?.message || "Server error" }); + } finally { + await database.disconnect(); + } +} diff --git a/system/netlify/functions/tell.mjs b/system/netlify/functions/tell.mjs new file mode 100644 index 0000000000..f8cd7839a6 --- /dev/null +++ b/system/netlify/functions/tell.mjs @@ -0,0 +1,156 @@ +// tell, 26.04.23 +// Send a one-way "tell" from one AC user to another. The recipient gets a +// push notification on every registered device and the message is stored +// in the `tells` collection as their inbox. +// +// POST /api/tell +// body: { to: "@handle", text: "message" } +// headers: Authorization: Bearer + +import { + authorize, + userIDFromHandleOrEmail, + getHandleOrEmail, +} from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { filter } from "../../backend/filter.mjs"; +import { shell } from "../../backend/shell.mjs"; + +import { initializeApp, cert, getApps } from "firebase-admin/app"; +import { getMessaging } from "firebase-admin/messaging"; + +const MAX_TEXT_LENGTH = 500; + +export async function handler(event) { + if (event.httpMethod !== "POST") { + return respond(405, { message: "Method Not Allowed" }); + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return respond(400, { message: "Invalid JSON body" }); + } + + const rawTo = typeof body.to === "string" ? body.to.trim() : ""; + const rawText = typeof body.text === "string" ? body.text : ""; + if (!rawTo) return respond(400, { message: "Missing recipient" }); + const text = filter(rawText.trim()).slice(0, MAX_TEXT_LENGTH); + if (!text) return respond(400, { message: "Empty message" }); + + const sender = await authorize(event.headers); + if (!sender?.sub) return respond(401, { message: "Unauthorized" }); + + const database = await connect(); + try { + const recipientSub = await userIDFromHandleOrEmail(rawTo, database); + if (!recipientSub) { + return respond(404, { message: "Recipient not found" }); + } + + const fromHandle = await getHandleOrEmail(sender.sub); + const toHandle = rawTo.startsWith("@") ? rawTo : `@${rawTo}`; + + const tells = database.db.collection("tells"); + await tells.createIndex({ to: 1, when: -1 }); + await tells.createIndex({ from: 1, when: -1 }); + + const when = new Date(); + const insertResult = await tells.insertOne({ + to: recipientSub, + toHandle, + from: sender.sub, + fromHandle, + text, + when, + }); + + // Look up recipient's registered push tokens. + const pushTokens = await database.db + .collection("push-tokens") + .find({ user: recipientSub }) + .project({ _id: 0, token: 1 }) + .toArray(); + const tokens = pushTokens.map((d) => d.token).filter(Boolean); + + let pushSummary = { attempted: 0, succeeded: 0, failed: 0 }; + + if (tokens.length > 0) { + const { got } = await import("got"); + const serviceAccount = ( + await got(process.env.GCM_FIREBASE_CONFIG_URL, { responseType: "json" }) + ).body; + if (getApps().length === 0) { + initializeApp({ credential: cert(serviceAccount) }); + } + + const message = { + notification: { + title: `${fromHandle} told you`, + body: text, + }, + data: { + kind: "tell", + from: fromHandle || "", + tellId: insertResult.insertedId.toString(), + }, + apns: { + payload: { aps: { sound: "default", "mutable-content": 1 } }, + }, + }; + + try { + const response = await getMessaging().sendEachForMulticast({ + tokens, + ...message, + }); + pushSummary = { + attempted: tokens.length, + succeeded: response.successCount, + failed: response.failureCount, + }; + + const invalid = []; + response.responses.forEach((resp, i) => { + if (resp.success) return; + const code = resp.error?.code || ""; + if ( + code === "messaging/registration-token-not-registered" || + code === "messaging/invalid-registration-token" || + code === "messaging/invalid-argument" + ) { + invalid.push(tokens[i]); + } else { + shell.log( + `โš ๏ธ tell push error for ${recipientSub}:`, + code, + resp.error?.message, + ); + } + }); + if (invalid.length) { + await database.db + .collection("push-tokens") + .deleteMany({ token: { $in: invalid } }); + shell.log(`๐Ÿงน Pruned ${invalid.length} stale push tokens.`); + } + } catch (err) { + shell.log("๐Ÿ”ด tell push send failed:", err?.message || err); + } + } + + return respond(200, { + status: "told", + to: toHandle, + when, + push: pushSummary, + }); + } catch (err) { + console.error("๐Ÿ”ด tell error:", err); + return respond(500, { message: err?.message || "Server error" }); + } finally { + await database.disconnect(); + } +} diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index f8338cb27a..43d9812b1f 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -21270,6 +21270,75 @@ async function boot(parsed, bpm = 60, resolution, debug) { content: { piece, ahistorical: false, alias: false }, }); }; + + // ๐Ÿ”” Native push-token bridge. + // Swift calls window.iOSReceivePushToken when FCM hands it a token. + // If the user is logged in we POST it to /api/register-push-token right + // away; otherwise we stash it and register once session:started fires + // (see iOSTryRegisterPushToken, called from boot.mjs after login). + let _iosPushToken = null; + let _iosPushPlatform = "ios"; + let _iosPushRegistered = false; + let _iosPushInflight = false; + + window.iOSReceivePushToken = (token, platform) => { + if (typeof token !== "string" || !token) return; + if (_iosPushToken === token && _iosPushRegistered) return; + _iosPushToken = token; + _iosPushPlatform = platform || "ios"; + _iosPushRegistered = false; + window.iOSTryRegisterPushToken(); + }; + + window.iOSTryRegisterPushToken = async () => { + if (!_iosPushToken || _iosPushRegistered || _iosPushInflight) return; + if (!window.auth0Client || !window.acUSER) return; // wait for login + _iosPushInflight = true; + try { + const authToken = await window.auth0Client.getTokenSilently(); + if (!authToken) return; + const res = await fetch("/api/register-push-token", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + token: _iosPushToken, + platform: _iosPushPlatform, + }), + }); + if (res.ok) { + _iosPushRegistered = true; + console.log("๐Ÿ“ฑ ๐Ÿ”” Push token registered."); + } else { + console.warn("๐Ÿ“ฑ ๐Ÿ”” Push token registration failed:", res.status); + } + } catch (err) { + console.warn("๐Ÿ“ฑ ๐Ÿ”” Push token registration error:", err); + } finally { + _iosPushInflight = false; + } + }; + + window.iOSUnregisterPushToken = async () => { + if (!_iosPushToken || !window.auth0Client || !window.acUSER) return; + try { + const authToken = await window.auth0Client.getTokenSilently(); + if (!authToken) return; + await fetch("/api/register-push-token", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ token: _iosPushToken, remove: true }), + }); + _iosPushRegistered = false; + } catch (err) { + console.warn("๐Ÿ“ฑ ๐Ÿ”” Push token unregister error:", err); + } + }; } // End of boot function function iOSAppSend(message) { diff --git a/system/public/aesthetic.computer/boot.mjs b/system/public/aesthetic.computer/boot.mjs index f1335812c0..3f366aab9b 100644 --- a/system/public/aesthetic.computer/boot.mjs +++ b/system/public/aesthetic.computer/boot.mjs @@ -1505,13 +1505,16 @@ if (!sandboxed && !skipAuth) { const legitParams = extractLegitimateParams(window.location.href); if (legitParams.has("signup")) window.acLOGIN("signup"); - window.acLOGOUT = () => { + window.acLOGOUT = async () => { if (isAuthenticated) { console.log("๐Ÿ” Logging out..."); window.acSEND({ type: "logout:broadcast:subscribe", content: { user: window.acUSER }, }); + try { + await window.iOSUnregisterPushToken?.(); + } catch {} auth0Client.logout({ logoutParams: { returnTo: window.location.origin }, }); @@ -1560,6 +1563,9 @@ if (!sandboxed && !skipAuth) { content: { user: window.acUSER }, }); + // ๐Ÿ”” If the native iOS app handed us a push token before login, register it now. + window.iOSTryRegisterPushToken?.(); + // Background: fetch handle from /user and refresh token if needed. // This runs after the disk already has auth โ€” it just enriches data. (async () => { diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index 759e34bb85..ce9645f136 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -2350,6 +2350,41 @@ async function halt($, text) { } makeFlash($); return true; + } else if (slug === "tell") { + // tell @handle your one-way message + const to = params[0] || ""; + const body = params.slice(1).join(" ").trim(); + if (!to.startsWith("@")) { + flashColor = [255, 255, 0]; + notice("TELL WHO?", ["yellow", "red"]); + makeFlash($, true); + return true; + } + if (!body) { + flashColor = [255, 255, 0]; + notice("TELL WHAT?", ["yellow", "red"]); + makeFlash($, true); + return true; + } + const res = await net.userRequest("POST", "/api/tell", { to, text: body }); + if (res?.status === 200) { + flashColor = [0, 255, 0]; + notice(`TOLD ${to.toUpperCase()}`); + } else if (res?.status === 404) { + flashColor = [255, 0, 0]; + notice("NO HANDLE", ["yellow", "red"]); + makeFlash($, true); + } else if (res?.status === 401 || res?.message === "unauthorized") { + flashColor = [255, 0, 0]; + notice("LOG IN TO TELL", ["yellow", "red"]); + makeFlash($, true); + } else { + flashColor = [255, 0, 0]; + notice("TELL FAILED", ["yellow", "red"]); + makeFlash($, true); + } + makeFlash($); + return true; } else if (text.startsWith("publish")) { const publishablePiece = store["publishable-piece"]; if (!publishablePiece) { -- 2.51.2