Add announcement service for important notice/updates

This commit is contained in:
Beingpax 2025-08-13 17:26:30 +05:45
parent b63ae24b59
commit 79ba95ccad
5 changed files with 293 additions and 4 deletions

View 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))
}
}

View 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)
)
}
}

View 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
}
}

View File

@ -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

View File

@ -3,10 +3,9 @@
"id": "2025-08-welcome",
"title": "Thanks for using VoiceInk!",
"description": "Were 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"
}
]