feat(client/ios): favorites menu (#6298)

![Screenshot 2024-08-14 at 16 08
14](https://github.com/user-attachments/assets/7d962b32-ee39-42d8-af4a-5f1287bb4b58)
![Screenshot 2024-08-14 at 16 36
10](https://github.com/user-attachments/assets/95876d86-1eb7-4e7f-87ca-6dbd610adddd)

---------

Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
Co-authored-by: Jamil <jamilbk@users.noreply.github.com>
This commit is contained in:
Reactor Scram
2024-08-20 12:57:57 -05:00
committed by GitHub
parent 027fe678cb
commit 7593dba7fb
7 changed files with 106 additions and 42 deletions

View File

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

View File

@@ -2,9 +2,11 @@ import Foundation
public class Favorites: ObservableObject {
private static let key = "favoriteResourceIDs"
@Published private(set) var ids: Set<String> = Favorites.load()
@Published private(set) var ids: Set<String>
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<String>) {
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<String> {

View File

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

View File

@@ -24,7 +24,6 @@ public final class MenuBar: NSObject, ObservableObject {
private var lastShownOthers: [Resource] = []
private var cancellables: Set<AnyCancellable> = []
@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()
}

View File

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

View File

@@ -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<AnyCancellable> = []
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<Bool>(
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<Bool>(
get: { model.isResourceEnabled(resource.id) },
set: { newValue in
model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue)
}
)).labelsHidden()
}
}
}
.navigationTitle("All Resources")
}
}
}
}
#endif

View File

@@ -15,7 +15,7 @@ export default function Apple() {
Implements glob-like matching of domains for DNS resources.
</ChangeItem>
<ChangeItem pull="6186">
macOS: Adds the ability to mark Resources as favorites.
Adds the ability to mark Resources as favorites.
</ChangeItem>
</ul>
</Entry> */}