Add announcement service for important notice/updates
This commit is contained in:
parent
b63ae24b59
commit
79ba95ccad
88
VoiceInk/Notifications/AnnouncementManager.swift
Normal file
88
VoiceInk/Notifications/AnnouncementManager.swift
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
94
VoiceInk/Notifications/AnnouncementView.swift
Normal file
94
VoiceInk/Notifications/AnnouncementView.swift
Normal file
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
106
VoiceInk/Services/AnnouncementsService.swift
Normal file
106
VoiceInk/Services/AnnouncementsService.swift
Normal file
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user