From 79ba95ccad9720fe57f98cc60f36eeed534648ed Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 13 Aug 2025 17:26:30 +0545 Subject: [PATCH] Add announcement service for important notice/updates --- .../Notifications/AnnouncementManager.swift | 88 +++++++++++++++ VoiceInk/Notifications/AnnouncementView.swift | 94 ++++++++++++++++ VoiceInk/Services/AnnouncementsService.swift | 106 ++++++++++++++++++ VoiceInk/VoiceInk.swift | 2 + announcements.json | 7 +- 5 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 VoiceInk/Notifications/AnnouncementManager.swift create mode 100644 VoiceInk/Notifications/AnnouncementView.swift create mode 100644 VoiceInk/Services/AnnouncementsService.swift diff --git a/VoiceInk/Notifications/AnnouncementManager.swift b/VoiceInk/Notifications/AnnouncementManager.swift new file mode 100644 index 0000000..cb87f73 --- /dev/null +++ b/VoiceInk/Notifications/AnnouncementManager.swift @@ -0,0 +1,88 @@ +import SwiftUI +import AppKit + +final class AnnouncementManager { + static let shared = AnnouncementManager() + + private var panel: NSPanel? + + private init() {} + + @MainActor + func showAnnouncement(title: String, description: String?, learnMoreURL: URL?, onDismiss: @escaping () -> Void) { + dismiss() + + let view = AnnouncementView( + title: title, + description: description ?? "", + onClose: { [weak self] in + onDismiss() + self?.dismiss() + }, + onLearnMore: { [weak self] in + if let url = learnMoreURL { + NSWorkspace.shared.open(url) + } + onDismiss() + self?.dismiss() + } + ) + + let hosting = NSHostingController(rootView: view) + hosting.view.layoutSubtreeIfNeeded() + let size = hosting.view.fittingSize + + let panel = NSPanel( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + panel.contentView = hosting.view + panel.isFloatingPanel = true + panel.level = .statusBar + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isMovableByWindowBackground = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + position(panel) + panel.alphaValue = 0 + panel.makeKeyAndOrderFront(nil as Any?) + self.panel = panel + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + } + } + + @MainActor + func dismiss() { + guard let panel = panel else { return } + self.panel = nil + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + panel.animator().alphaValue = 0 + }, completionHandler: { + panel.close() + }) + } + + @MainActor + private func position(_ panel: NSPanel) { + let screen = NSApp.keyWindow?.screen ?? NSScreen.main ?? NSScreen.screens[0] + let visibleFrame = screen.visibleFrame + // Match MiniRecorder: bottom padding 24, centered horizontally + let bottomPadding: CGFloat = 24 + let x = visibleFrame.midX - (panel.frame.width / 2) + // Ensure bottom padding, but if the panel is taller, anchor its bottom at padding + let y = max(visibleFrame.minY + bottomPadding, visibleFrame.minY + bottomPadding) + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } +} + + diff --git a/VoiceInk/Notifications/AnnouncementView.swift b/VoiceInk/Notifications/AnnouncementView.swift new file mode 100644 index 0000000..58815c7 --- /dev/null +++ b/VoiceInk/Notifications/AnnouncementView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct AnnouncementView: View { + let title: String + let description: String + let onClose: () -> Void + let onLearnMore: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + + Spacer() + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + .buttonStyle(PlainButtonStyle()) + } + + if !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ScrollView { + Text(description) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.9)) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 120) + } + + HStack(spacing: 8) { + Button(action: onLearnMore) { + Text("Learn more") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.black) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: onClose) { + Text("Dismiss") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.9)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(minWidth: 360, idealWidth: 420) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.clear) + .background( + ZStack { + // Match Mini Recorder background layers + Color.black.opacity(0.9) + LinearGradient( + colors: [ + Color.black.opacity(0.95), + Color(red: 0.15, green: 0.15, blue: 0.15).opacity(0.9) + ], + startPoint: .top, + endPoint: .bottom + ) + VisualEffectView(material: .hudWindow, blendingMode: .withinWindow) + .opacity(0.05) + } + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.3), lineWidth: 0.5) + ) + } +} + + + + diff --git a/VoiceInk/Services/AnnouncementsService.swift b/VoiceInk/Services/AnnouncementsService.swift new file mode 100644 index 0000000..55da561 --- /dev/null +++ b/VoiceInk/Services/AnnouncementsService.swift @@ -0,0 +1,106 @@ +import Foundation +import AppKit + +/// A minimal pull-based announcements fetcher that shows one-time in-app banners. +final class AnnouncementsService { + static let shared = AnnouncementsService() + + private init() {} + + // MARK: - Configuration + + // Hosted via GitHub Pages for this repo + private let announcementsURL = URL(string: "https://beingpax.github.io/VoiceInk/announcements.json")! + + // Fetch every 4 hours + private let refreshInterval: TimeInterval = 4 * 60 * 60 + + private let dismissedKey = "dismissedAnnouncementIds" + private let maxDismissedToKeep = 2 + private var timer: Timer? + + // MARK: - Public API + + func start() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in + self?.fetchAndMaybeShow() + } + // Do an initial fetch shortly after launch + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in + self?.fetchAndMaybeShow() + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + // MARK: - Core Logic + + private func fetchAndMaybeShow() { + let request = URLRequest(url: announcementsURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10) + let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + guard let self = self else { return } + guard error == nil, let data = data else { return } + guard let announcements = try? JSONDecoder().decode([RemoteAnnouncement].self, from: data) else { return } + + let now = Date() + let notDismissed = announcements.filter { !self.isDismissed($0.id) } + let valid = notDismissed.filter { $0.isActive(at: now) } + + guard let next = valid.first else { return } + + DispatchQueue.main.async { + let url = next.url.flatMap { URL(string: $0) } + AnnouncementManager.shared.showAnnouncement( + title: next.title, + description: next.description, + learnMoreURL: url, + onDismiss: { self.markDismissed(next.id) } + ) + } + } + task.resume() + } + + private func isDismissed(_ id: String) -> Bool { + let set = UserDefaults.standard.stringArray(forKey: dismissedKey) ?? [] + return set.contains(id) + } + + private func markDismissed(_ id: String) { + var ids = UserDefaults.standard.stringArray(forKey: dismissedKey) ?? [] + if !ids.contains(id) { + ids.append(id) + } + // Keep only the most recent N ids + if ids.count > maxDismissedToKeep { + let overflow = ids.count - maxDismissedToKeep + ids.removeFirst(overflow) + } + UserDefaults.standard.set(ids, forKey: dismissedKey) + } +} + +// MARK: - Models + +private struct RemoteAnnouncement: Decodable { + let id: String + let title: String + let description: String? + let url: String? + let startAt: String? + let endAt: String? + + func isActive(at date: Date) -> Bool { + let formatter = ISO8601DateFormatter() + if let startAt = startAt, let start = formatter.date(from: startAt), date < start { return false } + if let endAt = endAt, let end = formatter.date(from: endAt), date > end { return false } + return true + } + +} + + diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index 0f2f219..28a3c97 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -97,6 +97,7 @@ struct VoiceInkApp: App { .modelContainer(container) .onAppear { updaterViewModel.silentlyCheckForUpdates() + AnnouncementsService.shared.start() // Start the transcription auto-cleanup service (handles immediate and scheduled transcript deletion) transcriptionAutoCleanupService.startMonitoring(modelContext: container.mainContext) @@ -110,6 +111,7 @@ struct VoiceInkApp: App { WindowManager.shared.configureWindow(window) }) .onDisappear { + AnnouncementsService.shared.stop() whisperState.unloadModel() // Stop the transcription auto-cleanup service diff --git a/announcements.json b/announcements.json index f4fc37e..e692841 100644 --- a/announcements.json +++ b/announcements.json @@ -3,10 +3,9 @@ "id": "2025-08-welcome", "title": "Thanks for using VoiceInk!", "description": "We’re rolling out minor fixes and performance improvements this week. No action needed.", - "severity": "info", - "url": "https://beingpax.github.io/VoiceInk/", - "startAt": "2025-08-01T00:00:00Z", - "endAt": "2026-01-01T00:00:00Z" + "url": "https://tryvoiceink.com/docs/announcements", + "startAt": "2025-07-01T00:00:00Z", + "endAt": "2025-08-01T00:00:00Z" } ]