diff --git a/.github/workflows/nudge-release.yml b/.github/workflows/nudge-release.yml index 8b08a0e..9cd3bc3 100644 --- a/.github/workflows/nudge-release.yml +++ b/.github/workflows/nudge-release.yml @@ -51,6 +51,7 @@ jobs: - name: Archive (ad-hoc signed) env: VERSION: ${{ steps.version.outputs.version }} + KLIPY_API_KEY: ${{ secrets.KLIPY_API_KEY }} run: | set -euo pipefail xcodebuild archive \ @@ -59,6 +60,7 @@ jobs: -configuration Release \ -destination 'generic/platform=macOS' \ -archivePath build/Nudge.xcarchive \ + KLIPY_API_KEY="${KLIPY_API_KEY}" \ CODE_SIGN_IDENTITY="-" \ CODE_SIGN_STYLE=Manual \ CODE_SIGNING_REQUIRED=YES \ diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml deleted file mode 100644 index d70b6c4..0000000 --- a/.github/workflows/static.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: - - main - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup Pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - name: Upload artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - # Upload entire repository - path: 'updater/' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/Nudge.xcodeproj/project.pbxproj b/Nudge.xcodeproj/project.pbxproj index 5c5dca0..8746133 100644 --- a/Nudge.xcodeproj/project.pbxproj +++ b/Nudge.xcodeproj/project.pbxproj @@ -34,6 +34,10 @@ 14D570C92C5F38890011E668 /* NudgeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570C82C5F38890011E668 /* NudgeViewModel.swift */; }; 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */; }; 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */; }; + 1FB98063B994504FE32D0A59 /* NudgePasswordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8405B521985C8120633C3EE /* NudgePasswordPickerView.swift */; }; + 4056511EC8FE68B0B8A7AE0F /* NudgeRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A93A85D5B4F91DCE1C994D /* NudgeRoster.swift */; }; + 5D801A1AF37495A88EB59F2E /* NudgeTeamSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41DF965EE177D3892EF7B81 /* NudgeTeamSecret.swift */; }; + 9F18C9B556E15061567A3991 /* NudgeStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407FCC9217CDCECC553F619E /* NudgeStats.swift */; }; AAA000000000000000000020 /* NudgeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA000000000000000000010 /* NudgeConstants.swift */; }; AAA000000000000000000021 /* NudgeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA000000000000000000011 /* NudgeIdentity.swift */; }; AAA000000000000000000022 /* NudgeTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA000000000000000000012 /* NudgeTransport.swift */; }; @@ -50,6 +54,10 @@ B1C448962C9712C4001F0858 /* ActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C448952C9712C4001F0858 /* ActionBar.swift */; }; B1CE8CFE2C6F659400DD9871 /* KeyboardShortcutsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1CE8CFD2C6F659400DD9871 /* KeyboardShortcutsHelper.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; + B8AC936D553061A2C59E57CE /* NudgeGifService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A70506F0C82739025A3466C /* NudgeGifService.swift */; }; + F25174743AD7C7244255A32F /* GifPickerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA2E3C198DF89E1F33A29653 /* GifPickerWindowController.swift */; }; + F7291FB50E023ED8310236B8 /* NudgeGifPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B4A5914D74B087D00FF461 /* NudgeGifPickerView.swift */; }; + FBE51232E3221DE7A6FC71DF /* AnimatedGifView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89B24867B6257865F7198E6 /* AnimatedGifView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -97,6 +105,10 @@ 14D570C82C5F38890011E668 /* NudgeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeViewModel.swift; sourceTree = ""; }; 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Bouncing.swift"; sourceTree = ""; }; 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataTypes+Extensions.swift"; sourceTree = ""; }; + 1A70506F0C82739025A3466C /* NudgeGifService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeGifService.swift; sourceTree = ""; }; + 407FCC9217CDCECC553F619E /* NudgeStats.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeStats.swift; sourceTree = ""; }; + 57B4A5914D74B087D00FF461 /* NudgeGifPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeGifPickerView.swift; sourceTree = ""; }; + 74A93A85D5B4F91DCE1C994D /* NudgeRoster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeRoster.swift; sourceTree = ""; }; AAA000000000000000000010 /* NudgeConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeConstants.swift; sourceTree = ""; }; AAA000000000000000000011 /* NudgeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeIdentity.swift; sourceTree = ""; }; AAA000000000000000000012 /* NudgeTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeTransport.swift; sourceTree = ""; }; @@ -111,6 +123,10 @@ B1C448952C9712C4001F0858 /* ActionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBar.swift; sourceTree = ""; }; B1CE8CFD2C6F659400DD9871 /* KeyboardShortcutsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutsHelper.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; + C89B24867B6257865F7198E6 /* AnimatedGifView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AnimatedGifView.swift; sourceTree = ""; }; + E8405B521985C8120633C3EE /* NudgePasswordPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgePasswordPickerView.swift; sourceTree = ""; }; + F41DF965EE177D3892EF7B81 /* NudgeTeamSecret.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeTeamSecret.swift; sourceTree = ""; }; + FA2E3C198DF89E1F33A29653 /* GifPickerWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GifPickerWindowController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -235,6 +251,7 @@ B186542F2C6F455E000B926A /* Notch */, 9A0887332C7AFF7E00C160EA /* Tabs */, B186542E2C6F453B000B926A /* Music */, + 6CC064DE76441B5399B791BD /* GifPicker */, ); path = components; sourceTree = ""; @@ -350,6 +367,16 @@ path = models; sourceTree = ""; }; + 6CC064DE76441B5399B791BD /* GifPicker */ = { + isa = PBXGroup; + children = ( + 57B4A5914D74B087D00FF461 /* NudgeGifPickerView.swift */, + FA2E3C198DF89E1F33A29653 /* GifPickerWindowController.swift */, + ); + name = GifPicker; + path = GifPicker; + sourceTree = ""; + }; 9A0887332C7AFF7E00C160EA /* Tabs */ = { isa = PBXGroup; children = ( @@ -377,6 +404,12 @@ AAA000000000000000000013 /* NudgeNotifier.swift */, AAA000000000000000000014 /* NudgeNotchView.swift */, AAA000000000000000000015 /* NudgeIdentityPickerView.swift */, + F41DF965EE177D3892EF7B81 /* NudgeTeamSecret.swift */, + 74A93A85D5B4F91DCE1C994D /* NudgeRoster.swift */, + 407FCC9217CDCECC553F619E /* NudgeStats.swift */, + E8405B521985C8120633C3EE /* NudgePasswordPickerView.swift */, + 1A70506F0C82739025A3466C /* NudgeGifService.swift */, + C89B24867B6257865F7198E6 /* AnimatedGifView.swift */, ); path = Nudge; sourceTree = ""; @@ -583,6 +616,14 @@ 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */, 14288E0C2C6F8EC000B9F80C /* AppIcons.swift in Sources */, B19016242CC15B5000E3F12E /* Constants.swift in Sources */, + 5D801A1AF37495A88EB59F2E /* NudgeTeamSecret.swift in Sources */, + 4056511EC8FE68B0B8A7AE0F /* NudgeRoster.swift in Sources */, + 9F18C9B556E15061567A3991 /* NudgeStats.swift in Sources */, + 1FB98063B994504FE32D0A59 /* NudgePasswordPickerView.swift in Sources */, + B8AC936D553061A2C59E57CE /* NudgeGifService.swift in Sources */, + FBE51232E3221DE7A6FC71DF /* AnimatedGifView.swift in Sources */, + F7291FB50E023ED8310236B8 /* NudgeGifPickerView.swift in Sources */, + F25174743AD7C7244255A32F /* GifPickerWindowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -733,9 +774,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - ); + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Nudge/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Nudge; @@ -785,9 +824,7 @@ DEVELOPMENT_TEAM = Q37KAF726J; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - ); + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Nudge/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Nudge; diff --git a/Nudge/ContentView.swift b/Nudge/ContentView.swift index 86d4bd5..8cc154b 100644 --- a/Nudge/ContentView.swift +++ b/Nudge/ContentView.swift @@ -201,7 +201,8 @@ struct ContentView: View { private func handleIncoming(_ ping: NudgePing?) { guard let ping else { return } autoCloseTask?.cancel() - NudgeNotifier.postBackup(sender: ping.sender, message: ping.message) + let backupText = ping.message ?? (ping.gif != nil ? "sent a GIF 🎬" : nil) + NudgeNotifier.postBackup(sender: ping.sender, message: backupText) if Defaults[.nudgePlaySoundOnReceive] { NSSound(named: NSSound.Name("Pop"))?.play() } diff --git a/Nudge/Info.plist b/Nudge/Info.plist index 23dfa2a..f12ef88 100644 --- a/Nudge/Info.plist +++ b/Nudge/Info.plist @@ -11,5 +11,7 @@ + KlipyAPIKey + $(KLIPY_API_KEY) diff --git a/Nudge/Nudge/AnimatedGifView.swift b/Nudge/Nudge/AnimatedGifView.swift new file mode 100644 index 0000000..c821cee --- /dev/null +++ b/Nudge/Nudge/AnimatedGifView.swift @@ -0,0 +1,72 @@ +// +// AnimatedGifView.swift +// Nudge +// +// Renders an animated GIF from a remote URL. AppKit's NSImageView plays +// multi-frame GIFs natively, so no third-party dependency is needed. The +// fetched data is cached in-process so re-renders don't re-download. +// + +import SwiftUI +import AppKit + +/// Lightweight in-process cache of downloaded GIF data, keyed by URL. +@MainActor +private final class GifDataCache { + static let shared = GifDataCache() + private var store: [URL: Data] = [:] + private let maxEntries = 40 + + func data(for url: URL) -> Data? { store[url] } + + func insert(_ data: Data, for url: URL) { + if store.count >= maxEntries, let first = store.keys.first { + store.removeValue(forKey: first) + } + store[url] = data + } +} + +struct AnimatedGifView: NSViewRepresentable { + let url: URL + + func makeNSView(context: Context) -> NSImageView { + let view = NSImageView() + view.imageScaling = .scaleProportionallyDown + view.animates = true + view.canDrawSubviewsIntoLayer = true + view.setContentHuggingPriority(.defaultLow, for: .horizontal) + view.setContentHuggingPriority(.defaultLow, for: .vertical) + context.coordinator.loadedURL = url + load(into: view) + return view + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + // Only reload if the URL changed (avoid restarting playback each pass). + if context.coordinator.loadedURL != url { + context.coordinator.loadedURL = url + load(into: nsView) + } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + var loadedURL: URL? + } + + private func load(into view: NSImageView) { + if let cached = GifDataCache.shared.data(for: url) { + view.image = NSImage(data: cached) + return + } + let url = self.url + Task { @MainActor in + guard let (data, _) = try? await URLSession.shared.data(from: url), + let image = NSImage(data: data) else { return } + GifDataCache.shared.insert(data, for: url) + view.image = image + } + } +} diff --git a/Nudge/Nudge/NudgeConstants.swift b/Nudge/Nudge/NudgeConstants.swift index 0c220b9..69ffdf7 100644 --- a/Nudge/Nudge/NudgeConstants.swift +++ b/Nudge/Nudge/NudgeConstants.swift @@ -5,12 +5,8 @@ import Foundation -// Shared 12-char lowercase nonce. Every Ontora teammate's build MUST have the -// same value here. Rotate by changing the constant and shipping a new build. -let nudgeNonce = "k4n9pq7vx2tm" +// Names are no longer hardcoded — users type their own during onboarding, +// and the roster auto-discovers teammates via "hello" broadcasts on the +// shared team topic. -let nudgeUsers: [String] = ["Max", "Leon", "David"] - -func ntfyTopic(for user: String) -> String { - "nudge-ontora-\(user.lowercased())-\(nudgeNonce)" -} +let nudgeMaxNameLength = 32 diff --git a/Nudge/Nudge/NudgeGifService.swift b/Nudge/Nudge/NudgeGifService.swift new file mode 100644 index 0000000..2be1db1 --- /dev/null +++ b/Nudge/Nudge/NudgeGifService.swift @@ -0,0 +1,136 @@ +// +// NudgeGifService.swift +// Nudge +// +// GIF search via KLIPY (https://klipy.com/developers). Tenor was shut down +// by Google on 2026-06-30; KLIPY is the drop-in successor. The service is +// provider-abstracted so swapping again later is a small change. +// +// The team-wide API key is NOT in source (public repo). CI injects the +// `KLIPY_API_KEY` GitHub Actions secret into the app's Info.plist at build +// time (`KlipyAPIKey` = `$(KLIPY_API_KEY)`); a per-user key in Settings +// overrides it. Local dev builds leave it empty and fall back to Settings. +// + +import Foundation +import Defaults +import os + +/// A single GIF search result. +struct NudgeGif: Identifiable, Equatable { + let id: String + /// Small looping preview used in the picker grid. + let previewURL: URL + /// URL actually sent to teammates (kept small — it renders in the notch). + let sendURL: URL + let description: String +} + +enum NudgeGifError: LocalizedError { + case noAPIKey + case badResponse + + var errorDescription: String? { + switch self { + case .noAPIKey: return "Add a Klipy API key in Settings → GIFs." + case .badResponse: return "Couldn't reach the GIF service." + } + } +} + +@MainActor +enum NudgeGifService { + private static let log = Logger(subsystem: "com.ontora.nudge", category: "gif") + + /// Team-wide default key from Info.plist (CI-injected). Empty in local dev. + private static var bundledKey: String { + (Bundle.main.object(forInfoDictionaryKey: "KlipyAPIKey") as? String)? + .trimmingCharacters(in: .whitespaces) ?? "" + } + + /// The user's own key if set, otherwise the shared bundled key. + private static var effectiveKey: String { + let user = Defaults[.klipyApiKey].trimmingCharacters(in: .whitespaces) + return user.isEmpty ? bundledKey : user + } + + static var isConfigured: Bool { !effectiveKey.isEmpty } + + /// Search GIFs for `query`. Empty query returns trending. + static func search(_ query: String, limit: Int = 24) async throws -> [NudgeGif] { + let key = effectiveKey + guard !key.isEmpty else { throw NudgeGifError.noAPIKey } + return try await KlipyProvider.search(query: query, key: key, limit: limit) + } +} + +// MARK: - KLIPY provider + +private enum KlipyProvider { + static func search(query: String, key: String, limit: Int) async throws -> [NudgeGif] { + let q = query.trimmingCharacters(in: .whitespaces) + let trending = q.isEmpty + var comps = URLComponents( + string: "https://api.klipy.com/api/v1/\(key)/gifs/" + (trending ? "trending" : "search") + )! + var items = [ + URLQueryItem(name: "per_page", value: String(min(max(limit, 8), 50))), + URLQueryItem(name: "page", value: "1"), + URLQueryItem(name: "customer_id", value: "nudge"), + ] + if !trending { items.insert(URLQueryItem(name: "q", value: q), at: 0) } + comps.queryItems = items + + do { + let (data, resp) = try await URLSession.shared.data(from: comps.url!) + guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NudgeGifError.badResponse + } + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + // { result, data: { data: [items], ... } } — tolerate a flat array too. + let container = root["data"] + let list = (container as? [[String: Any]]) + ?? ((container as? [String: Any])?["data"] as? [[String: Any]]) + ?? [] + return list.compactMap(parse) + } catch { + // Trending is best-effort (endpoint/params may vary); don't surface + // an error for the picker's initial load. + if trending { return [] } + throw error is NudgeGifError ? error : NudgeGifError.badResponse + } + } + + /// `file...url`, sizes xs/sm/md/hd, formats gif/webp/jpg/mp4/webm. + private static func parse(_ item: [String: Any]) -> NudgeGif? { + let file = item["file"] as? [String: Any] ?? [:] + // sm is a good notch/grid size; fall back up/down and to a recursive scan. + let preview = url(file, size: "sm", "gif") ?? url(file, size: "xs", "gif") + ?? url(file, size: "md", "gif") ?? anyGifURL(file) + let send = url(file, size: "sm", "gif") ?? url(file, size: "md", "gif") + ?? url(file, size: "xs", "gif") ?? url(file, size: "hd", "gif") ?? anyGifURL(file) + guard let previewStr = preview, let sendStr = send, + let p = URL(string: previewStr), let s = URL(string: sendStr) else { + return nil + } + let id = item["id"].map { "\($0)" } ?? sendStr + let desc = (item["title"] as? String) ?? (item["slug"] as? String) ?? "GIF" + return NudgeGif(id: id, previewURL: p, sendURL: s, description: desc) + } + + private static func url(_ file: [String: Any], size: String, _ format: String) -> String? { + ((file[size] as? [String: Any])?[format] as? [String: Any])?["url"] as? String + } + + /// Last resort: first `.gif` URL found anywhere in the file tree. + private static func anyGifURL(_ obj: Any) -> String? { + if let s = obj as? String, s.hasPrefix("http"), s.contains(".gif") { return s } + if let dict = obj as? [String: Any] { + for v in dict.values { if let f = anyGifURL(v) { return f } } + } + if let arr = obj as? [Any] { + for v in arr { if let f = anyGifURL(v) { return f } } + } + return nil + } +} diff --git a/Nudge/Nudge/NudgeIdentity.swift b/Nudge/Nudge/NudgeIdentity.swift index 032ba97..51164a5 100644 --- a/Nudge/Nudge/NudgeIdentity.swift +++ b/Nudge/Nudge/NudgeIdentity.swift @@ -15,27 +15,37 @@ final class NudgeIdentity: ObservableObject { private let storageKey = "nudgeCurrentUser" private init() { - let stored = UserDefaults.standard.string(forKey: storageKey) - if let stored, nudgeUsers.contains(stored) { + if let stored = UserDefaults.standard.string(forKey: storageKey), + NudgeIdentity.isValid(stored) { self.current = stored } else { self.current = nil } } + static func isValid(_ raw: String) -> Bool { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + guard trimmed.count <= nudgeMaxNameLength else { return false } + // ':' is our key separator in stats/roster storage. Disallow it + // to keep persistence keys unambiguous. + guard !trimmed.contains(":") else { return false } + return true + } + + static func sanitize(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + func setCurrent(_ user: String) { - guard nudgeUsers.contains(user) else { return } - UserDefaults.standard.set(user, forKey: storageKey) - self.current = user + let cleaned = NudgeIdentity.sanitize(user) + guard NudgeIdentity.isValid(cleaned) else { return } + UserDefaults.standard.set(cleaned, forKey: storageKey) + self.current = cleaned } func clear() { UserDefaults.standard.removeObject(forKey: storageKey) self.current = nil } - - var others: [String] { - guard let me = current else { return nudgeUsers } - return nudgeUsers.filter { $0 != me } - } } diff --git a/Nudge/Nudge/NudgeIdentityPickerView.swift b/Nudge/Nudge/NudgeIdentityPickerView.swift index 013d08a..df891fa 100644 --- a/Nudge/Nudge/NudgeIdentityPickerView.swift +++ b/Nudge/Nudge/NudgeIdentityPickerView.swift @@ -8,6 +8,13 @@ import SwiftUI struct NudgeIdentityPickerView: View { let onFinish: () -> Void + @State private var name: String = NudgeIdentity.shared.current ?? "" + @State private var error: String? + + private var cleaned: String { + NudgeIdentity.sanitize(name) + } + var body: some View { VStack(spacing: 24) { VStack(spacing: 8) { @@ -16,55 +23,57 @@ struct NudgeIdentityPickerView: View { .foregroundStyle(.tint) Text("Welcome to Nudge") .font(.title2.weight(.semibold)) - Text("Who are you?") + Text("What's your name?") .font(.body) .foregroundStyle(.secondary) } .padding(.top, 40) - VStack(spacing: 12) { - ForEach(nudgeUsers, id: \.self) { user in - Button { - pick(user) - } label: { - HStack { - Text(user) - .font(.system(size: 18, weight: .medium)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.primary.opacity(0.08)) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + VStack(spacing: 10) { + TextField("Your name", text: $name) + .textFieldStyle(.roundedBorder) + .font(.system(.body)) + .onSubmit(confirm) + if let err = error { + Text(err) + .font(.caption) + .foregroundStyle(.red) } } .padding(.horizontal, 40) + Button(action: confirm) { + Text("Continue") + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 40) + .disabled(!NudgeIdentity.isValid(cleaned)) + Spacer() - Text("You can change this later in Settings.") + Text("Your teammates will see this name on their notch when you nudge them. You can change it later in Settings.") .font(.caption) .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) .padding(.bottom, 24) + .fixedSize(horizontal: false, vertical: true) } .frame(width: 400, height: 600) } - private func pick(_ user: String) { - NudgeIdentity.shared.setCurrent(user) + private func confirm() { + guard NudgeIdentity.isValid(cleaned) else { + error = "Please enter a name (1–\(nudgeMaxNameLength) characters, no colons)." + return + } + NudgeIdentity.shared.setCurrent(cleaned) Task { await NudgeNotifier.requestAuthorization() } - NudgeViewCoordinator.shared.firstLaunch = false onFinish() } } diff --git a/Nudge/Nudge/NudgeNotchView.swift b/Nudge/Nudge/NudgeNotchView.swift index d0177c8..78f3501 100644 --- a/Nudge/Nudge/NudgeNotchView.swift +++ b/Nudge/Nudge/NudgeNotchView.swift @@ -9,6 +9,8 @@ import AppKit struct NudgeNotchView: View { @ObservedObject var transport: NudgeTransport = .shared @ObservedObject var identity: NudgeIdentity = .shared + @ObservedObject var teamSecret: NudgeTeamSecret = .shared + @ObservedObject var roster: NudgeRoster = .shared @EnvironmentObject var vm: NudgeViewModel @State private var sending: String? @@ -16,6 +18,10 @@ struct NudgeNotchView: View { @State private var messageText: String = "" @FocusState private var messageFocused: Bool + private var others: [String] { + roster.others(excluding: identity.current) + } + var body: some View { Group { if let ping = transport.lastIncoming, isRecent(ping) { @@ -36,15 +42,22 @@ struct NudgeNotchView: View { Text("Pick a name in Settings") .font(.callout) .foregroundStyle(.secondary) - } else if identity.others.isEmpty { - Text("No teammates configured") + } else if !teamSecret.hasPassword { + Text("Set team password in Settings") + .font(.callout) + .foregroundStyle(.secondary) + } else if others.isEmpty { + Text("Waiting for teammates…") .font(.callout) .foregroundStyle(.secondary) } else { VStack(spacing: 8) { - messageField + HStack(spacing: 8) { + messageField + gifButton + } HStack(spacing: 10) { - ForEach(identity.others, id: \.self) { other in + ForEach(others, id: \.self) { other in pingButton(other) } if let err = sendError { @@ -73,6 +86,24 @@ struct NudgeNotchView: View { ) } + private var gifButton: some View { + Button { + GifPickerWindowController.shared.showWindow() + } label: { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.white.opacity(0.12)) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .help("Send a GIF") + } + private func pingButton(_ recipient: String) -> some View { let isSending = sending == recipient return Button { @@ -96,33 +127,49 @@ struct NudgeNotchView: View { } private func incomingView(for ping: NudgePing) -> some View { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(Color.accentColor.opacity(0.9)) - .frame(width: 36, height: 36) - Text(String(ping.sender.prefix(1))) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(.white) - } - VStack(alignment: .leading, spacing: 2) { - Text("\(ping.sender) wants you") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - if let message = ping.message, !message.isEmpty { - Text(message) - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.85)) - .lineLimit(2) + VStack(spacing: 6) { + HStack(spacing: 10) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.9)) + .frame(width: 26, height: 26) + Text(String(ping.sender.prefix(1))) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white) + } + VStack(alignment: .center, spacing: 2) { + Text("\(ping.sender) is waving") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + if let message = ping.message, !message.isEmpty { + Text(message) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(2) + } + Text(ping.receivedAt.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(.secondary) } - Text(ping.receivedAt.formatted(date: .omitted, time: .shortened)) - .font(.caption2) - .foregroundStyle(.secondary) } - Spacer(minLength: 0) + if let gif = ping.gif, let url = URL(string: gif) { + AnimatedGifView(url: url) + .frame(maxWidth: 200, maxHeight: 90) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + dismissIncoming() } } + private func dismissIncoming() { + transport.lastIncoming = nil + vm.close() + } + private func tap(_ recipient: String) { guard let me = identity.current else { return } sending = recipient diff --git a/Nudge/Nudge/NudgePasswordPickerView.swift b/Nudge/Nudge/NudgePasswordPickerView.swift new file mode 100644 index 0000000..3a5f040 --- /dev/null +++ b/Nudge/Nudge/NudgePasswordPickerView.swift @@ -0,0 +1,97 @@ +// +// NudgePasswordPickerView.swift +// Nudge +// + +import SwiftUI + +struct NudgePasswordPickerView: View { + let onFinish: () -> Void + + @State private var password: String = "" + @State private var showPassword: Bool = false + @State private var error: String? + + private var trimmed: String { + password.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + VStack(spacing: 20) { + VStack(spacing: 8) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 44)) + .foregroundStyle(.tint) + Text("Team password") + .font(.title2.weight(.semibold)) + Text("Everyone on your team types the same password.\nIt encrypts your pings and decides who can reach you.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 40) + + VStack(spacing: 10) { + HStack { + Group { + if showPassword { + TextField("Team password", text: $password) + } else { + SecureField("Team password", text: $password) + } + } + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .onSubmit(confirm) + + Button { + showPassword.toggle() + } label: { + Image(systemName: showPassword ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + .help(showPassword ? "Hide password" : "Show password") + } + if let err = error { + Text(err) + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.horizontal, 40) + + Button(action: confirm) { + Text("Save") + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 40) + .disabled(trimmed.count < 6) + + Spacer() + + Text("Stored only in your Mac's Keychain — never sent over the network. Change it any time in Settings.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.bottom, 24) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: 400, height: 600) + } + + private func confirm() { + if trimmed.count < 6 { + error = "Please use at least 6 characters." + return + } + NudgeTeamSecret.shared.setPassword(trimmed) + NudgeViewCoordinator.shared.firstLaunch = false + onFinish() + } +} diff --git a/Nudge/Nudge/NudgeRoster.swift b/Nudge/Nudge/NudgeRoster.swift new file mode 100644 index 0000000..3ee0380 --- /dev/null +++ b/Nudge/Nudge/NudgeRoster.swift @@ -0,0 +1,66 @@ +// +// NudgeRoster.swift +// Nudge +// +// Tracks which teammates have been seen on the shared team topic. +// Populated by "hello" broadcasts from NudgeTransport on app launch and +// on a periodic heartbeat; persisted in UserDefaults so a quick app +// restart doesn't blank the ping buttons. +// + +import Foundation +import Combine + +@MainActor +final class NudgeRoster: ObservableObject { + static let shared = NudgeRoster() + + /// name -> ISO8601 last-seen timestamp string. + @Published private(set) var teammates: [String: Date] = [:] + + private let storageKey = "nudgeRoster" + + private init() { + if let raw = UserDefaults.standard.dictionary(forKey: storageKey) as? [String: TimeInterval] { + for (name, ts) in raw { + teammates[name] = Date(timeIntervalSince1970: ts) + } + } + } + + /// Record that we heard from `name` just now. + func record(name rawName: String) { + let name = NudgeIdentity.sanitize(rawName) + guard NudgeIdentity.isValid(name) else { return } + teammates[name] = Date() + persist() + } + + /// Forget everyone — used when the team password changes. + func clear() { + teammates.removeAll() + persist() + } + + /// All known teammates except `excluded` (the current identity), sorted + /// alphabetically. + func others(excluding excluded: String?) -> [String] { + teammates.keys + .filter { $0 != excluded } + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + /// Names known including yourself, sorted alphabetically. + func everyone(including me: String?) -> [String] { + var s = Set(teammates.keys) + if let me { s.insert(me) } + return s.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + private func persist() { + let raw = Dictionary( + uniqueKeysWithValues: teammates.map { ($0.key, $0.value.timeIntervalSince1970) } + ) + UserDefaults.standard.set(raw, forKey: storageKey) + } +} diff --git a/Nudge/Nudge/NudgeStats.swift b/Nudge/Nudge/NudgeStats.swift new file mode 100644 index 0000000..04c3f97 --- /dev/null +++ b/Nudge/Nudge/NudgeStats.swift @@ -0,0 +1,111 @@ +// +// NudgeStats.swift +// Nudge +// +// Tracks GLOBAL (all-time) nudge counters: how many you've sent (per peer +// + total) and received (per peer + total). Never resets. Also keeps a +// peer-leaderboard table of what each teammate has self-reported via their +// own pings, so we can show a cross-team leaderboard with no backend. +// + +import Foundation +import Combine + +@MainActor +final class NudgeStats: ObservableObject { + static let shared = NudgeStats() + + /// Bumped on every write so SwiftUI views observing this object refresh. + @Published private(set) var lastUpdate: Date = .distantPast + + private let countsKey = "nudgeCountsAllTime" // "sent:" / "recv:" -> Int + private let peerLBKey = "nudgePeerLeaderboardAllTime" // "" -> peer's self-reported total sends + + private var counts: [String: Int] = [:] + private var peerLB: [String: Int] = [:] + + private init() { + if let dict = UserDefaults.standard.dictionary(forKey: countsKey) as? [String: Int] { + counts = dict + } + if let dict = UserDefaults.standard.dictionary(forKey: peerLBKey) as? [String: Int] { + peerLB = dict + } + } + + // MARK: - Writes + + func recordSent(to peer: String) { + counts["sent:\(peer)", default: 0] += 1 + persist() + } + + func recordReceived(from peer: String) { + counts["recv:\(peer)", default: 0] += 1 + persist() + } + + /// Record what a peer claims their own all-time send total is. + /// Monotonic — never accept a lower number (clock skew / stale messages). + func recordPeerLeaderboard(peer: String, totalSends: Int) { + guard totalSends > 0 else { return } + let existing = peerLB[peer] ?? 0 + if totalSends > existing { + peerLB[peer] = totalSends + persist() + } + } + + // MARK: - Reads + + var mySendsTotal: Int { + counts.filter { $0.key.hasPrefix("sent:") }.map(\.value).reduce(0, +) + } + + var myReceivedTotal: Int { + counts.filter { $0.key.hasPrefix("recv:") }.map(\.value).reduce(0, +) + } + + func sentTotal(to peer: String) -> Int { + counts["sent:\(peer)"] ?? 0 + } + + func receivedTotal(from peer: String) -> Int { + counts["recv:\(peer)"] ?? 0 + } + + func sentBreakdown() -> [(peer: String, count: Int)] { + counts + .filter { $0.key.hasPrefix("sent:") && $0.value > 0 } + .map { (peer: String($0.key.dropFirst("sent:".count)), count: $0.value) } + .sorted { $0.peer.localizedCaseInsensitiveCompare($1.peer) == .orderedAscending } + } + + func receivedBreakdown() -> [(peer: String, count: Int)] { + counts + .filter { $0.key.hasPrefix("recv:") && $0.value > 0 } + .map { (peer: String($0.key.dropFirst("recv:".count)), count: $0.value) } + .sorted { $0.peer.localizedCaseInsensitiveCompare($1.peer) == .orderedAscending } + } + + func leaderboard() -> [(peer: String, sends: Int)] { + let me = NudgeIdentity.shared.current + let names = NudgeRoster.shared.everyone(including: me) + return names.map { name -> (peer: String, sends: Int) in + if name == me { + return (peer: name, sends: mySendsTotal) + } else { + return (peer: name, sends: peerLB[name] ?? 0) + } + } + .sorted { $0.sends > $1.sends } + } + + // MARK: - Persistence + + private func persist() { + UserDefaults.standard.set(counts, forKey: countsKey) + UserDefaults.standard.set(peerLB, forKey: peerLBKey) + lastUpdate = Date() + } +} diff --git a/Nudge/Nudge/NudgeTeamSecret.swift b/Nudge/Nudge/NudgeTeamSecret.swift new file mode 100644 index 0000000..85d1420 --- /dev/null +++ b/Nudge/Nudge/NudgeTeamSecret.swift @@ -0,0 +1,167 @@ +// +// NudgeTeamSecret.swift +// Nudge +// +// Holds the team password (in Keychain) and derives both the ntfy topic +// nonce and the AES-GCM encryption key from it via HKDF-SHA256. +// Everyone on the team types the same password; they land on the same +// topic and can decrypt each other's messages. Anyone else who happens +// to know the topic just sees ciphertext. +// + +import Foundation +import Combine +import CryptoKit +import Security +import os + +@MainActor +final class NudgeTeamSecret: ObservableObject { + static let shared = NudgeTeamSecret() + + @Published private(set) var hasPassword: Bool = false + + private let log = Logger(subsystem: "com.ontora.nudge", category: "team-secret") + private let service = "com.ontora.nudge.team-secret" + private let account = "team" + + private var cachedKey: SymmetricKey? + private var cachedNonce: String? + + private init() { + if let pw = readPassword() { + derive(from: pw) + hasPassword = true + } + } + + // MARK: - Mutation + + func setPassword(_ pw: String) { + let trimmed = pw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let previous = readPassword() + writePassword(trimmed) + derive(from: trimmed) + hasPassword = true + log.info("team password set") + if previous != trimmed { + // Joined (or rejoined under a new password) a different team — + // the old roster came from a different topic and is stale. + NudgeRoster.shared.clear() + // Force resubscription on the new topic. + if let me = NudgeIdentity.shared.current { + NudgeTransport.shared.stopReceiving() + NudgeTransport.shared.startReceiving(as: me) + } + } + } + + func clearPassword() { + deletePassword() + cachedKey = nil + cachedNonce = nil + hasPassword = false + log.info("team password cleared") + } + + /// For the "show" toggle in Settings. + func currentPassword() -> String? { + readPassword() + } + + // MARK: - Derived values + + /// `nudge-team-<12 hex chars derived from password>`. One topic for + /// the whole team — recipient name moves into the encrypted payload. + /// Returns nil if no password set. + var teamTopic: String? { + guard let nonce = cachedNonce else { return nil } + return "nudge-team-\(nonce)" + } + + func encrypt(_ plaintext: String) -> String? { + guard let key = cachedKey, + let data = plaintext.data(using: .utf8), + let sealed = try? AES.GCM.seal(data, using: key), + let combined = sealed.combined else { return nil } + return combined.base64EncodedString() + } + + func decrypt(_ ciphertextB64: String) -> String? { + let trimmed = ciphertextB64.trimmingCharacters(in: .whitespacesAndNewlines) + guard let key = cachedKey, + let data = Data(base64Encoded: trimmed), + let box = try? AES.GCM.SealedBox(combined: data), + let opened = try? AES.GCM.open(box, using: key), + let str = String(data: opened, encoding: .utf8) else { return nil } + return str + } + + // MARK: - Derivation + + private func derive(from password: String) { + // HKDF-SHA256 derives two separate values from one password via + // distinct `info` tags. Salt is a fixed app-level domain separator + // (per-user salt isn't possible since everyone must derive the same + // values to interoperate). For a 3-person internal tool with a + // verbally-shared password this is adequate; a brute-force attacker + // would have to guess the password to compute the topic. + let ikm = SymmetricKey(data: Data(password.utf8)) + let salt = Data("nudge-ontora-v1".utf8) + + cachedKey = HKDF.deriveKey( + inputKeyMaterial: ikm, + salt: salt, + info: Data("nudge-encryption-key-v1".utf8), + outputByteCount: 32 + ) + + let nonceBytes = HKDF.deriveKey( + inputKeyMaterial: ikm, + salt: salt, + info: Data("nudge-topic-id-v1".utf8), + outputByteCount: 6 + ) + cachedNonce = nonceBytes.withUnsafeBytes { buf in + buf.map { String(format: "%02x", $0) }.joined() + } + } + + // MARK: - Keychain + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } + + private func readPassword() -> String? { + var q = baseQuery() + q[kSecReturnData as String] = true + q[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(q as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let str = String(data: data, encoding: .utf8) else { return nil } + return str + } + + private func writePassword(_ pw: String) { + deletePassword() + var q = baseQuery() + q[kSecValueData as String] = Data(pw.utf8) + q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(q as CFDictionary, nil) + if status != errSecSuccess { + log.error("keychain write failed: \(status)") + } + } + + private func deletePassword() { + SecItemDelete(baseQuery() as CFDictionary) + } +} diff --git a/Nudge/Nudge/NudgeTransport.swift b/Nudge/Nudge/NudgeTransport.swift index f7ae749..9955eb3 100644 --- a/Nudge/Nudge/NudgeTransport.swift +++ b/Nudge/Nudge/NudgeTransport.swift @@ -2,6 +2,15 @@ // NudgeTransport.swift // Nudge // +// Single shared ntfy topic for the whole team (derived from password). +// Every member subscribes; every message is encrypted with the team key. +// Two message types travel on this topic: +// +// - "hello": "I'm , I'm online" — broadcasts your existence so +// teammates can populate their roster without prior coordination. +// - "ping" : " nudges " — the actual wave. Receivers ignore +// pings whose `to` doesn't match their own identity. +// import Foundation import Combine @@ -11,9 +20,29 @@ import os struct NudgePing: Equatable { let sender: String let message: String? + let gif: String? let receivedAt: Date } +/// Wire payload (v2). Carried inside the AES-GCM-encrypted body. +private struct NudgePayload: Codable { + let v: Int + let type: String // "ping" | "hello" + let sender: String + let to: String? // present only on type=="ping" + let sends: Int // sender's all-time send total (for the leaderboard) + let msg: String? // optional free-text message (ping only); omitted when nil + let gif: String? // optional GIF URL (ping only); omitted when nil + + static func ping(from sender: String, to recipient: String, sends: Int, msg: String?, gif: String?) -> NudgePayload { + NudgePayload(v: 2, type: "ping", sender: sender, to: recipient, sends: sends, msg: msg, gif: gif) + } + + static func hello(from sender: String, sends: Int) -> NudgePayload { + NudgePayload(v: 2, type: "hello", sender: sender, to: nil, sends: sends, msg: nil, gif: nil) + } +} + @MainActor final class NudgeTransport: ObservableObject { static let shared = NudgeTransport() @@ -22,50 +51,71 @@ final class NudgeTransport: ObservableObject { private let log = Logger(subsystem: "com.ontora.nudge", category: "transport") private var receiveTask: Task? - private var currentSubscribedUser: String? + private var heartbeatTask: Task? + private var currentUser: String? private let pathMonitor = NWPathMonitor() private let pathMonitorQueue = DispatchQueue(label: "com.ontora.nudge.path") private var lastPathStatus: NWPath.Status = .requiresConnection private var pathMonitorStarted = false + /// How often each user broadcasts a fresh "hello" so that rosters + /// converge even after sleep / network changes. + private let heartbeatInterval: TimeInterval = 30 * 60 + private init() {} - func send(to recipient: String, from sender: String, message: String? = nil) async throws { - let url = URL(string: "https://ntfy.sh/\(ntfyTopic(for: recipient))")! - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.setValue("\(sender) wants you", forHTTPHeaderField: "Title") - // The sender lives in the Title header. The body carries the optional - // free-text message; when there's no message we fall back to the sender - // name so older clients still surface something sensible. - let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) - let body = (trimmed?.isEmpty == false) ? trimmed! : sender - req.httpBody = Data(body.utf8) - let (_, resp) = try await URLSession.shared.data(for: req) - if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - log.error("send returned \(http.statusCode)") - throw URLError(.badServerResponse) + // MARK: - Public + + func send(to recipient: String, from sender: String, message: String? = nil, gif: String? = nil) async throws { + guard let topic = NudgeTeamSecret.shared.teamTopic else { + log.error("send aborted: no team password set") + throw URLError(.userAuthenticationRequired) } - log.info("sent ping to \(recipient) from \(sender)") + + // Increment first so the count we broadcast already includes this ping. + NudgeStats.shared.recordSent(to: recipient) + let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) + let gifTrimmed = gif?.trimmingCharacters(in: .whitespacesAndNewlines) + let payload = NudgePayload.ping( + from: sender, + to: recipient, + sends: NudgeStats.shared.mySendsTotal, + msg: (trimmed?.isEmpty == false) ? trimmed : nil, + gif: (gifTrimmed?.isEmpty == false) ? gifTrimmed : nil + ) + try await post(payload: payload, to: topic, label: "ping->\(recipient)") } func startReceiving(as user: String) { - if currentSubscribedUser == user, receiveTask != nil { return } + if currentUser == user, receiveTask != nil { return } stopReceiving() - currentSubscribedUser = user - log.info("starting subscription for \(user)") + currentUser = user + log.info("starting team subscription for \(user)") startPathMonitorIfNeeded() + receiveTask = Task { [weak self] in await self?.subscribeLoop(user: user) } + + // Announce yourself immediately and on a heartbeat. + Task { [weak self] in + await self?.sendHello(as: user) + } + heartbeatTask = Task { [weak self] in + await self?.heartbeatLoop(user: user) + } } func stopReceiving() { receiveTask?.cancel() receiveTask = nil - currentSubscribedUser = nil + heartbeatTask?.cancel() + heartbeatTask = nil + currentUser = nil } + // MARK: - Subscribe + private func subscribeLoop(user: String) async { var backoff: UInt64 = 1 while !Task.isCancelled { @@ -84,7 +134,10 @@ final class NudgeTransport: ObservableObject { } private func subscribeOnce(user: String) async throws { - let url = URL(string: "https://ntfy.sh/\(ntfyTopic(for: user))/sse")! + guard let topic = NudgeTeamSecret.shared.teamTopic else { + throw URLError(.userAuthenticationRequired) + } + let url = URL(string: "https://ntfy.sh/\(topic)/sse")! var req = URLRequest(url: url) req.timeoutInterval = 0 req.setValue("text/event-stream", forHTTPHeaderField: "Accept") @@ -92,39 +145,97 @@ final class NudgeTransport: ObservableObject { guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw URLError(.badServerResponse) } - log.info("SSE connected for \(user)") + log.info("SSE connected for team topic") + for try await line in bytes.lines { if Task.isCancelled { break } let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("data:") else { continue } - let payload = String(trimmed.dropFirst(5)).trimmingCharacters(in: .whitespaces) - guard let data = payload.data(using: .utf8) else { continue } - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let event = json["event"] as? String ?? "" - if event != "message" { continue } - let title = (json["title"] as? String) ?? "" - let body = (json["message"] as? String) ?? "" - // Sender is encoded in the title as "{sender} wants you". - let suffix = " wants you" - var sender = "Someone" - if title.hasSuffix(suffix) { - let parsed = String(title.dropLast(suffix.count)) - if !parsed.isEmpty { sender = parsed } - } else if !body.isEmpty { - sender = body - } - // The body holds the optional message — unless it's just the - // sender name (the no-message fallback). - let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) - let message: String? = (trimmedBody.isEmpty || trimmedBody == sender) - ? nil : trimmedBody - await MainActor.run { - self.lastIncoming = NudgePing(sender: sender, message: message, receivedAt: Date()) - } + let raw = String(trimmed.dropFirst(5)).trimmingCharacters(in: .whitespaces) + guard let json = try? JSONSerialization.jsonObject(with: Data(raw.utf8)) as? [String: Any], + (json["event"] as? String) == "message", + let messageBody = json["message"] as? String, + let plaintext = NudgeTeamSecret.shared.decrypt(messageBody), + let pdata = plaintext.data(using: .utf8), + let payload = try? JSONDecoder().decode(NudgePayload.self, from: pdata) else { + continue } + await handle(payload: payload, myUser: user) + } + } + + private func handle(payload: NudgePayload, myUser: String) async { + // Anyone we hear from goes into the roster. + NudgeRoster.shared.record(name: payload.sender) + + switch payload.type { + case "hello": + if payload.sends > 0 { + NudgeStats.shared.recordPeerLeaderboard( + peer: payload.sender, + totalSends: payload.sends + ) + } + case "ping": + // Ignore pings addressed to other people. Also ignore your + // own loopback (you'd see your own broadcast otherwise). + guard let to = payload.to, + to == myUser, + payload.sender != myUser else { return } + NudgeStats.shared.recordReceived(from: payload.sender) + NudgeStats.shared.recordPeerLeaderboard( + peer: payload.sender, + totalSends: payload.sends + ) + self.lastIncoming = NudgePing(sender: payload.sender, message: payload.msg, gif: payload.gif, receivedAt: Date()) + default: + log.info("ignoring unknown payload type: \(payload.type)") + } + } + + // MARK: - Hello / heartbeat + + private func sendHello(as user: String) async { + guard let topic = NudgeTeamSecret.shared.teamTopic else { return } + let payload = NudgePayload.hello(from: user, sends: NudgeStats.shared.mySendsTotal) + do { + try await post(payload: payload, to: topic, label: "hello") + } catch { + log.error("hello send failed: \(error.localizedDescription)") } } + private func heartbeatLoop(user: String) async { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(heartbeatInterval) * 1_000_000_000) + if Task.isCancelled { return } + await sendHello(as: user) + } + } + + // MARK: - HTTP helper + + private func post(payload: NudgePayload, to topic: String, label: String) async throws { + guard let data = try? JSONEncoder().encode(payload), + let str = String(data: data, encoding: .utf8), + let body = NudgeTeamSecret.shared.encrypt(str) else { + log.error("\(label): payload encode/encryption failed") + throw URLError(.cannotCreateFile) + } + let url = URL(string: "https://ntfy.sh/\(topic)")! + var req = URLRequest(url: url) + req.httpMethod = "POST" + // Generic title — never leak names in cleartext to ntfy's own clients. + req.setValue("Nudge", forHTTPHeaderField: "Title") + req.httpBody = Data(body.utf8) + let (_, resp) = try await URLSession.shared.data(for: req) + if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + log.error("\(label) returned \(http.statusCode)") + throw URLError(.badServerResponse) + } + log.info("\(label) ok") + } + private func startPathMonitorIfNeeded() { guard !pathMonitorStarted else { return } pathMonitorStarted = true @@ -134,12 +245,15 @@ final class NudgeTransport: ObservableObject { let was = self.lastPathStatus self.lastPathStatus = path.status if was != .satisfied, path.status == .satisfied, - let user = self.currentSubscribedUser { - self.log.info("network became satisfied, resubscribing") + let user = self.currentUser { + self.log.info("network became satisfied, resubscribing + saying hello") self.receiveTask?.cancel() self.receiveTask = Task { [weak self] in await self?.subscribeLoop(user: user) } + Task { [weak self] in + await self?.sendHello(as: user) + } } } } diff --git a/Nudge/NudgeApp.swift b/Nudge/NudgeApp.swift index e80bc46..6385a15 100644 --- a/Nudge/NudgeApp.swift +++ b/Nudge/NudgeApp.swift @@ -18,23 +18,35 @@ struct DynamicNotchApp: App { @Default(.nudgePlaySoundOnReceive) var playSoundOnReceive @Default(.nudgeShowBackupNotification) var showBackupNotification @ObservedObject var identity = NudgeIdentity.shared + @ObservedObject var teamSecret = NudgeTeamSecret.shared + @ObservedObject var stats = NudgeStats.shared @Environment(\.openWindow) var openWindow @State private var launchAtLogin: Bool = LoginItem.isEnabled var body: some Scene { - MenuBarExtra("Nudge", systemImage: "hand.wave.fill", isInserted: $showMenuBarIcon) { + MenuBarExtra(isInserted: $showMenuBarIcon) { if let me = identity.current { Text("You are: \(me)") - Button("Switch user…") { - DispatchQueue.main.async { - appDelegate.showIdentityPicker() - } - } } else { - Button("Pick your name…") { - DispatchQueue.main.async { - appDelegate.showIdentityPicker() - } + Text("Identity: not set") + } + if teamSecret.hasPassword { + Text("Team password: set ✓") + } else { + Text("Team password: NOT set") + } + Divider() + Text("Nudges sent: \(stats.mySendsTotal)") + Text("Nudges received: \(stats.myReceivedTotal)") + Divider() + Button("Send a GIF…") { + DispatchQueue.main.async { + GifPickerWindowController.shared.showWindow() + } + } + Button("Open onboarding…") { + DispatchQueue.main.async { + appDelegate.showOnboarding() } } Divider() @@ -48,12 +60,6 @@ struct DynamicNotchApp: App { } )) Divider() - Text("Topic nonce: \(nudgeNonce)") - Button("Copy nonce") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(nudgeNonce, forType: .string) - } - Divider() Button("Settings") { DispatchQueue.main.async { SettingsWindowController.shared.showWindow() @@ -64,25 +70,24 @@ struct DynamicNotchApp: App { NSApplication.shared.terminate(self) } .keyboardShortcut(KeyEquivalent("Q"), modifiers: .command) + } label: { + // Just the wave — the counts live inside the menu, not next to the icon. + Image(systemName: "hand.wave.fill") } } } class AppDelegate: NSObject, NSApplicationDelegate { - var statusItem: NSStatusItem? var windows: [String: NSWindow] = [:] // UUID -> NSWindow var viewModels: [String: NudgeViewModel] = [:] // UUID -> NudgeViewModel var window: NSWindow? let vm: NudgeViewModel = .init() @ObservedObject var coordinator = NudgeViewCoordinator.shared - var whatsNewWindow: NSWindow? - var timer: Timer? var closeNotchTask: Task? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? private var screenLockedObserver: Any? private var screenUnlockedObserver: Any? - private var isScreenLocked: Bool = false private var windowScreenDidChangeObserver: Any? private var identityCancellable: AnyCancellable? @@ -106,57 +111,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { @MainActor func onScreenLocked(_ notification: Notification) { - isScreenLocked = true if !Defaults[.showOnLockScreen] { cleanupWindows() - } else { - enableSkyLightOnAllWindows() } } @MainActor func onScreenUnlocked(_ notification: Notification) { - isScreenLocked = false if !Defaults[.showOnLockScreen] { adjustWindowPosition(changeAlpha: true) - } else { - disableSkyLightOnAllWindows() - } - } - - @MainActor - private func enableSkyLightOnAllWindows() { - if Defaults[.showOnAllDisplays] { - windows.values.forEach { window in - if let skyWindow = window as? NudgeSkyLightWindow { - skyWindow.enableSkyLight() - } - } - } else { - if let skyWindow = window as? NudgeSkyLightWindow { - skyWindow.enableSkyLight() - } - } - } - - @MainActor - private func disableSkyLightOnAllWindows() { - // Delay disabling SkyLight to avoid flicker during unlock transition - Task { - try? await Task.sleep(for: .milliseconds(150)) - await MainActor.run { - if Defaults[.showOnAllDisplays] { - self.windows.values.forEach { window in - if let skyWindow = window as? NudgeSkyLightWindow { - skyWindow.disableSkyLight() - } - } - } else { - if let skyWindow = self.window as? NudgeSkyLightWindow { - skyWindow.disableSkyLight() - } - } - } } } @@ -184,21 +147,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func createNudgeWindow(for screen: NSScreen, with viewModel: NudgeViewModel) -> NSWindow { let rect = NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height) let styleMask: NSWindow.StyleMask = [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow] - let window = NudgeSkyLightWindow(contentRect: rect, styleMask: styleMask, backing: .buffered, defer: false) - - // Enable SkyLight only when screen is locked - if isScreenLocked { - window.enableSkyLight() - } else { - window.disableSkyLight() - } - window.contentView = NSHostingView( rootView: ContentView() .environmentObject(viewModel) ) - window.orderFrontRegardless() NotchSpaceManager.shared.notchSpace.windows.insert(window) return window @@ -335,20 +288,29 @@ class AppDelegate: NSObject, NSApplicationDelegate { adjustWindowPosition(changeAlpha: true) } - // Start Nudge transport when an identity is set; stop when cleared. - identityCancellable = NudgeIdentity.shared.$current - .receive(on: RunLoop.main) - .sink { user in - if let user { - NudgeTransport.shared.startReceiving(as: user) - } else { - NudgeTransport.shared.stopReceiving() - } + // Start Nudge transport only when BOTH identity and team password + // are present; stop whenever either is missing. + identityCancellable = Publishers.CombineLatest( + NudgeIdentity.shared.$current, + NudgeTeamSecret.shared.$hasPassword + ) + .receive(on: RunLoop.main) + .sink { user, hasPwd in + if let user, hasPwd { + NudgeTransport.shared.startReceiving(as: user) + } else { + NudgeTransport.shared.stopReceiving() } + } + // Resume onboarding at whichever step is missing. if NudgeIdentity.shared.current == nil { DispatchQueue.main.async { [weak self] in - self?.showOnboardingWindow() + self?.showOnboardingWindow(step: .identity) + } + } else if !NudgeTeamSecret.shared.hasPassword { + DispatchQueue.main.async { [weak self] in + self?.showOnboardingWindow(step: .password) } } @@ -356,8 +318,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @MainActor - func showIdentityPicker() { - showOnboardingWindow() + func showOnboarding() { + // Pick the right step based on what's missing. + let step: OnboardingStep + if NudgeIdentity.shared.current == nil { + step = .identity + } else if !NudgeTeamSecret.shared.hasPassword { + step = .password + } else { + step = .identity + } + showOnboardingWindow(step: step) } func deviceHasNotch() -> Bool { @@ -458,22 +429,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - @objc func togglePopover(_ sender: Any?) { - if window?.isVisible == true { - window?.orderOut(nil) - } else { - window?.orderFrontRegardless() - } - } - - @objc func showMenu() { - statusItem?.menu?.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) - } - - @objc func quitAction() { - NSApplication.shared.terminate(self) - } - private func showOnboardingWindow(step: OnboardingStep = .identity) { if onboardingWindowController == nil { let window = NSWindow( diff --git a/Nudge/components/GifPicker/GifPickerWindowController.swift b/Nudge/components/GifPicker/GifPickerWindowController.swift new file mode 100644 index 0000000..a7346bb --- /dev/null +++ b/Nudge/components/GifPicker/GifPickerWindowController.swift @@ -0,0 +1,65 @@ +// +// GifPickerWindowController.swift +// Nudge +// +// Hosts the GIF picker in its own panel, opened from the notch's GIF +// button and the menu bar. +// + +import AppKit +import SwiftUI + +final class GifPickerWindowController: NSWindowController { + static let shared = GifPickerWindowController() + + private init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 540), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + super.init(window: window) + setupWindow() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupWindow() { + guard let window = window else { return } + window.title = "Send a GIF" + window.isMovableByWindowBackground = true + window.hidesOnDeactivate = false + window.isRestorable = false + window.identifier = NSUserInterfaceItemIdentifier("NudgeGifPickerWindow") + window.contentView = NSHostingView( + rootView: NudgeGifPickerView(onClose: { [weak self] in self?.close() }) + ) + window.delegate = self + } + + func showWindow() { + NSApp.setActivationPolicy(.regular) + window?.center() + window?.makeKeyAndOrderFront(nil) + window?.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + } + + override func close() { + super.close() + relinquishFocus() + } + + private func relinquishFocus() { + window?.orderOut(nil) + NSApp.setActivationPolicy(.accessory) + } +} + +extension GifPickerWindowController: NSWindowDelegate { + func windowWillClose(_ notification: Notification) { relinquishFocus() } + func windowDidBecomeKey(_ notification: Notification) { NSApp.setActivationPolicy(.regular) } +} diff --git a/Nudge/components/GifPicker/NudgeGifPickerView.swift b/Nudge/components/GifPicker/NudgeGifPickerView.swift new file mode 100644 index 0000000..9c40e8c --- /dev/null +++ b/Nudge/components/GifPicker/NudgeGifPickerView.swift @@ -0,0 +1,183 @@ +// +// NudgeGifPickerView.swift +// Nudge +// +// Search KLIPY, pick a GIF, choose a teammate, optionally add a message, +// and send. Lives in its own panel (see GifPickerWindowController) so the +// ephemeral notch doesn't dismiss it mid-search. +// + +import SwiftUI + +struct NudgeGifPickerView: View { + @ObservedObject var identity = NudgeIdentity.shared + @ObservedObject var roster = NudgeRoster.shared + @ObservedObject var teamSecret = NudgeTeamSecret.shared + + @State private var query: String = "" + @State private var results: [NudgeGif] = [] + @State private var selected: NudgeGif? + @State private var message: String = "" + @State private var recipient: String = "" + @State private var loading: Bool = false + @State private var error: String? + @State private var sending: Bool = false + + let onClose: () -> Void + + private let columns = [GridItem(.adaptive(minimum: 110), spacing: 8)] + + private var others: [String] { roster.others(excluding: identity.current) } + + var body: some View { + VStack(spacing: 12) { + header + + if !NudgeGifService.isConfigured { + notConfigured + } else if identity.current == nil || !teamSecret.hasPassword { + Text("Finish onboarding (name + team password) before sending GIFs.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + recipientRow + searchRow + resultsGrid + composeRow + } + } + .padding(16) + .frame(minWidth: 460, minHeight: 520) + .task { await initialLoad() } + .onAppear { if recipient.isEmpty { recipient = others.first ?? "" } } + } + + private var header: some View { + HStack { + Label("Send a GIF", systemImage: "photo.on.rectangle.angled") + .font(.headline) + Spacer() + Button("Close", action: onClose) + } + } + + private var notConfigured: some View { + VStack(spacing: 12) { + Image(systemName: "key.horizontal") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("No Klipy API key yet") + .font(.title3.weight(.semibold)) + Text("Paste a Klipy API key in Settings → GIFs to search and send GIFs.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + Button("Open Settings") { + SettingsWindowController.shared.showWindow() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var recipientRow: some View { + HStack { + Text("To") + if others.isEmpty { + Text("No teammates online yet") + .foregroundStyle(.secondary) + } else { + Picker("", selection: $recipient) { + ForEach(others, id: \.self) { Text($0).tag($0) } + } + .labelsHidden() + } + Spacer() + } + } + + private var searchRow: some View { + HStack { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search GIFs", text: $query) + .textFieldStyle(.plain) + .onSubmit { Task { await runSearch() } } + if loading { ProgressView().controlSize(.small) } + } + .padding(8) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.06))) + } + + private var resultsGrid: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(results) { gif in + Button { + selected = gif + } label: { + AnimatedGifView(url: gif.previewURL) + .frame(height: 90) + .frame(maxWidth: .infinity) + .background(Color.black.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(selected == gif ? Color.accentColor : .clear, lineWidth: 3) + ) + } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: .infinity) + } + + private var composeRow: some View { + VStack(spacing: 8) { + if let error { Text(error).font(.caption).foregroundStyle(.red) } + HStack { + TextField("Add a message (optional)", text: $message) + .textFieldStyle(.roundedBorder) + Button { + Task { await send() } + } label: { + if sending { ProgressView().controlSize(.small) } + else { Label("Send", systemImage: "paperplane.fill") } + } + .buttonStyle(.borderedProminent) + .disabled(selected == nil || recipient.isEmpty || sending) + } + } + } + + // MARK: - Actions + + private func initialLoad() async { + guard NudgeGifService.isConfigured, results.isEmpty else { return } + await runSearch() + } + + private func runSearch() async { + error = nil + loading = true + defer { loading = false } + do { + results = try await NudgeGifService.search(query) + } catch { + self.error = (error as? LocalizedError)?.errorDescription ?? "Search failed" + } + } + + private func send() async { + guard let me = identity.current, let gif = selected, !recipient.isEmpty else { return } + sending = true + error = nil + do { + try await NudgeTransport.shared.send( + to: recipient, from: me, message: message, gif: gif.sendURL.absoluteString + ) + onClose() + } catch { + self.error = "Send failed" + } + sending = false + } +} diff --git a/Nudge/components/Onboarding/OnboardingFinishView.swift b/Nudge/components/Onboarding/OnboardingFinishView.swift index 15481f5..45e1694 100644 --- a/Nudge/components/Onboarding/OnboardingFinishView.swift +++ b/Nudge/components/Onboarding/OnboardingFinishView.swift @@ -16,7 +16,7 @@ struct OnboardingFinishView: View { VStack(spacing: 20) { Spacer() - Image(systemName: "sparkles") + Image(systemName: "hand.wave.fill") .font(.system(size: 60)) .foregroundColor(.effectiveAccent) .padding() diff --git a/Nudge/components/Onboarding/OnboardingView.swift b/Nudge/components/Onboarding/OnboardingView.swift index 4824cb1..c00da0a 100644 --- a/Nudge/components/Onboarding/OnboardingView.swift +++ b/Nudge/components/Onboarding/OnboardingView.swift @@ -7,6 +7,7 @@ import SwiftUI enum OnboardingStep { case identity + case password case finished } @@ -20,6 +21,13 @@ struct OnboardingView: View { switch step { case .identity: NudgeIdentityPickerView { + withAnimation(.easeInOut(duration: 0.4)) { + step = .password + } + } + .transition(.opacity) + case .password: + NudgePasswordPickerView { withAnimation(.easeInOut(duration: 0.4)) { step = .finished } diff --git a/Nudge/components/Settings/SettingsView.swift b/Nudge/components/Settings/SettingsView.swift index a605523..f05d493 100644 --- a/Nudge/components/Settings/SettingsView.swift +++ b/Nudge/components/Settings/SettingsView.swift @@ -40,15 +40,22 @@ struct SettingsView: View { NavigationStack { NudgeSettingsPane() } - .frame(width: 540, height: 480) + .frame(width: 540, height: 600) } } struct NudgeSettingsPane: View { @ObservedObject var identity = NudgeIdentity.shared + @ObservedObject var teamSecret = NudgeTeamSecret.shared + @ObservedObject var stats = NudgeStats.shared + @ObservedObject var roster = NudgeRoster.shared @Default(.nudgePlaySoundOnReceive) var playSoundOnReceive @Default(.nudgeShowBackupNotification) var showBackupNotification - @State private var copiedNonce: Bool = false + @Default(.klipyApiKey) var klipyApiKey + + @State private var nameDraft: String = NudgeIdentity.shared.current ?? "" + @State private var nameError: String? + @State private var showingPasswordSheet: Bool = false @State private var launchAtLogin: Bool = LoginItem.isEnabled private var launchAtLoginBinding: Binding { @@ -65,28 +72,146 @@ struct NudgeSettingsPane: View { Form { Section { HStack { - Text("Current user") + TextField("Your name", text: $nameDraft) + .textFieldStyle(.roundedBorder) + .onSubmit(saveName) + Button("Save", action: saveName) + .disabled( + !NudgeIdentity.isValid(NudgeIdentity.sanitize(nameDraft)) || + NudgeIdentity.sanitize(nameDraft) == identity.current + ) + } + if let err = nameError { + Text(err) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text("Identity") + } footer: { + Text("This is what teammates see on their notch when you nudge them. 1–\(nudgeMaxNameLength) characters, no colons.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + HStack { + Text("Status") Spacer() - Text(identity.current ?? "—") + if teamSecret.hasPassword { + Label("Set", systemImage: "lock.fill") + .foregroundStyle(.secondary) + } else { + Label("Not set — pings won't work", systemImage: "lock.open.fill") + .foregroundStyle(.red) + } + } + Button("Change password…") { + showingPasswordSheet = true + } + } header: { + Text("Team password") + } footer: { + Text("Everyone on your team must type the same password. It encrypts your pings and decides who's on the same team. Stored in Keychain, never sent over the network.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + let others = roster.others(excluding: identity.current) + if others.isEmpty { + Text("No teammates seen yet. Send or wait for a hello — you'll appear here within a minute or two.") + .font(.callout) .foregroundStyle(.secondary) + } else { + ForEach(others, id: \.self) { name in + HStack { + Image(systemName: "person.fill") + .foregroundStyle(.secondary) + Text(name) + Spacer() + if let seen = roster.teammates[name] { + Text(seen.formatted(.relative(presentation: .named))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } } - HStack { - ForEach(nudgeUsers, id: \.self) { user in - Button { - identity.setCurrent(user) - } label: { - Text(user) - .padding(.horizontal, 12) - .padding(.vertical, 6) + } header: { + Text("Known teammates") + } footer: { + Text("People who have said hello on this team password. Auto-refreshes every ~30 minutes per teammate.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + let lb = stats.leaderboard() + let allZero = lb.allSatisfy { $0.sends == 0 } + if allZero { + Text("No nudges yet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(Array(lb.enumerated()), id: \.element.peer) { idx, row in + HStack { + Text("\(idx + 1).") + .foregroundStyle(.secondary) + .monospacedDigit() + .frame(width: 22, alignment: .leading) + Text(row.peer) + .fontWeight(row.peer == identity.current ? .semibold : .regular) + if row.peer == identity.current { + Text("(you)") + .foregroundStyle(.secondary) + .font(.caption) + } + Spacer() + Text("\(row.sends)") + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } + + let sent = stats.sentBreakdown() + if !sent.isEmpty { + Divider() + Text("You sent") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(sent, id: \.peer) { row in + HStack { + Text("→ \(row.peer)") + Spacer() + Text("\(row.count)") + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } + + let recv = stats.receivedBreakdown() + if !recv.isEmpty { + Divider() + Text("You received") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(recv, id: \.peer) { row in + HStack { + Text("← \(row.peer)") + Spacer() + Text("\(row.count)") + .monospacedDigit() + .foregroundStyle(.secondary) } - .buttonStyle(.bordered) - .tint(identity.current == user ? .accentColor : .secondary) } } } header: { - Text("Identity") + Text("Leaderboard (all-time)") } footer: { - Text("Pick who you are. Every Ontora teammate's build runs this app.") + Text("All-time totals. Each row is what that teammate has reported via their pings. Someone who hasn't pinged yet shows 0.") .font(.caption) .foregroundStyle(.secondary) } @@ -109,33 +234,18 @@ struct NudgeSettingsPane: View { } Section { - HStack { - Text("Topic nonce") - .frame(width: 120, alignment: .leading) - Text(nudgeNonce) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - Spacer() - Button(copiedNonce ? "Copied!" : "Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(nudgeNonce, forType: .string) - copiedNonce = true - Task { - try? await Task.sleep(nanoseconds: 1_500_000_000) - await MainActor.run { copiedNonce = false } - } - } - } + SecureField("Klipy API key", text: $klipyApiKey) + .textFieldStyle(.roundedBorder) } header: { - Text("Shared nonce") + Text("GIFs") } footer: { - Text("All Ontora teammates' builds must have the exact same nonce. Rotate by editing NudgeConstants.swift and shipping a new build to everyone.") + Text("A shared team key ships with the app, so GIFs work out of the box. Paste your own Klipy key here only if you want to override it. Get one free at klipy.com/developers — stored locally, never sent to teammates.") .font(.caption) .foregroundStyle(.secondary) } Section { - Text("Nudge sends a hand-raise ping through ntfy.sh under a topic derived from the recipient's name + the shared nonce. No backend, no accounts — just \"X wants you\" plus an optional short message. When someone pings you, your notch expands for ~6 seconds; a macOS notification fires as a backup in case the notch is off-screen.") + Text("Nudge sends an encrypted hand-wave through ntfy.sh on a topic derived from your team password. The whole team shares one topic and decodes the same payloads; recipients filter pings addressed to them. No backend, no accounts, no plaintext on the wire.") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -145,6 +255,24 @@ struct NudgeSettingsPane: View { } .formStyle(.grouped) .navigationTitle("Nudge") + .sheet(isPresented: $showingPasswordSheet) { + NudgePasswordPickerView { + showingPasswordSheet = false + } + } + .onChange(of: identity.current) { _, newValue in + nameDraft = newValue ?? "" + } + } + + private func saveName() { + let cleaned = NudgeIdentity.sanitize(nameDraft) + guard NudgeIdentity.isValid(cleaned) else { + nameError = "1–\(nudgeMaxNameLength) characters, no colons." + return + } + nameError = nil + identity.setCurrent(cleaned) } } diff --git a/Nudge/models/Constants.swift b/Nudge/models/Constants.swift index ea26765..4ec2615 100644 --- a/Nudge/models/Constants.swift +++ b/Nudge/models/Constants.swift @@ -174,4 +174,6 @@ extension Defaults.Keys { // MARK: Nudge static let nudgePlaySoundOnReceive = Key("nudgePlaySoundOnReceive", default: true) static let nudgeShowBackupNotification = Key("nudgeShowBackupNotification", default: true) + // Klipy (GIF search) API key — paste your own in Settings. + static let klipyApiKey = Key("klipyApiKey", default: "") }