diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index 9a3f3489a..43993ed26 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -70,7 +70,7 @@ struct FirezoneApp: App { func applicationDidFinishLaunching(_: Notification) { if let store = store { - menuBar = MenuBar(favorites: favorites, model: SessionViewModel(store: store)) + menuBar = MenuBar(model: SessionViewModel(favorites: favorites, store: store)) } // SwiftUI will show the first window group, so close it on launch diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift index 0690f2f3b..0f2615a58 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift @@ -2,9 +2,11 @@ import Foundation public class Favorites: ObservableObject { private static let key = "favoriteResourceIDs" - @Published private(set) var ids: Set = Favorites.load() + @Published private(set) var ids: Set - public init() {} + public init() { + ids = Favorites.load() + } func contains(_ id: String) -> Bool { return ids.contains(id) @@ -13,24 +15,25 @@ public class Favorites: ObservableObject { func reset() { objectWillChange.send() ids = Set() - Favorites.save(ids) + save() } func add(_ id: String) { objectWillChange.send() ids.insert(id) - Favorites.save(ids) + save() } func remove(_ id: String) { objectWillChange.send() ids.remove(id) - Favorites.save(ids) + save() } - private static func save(_ ids: Set) { + private func save() { // It's a run-time exception if we pass the `Set` directly here - UserDefaults.standard.set(Array(ids), forKey: key) + let ids = Array(ids) + UserDefaults.standard.set(ids, forKey: Favorites.key) } private static func load() -> Set { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index 03427c42d..34aebf508 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -85,7 +85,7 @@ public struct AppView: View { } case (_, _): iOSNavigationView(model: model) { - SessionView(model: SessionViewModel(store: model.store)) + SessionView(model: SessionViewModel(favorites: model.favorites, store: model.store)) } } #elseif os(macOS) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 087c1a7db..e1b62cdd2 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -24,7 +24,6 @@ public final class MenuBar: NSObject, ObservableObject { private var lastShownOthers: [Resource] = [] private var cancellables: Set = [] - @ObservedObject var favorites: Favorites @ObservedObject var model: SessionViewModel private lazy var signedOutIcon = NSImage(named: "MenuBarIconSignedOut") @@ -38,9 +37,8 @@ public final class MenuBar: NSObject, ObservableObject { private var connectingAnimationImageIndex: Int = 0 private var connectingAnimationTimer: Timer? - public init(favorites: Favorites, model: SessionViewModel) { + public init(model: SessionViewModel) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - self.favorites = favorites self.model = model super.init() @@ -58,7 +56,7 @@ public final class MenuBar: NSObject, ObservableObject { } private func setupObservers() { - favorites.$ids + model.favorites.$ids .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] ids in guard let self = self else { return } @@ -417,23 +415,23 @@ public final class MenuBar: NSObject, ObservableObject { private func populateResourceMenus(_ newResources: [Resource]) { // If we have no favorites, then everything is a favorite - let hasAnyFavorites = newResources.contains { self.favorites.contains($0.id) } + let hasAnyFavorites = newResources.contains { model.favorites.contains($0.id) } let newFavorites = if (hasAnyFavorites) { - newResources.filter { self.favorites.contains($0.id) } + newResources.filter { model.favorites.contains($0.id) } } else { newResources } let newOthers: [Resource] = if hasAnyFavorites { - newResources.filter { !self.favorites.contains($0.id) } + newResources.filter { !model.favorites.contains($0.id) } } else { [] } - populateFavoriteResourcesMenu(newFavorites, hasAnyFavorites) + populateFavoriteResourcesMenu(newFavorites) populateOtherResourcesMenu(newOthers) } - private func populateFavoriteResourcesMenu(_ newFavorites: [Resource], _ hasAnyFavorites: Bool) { + private func populateFavoriteResourcesMenu(_ newFavorites: [Resource]) { // Update the menu in place so everything won't vanish if it's open when it updates let diff = (newFavorites).difference( from: lastShownFavorites, @@ -548,7 +546,7 @@ public final class MenuBar: NSObject, ObservableObject { resourceAddressItem.target = self subMenu.addItem(resourceAddressItem) - if favorites.contains(resource.id) { + if model.favorites.contains(resource.id) { toggleFavoriteItem.action = #selector(removeFavoriteTapped(_:)) toggleFavoriteItem.title = "Remove from favorites" toggleFavoriteItem.toolTip = "Click to remove this Resource from Favorites" @@ -643,9 +641,9 @@ public final class MenuBar: NSObject, ObservableObject { private func setFavorited(id: String, favorited: Bool) { if favorited { - favorites.add(id) + model.favorites.add(id) } else { - favorites.remove(id) + model.favorites.remove(id) } favoritesChanged() } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift index ee236a1a3..929e313c8 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift @@ -9,6 +9,7 @@ import SwiftUI #if os(iOS) struct ResourceView: View { + @ObservedObject var model: SessionViewModel var resource: Resource @Environment(\.openURL) var openURL @@ -68,6 +69,28 @@ struct ResourceView: View { } } } + + if(model.favorites.ids.contains(resource.id)) { + Button(action: { + model.favorites.remove(resource.id) + }) { + HStack { + Image(systemName: "star") + Text("Remove from favorites") + Spacer() + } + } + } else { + Button(action: { + model.favorites.add(resource.id) + }) { + HStack { + Image(systemName: "star.fill") + Text("Add to favorites") + Spacer() + } + } + } } if let site = resource.sites.first { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index 544b8b2a3..f0deb4f2d 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift @@ -12,17 +12,26 @@ import SwiftUI @MainActor public final class SessionViewModel: ObservableObject { @Published private(set) var actorName: String? = nil + @Published private(set) var favorites: Favorites @Published private(set) var resources: ResourceList = ResourceList.loading @Published private(set) var status: NEVPNStatus? = nil - let store: Store private var cancellables: Set = [] - public init(store: Store) { + public init(favorites: Favorites, store: Store) { + self.favorites = favorites self.store = store + favorites.$ids + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] ids in + guard let self = self else { return } + self.objectWillChange.send() + }) + .store(in: &cancellables) + store.$actorName .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] actorName in @@ -72,24 +81,27 @@ struct SessionView: View { if resources.isEmpty { Text("No Resources. Contact your admin to be granted access.") } else { - List(resources) { resource in - HStack { - NavigationLink { ResourceView(resource: resource) } - label: { - HStack { - Text(resource.name) - if resource.canBeDisabled { - Spacer() - Toggle("Enabled", isOn: Binding( - get: { model.isResourceEnabled(resource.id) }, - set: { newValue in - model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue) - } - )).labelsHidden() - } - } - } - .navigationTitle("All Resources") + List { + let hasAnyFavorites = resources.contains { model.favorites.contains($0.id) } + if hasAnyFavorites { + Section("Favorites") { + ResourceSection( + resources: resources.filter { model.favorites.contains($0.id) }, + model: model + ) + } + + Section("Other Resources") { + ResourceSection( + resources: resources.filter { !model.favorites.contains($0.id) }, + model: model + ) + } + } else { + ResourceSection( + resources: resources, + model: model + ) } } .listStyle(GroupedListStyle()) @@ -112,4 +124,32 @@ struct SessionView: View { } } } + +struct ResourceSection: View { + let resources: [Resource] + @ObservedObject var model: SessionViewModel + + var body: some View { + ForEach(resources) { resource in + HStack { + NavigationLink { ResourceView(model: model, resource: resource) } + label: { + HStack { + Text(resource.name) + if resource.canBeDisabled { + Spacer() + Toggle("Enabled", isOn: Binding( + get: { model.isResourceEnabled(resource.id) }, + set: { newValue in + model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue) + } + )).labelsHidden() + } + } + } + .navigationTitle("All Resources") + } + } + } +} #endif diff --git a/website/src/components/Changelog/Apple.tsx b/website/src/components/Changelog/Apple.tsx index a88154ed0..42d460cfb 100644 --- a/website/src/components/Changelog/Apple.tsx +++ b/website/src/components/Changelog/Apple.tsx @@ -15,7 +15,7 @@ export default function Apple() { Implements glob-like matching of domains for DNS resources. - macOS: Adds the ability to mark Resources as favorites. + Adds the ability to mark Resources as favorites. */}