mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-22 00:01:00 +00:00
Anonymous browsing improvements
This commit is contained in:
parent
8229eecc3a
commit
335a006f45
18 changed files with 192 additions and 107 deletions
|
@ -87,7 +87,9 @@ public extension ContentDatabase {
|
|||
try Timeline.list(list).save($0)
|
||||
}
|
||||
|
||||
try Timeline.filter(!(Timeline.nonLists.map(\.id) + lists.map(\.id)).contains(Column("id"))).deleteAll($0)
|
||||
try Timeline
|
||||
.filter(!(Timeline.authenticatedDefaults.map(\.id) + lists.map(\.id)).contains(Column("id")))
|
||||
.deleteAll($0)
|
||||
}
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
|
@ -155,7 +157,7 @@ public extension ContentDatabase {
|
|||
}
|
||||
|
||||
func listsObservation() -> AnyPublisher<[Timeline], Error> {
|
||||
ValueObservation.tracking(Timeline.filter(!Timeline.nonLists.map(\.id).contains(Column("id")))
|
||||
ValueObservation.tracking(Timeline.filter(!Timeline.authenticatedDefaults.map(\.id).contains(Column("id")))
|
||||
.order(Column("listTitle").collating(.localizedCaseInsensitiveCompare).asc)
|
||||
.fetchAll)
|
||||
.removeDuplicates()
|
||||
|
|
|
@ -6,6 +6,7 @@ import Mastodon
|
|||
public struct Identity: Codable, Hashable, Identifiable {
|
||||
public let id: UUID
|
||||
public let url: URL
|
||||
public let authenticated: Bool
|
||||
public let lastUsedAt: Date
|
||||
public let preferences: Identity.Preferences
|
||||
public let instance: Identity.Instance?
|
||||
|
|
|
@ -8,6 +8,7 @@ extension Identity {
|
|||
self.init(
|
||||
id: result.identity.id,
|
||||
url: result.identity.url,
|
||||
authenticated: result.identity.authenticated,
|
||||
lastUsedAt: result.identity.lastUsedAt,
|
||||
preferences: result.identity.preferences,
|
||||
instance: result.instance,
|
||||
|
|
|
@ -33,11 +33,12 @@ public struct IdentityDatabase {
|
|||
}
|
||||
|
||||
public extension IdentityDatabase {
|
||||
func createIdentity(id: UUID, url: URL) -> AnyPublisher<Never, Error> {
|
||||
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
|
||||
databaseQueue.writePublisher(
|
||||
updates: IdentityRecord(
|
||||
id: id,
|
||||
url: url,
|
||||
authenticated: authenticated,
|
||||
lastUsedAt: Date(),
|
||||
preferences: Identity.Preferences(),
|
||||
instanceURI: nil,
|
||||
|
@ -161,7 +162,7 @@ public extension IdentityDatabase {
|
|||
func identitiesObservation() -> AnyPublisher<[Identity], Error> {
|
||||
ValueObservation.tracking(Self.identitiesRequest().fetchAll)
|
||||
.removeDuplicates()
|
||||
.publisher(in: databaseQueue, scheduling: .immediate)
|
||||
.publisher(in: databaseQueue)
|
||||
.map { $0.map(Identity.init(result:)) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
@ -173,7 +174,7 @@ public extension IdentityDatabase {
|
|||
.limit(9)
|
||||
.fetchAll)
|
||||
.removeDuplicates()
|
||||
.publisher(in: databaseQueue, scheduling: .immediate)
|
||||
.publisher(in: databaseQueue)
|
||||
.map { $0.map(Identity.init(result:)) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
@ -230,6 +231,7 @@ private extension IdentityDatabase {
|
|||
try db.create(table: "identityRecord", ifNotExists: true) { t in
|
||||
t.column("id", .text).notNull().primaryKey(onConflict: .replace)
|
||||
t.column("url", .text).notNull()
|
||||
t.column("authenticated", .boolean).notNull()
|
||||
t.column("lastUsedAt", .datetime).notNull()
|
||||
t.column("instanceURI", .text)
|
||||
.indexed()
|
||||
|
|
|
@ -7,6 +7,7 @@ import Mastodon
|
|||
struct IdentityRecord: Codable, Hashable, FetchableRecord, PersistableRecord {
|
||||
let id: UUID
|
||||
let url: URL
|
||||
let authenticated: Bool
|
||||
let lastUsedAt: Date
|
||||
let preferences: Identity.Preferences
|
||||
let instanceURI: String?
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
"secondary-navigation.manage-accounts" = "Manage Accounts";
|
||||
"secondary-navigation.lists" = "Lists";
|
||||
"secondary-navigation.preferences" = "Preferences";
|
||||
"identities.accounts" = "Accounts";
|
||||
"identities.browsing-anonymously" = "Browsing Anonymously";
|
||||
"lists.new-list-title" = "New List Title";
|
||||
"preferences" = "Preferences";
|
||||
"preferences.posting-reading" = "Posting and Reading";
|
||||
|
|
|
@ -11,7 +11,8 @@ public enum Timeline: Hashable {
|
|||
}
|
||||
|
||||
public extension Timeline {
|
||||
static let nonLists: [Timeline] = [.home, .local, .federated]
|
||||
static let unauthenticatedDefaults: [Timeline] = [.local, .federated]
|
||||
static let authenticatedDefaults: [Timeline] = [.home, .local, .federated]
|
||||
}
|
||||
|
||||
extension Timeline: Identifiable {
|
||||
|
|
|
@ -35,7 +35,7 @@ public extension Secrets {
|
|||
}
|
||||
}
|
||||
|
||||
enum SecretsError: Error {
|
||||
public enum SecretsError: Error {
|
||||
case itemAbsent
|
||||
}
|
||||
|
||||
|
@ -89,8 +89,9 @@ public extension Secrets {
|
|||
return "x'\(passphraseData.base16EncodedString(options: [.uppercase]))'"
|
||||
}
|
||||
|
||||
func deleteAllItems() throws {
|
||||
func deleteAllItems() {
|
||||
for item in Secrets.Item.allCases {
|
||||
do {
|
||||
switch item.kind {
|
||||
case .genericPassword:
|
||||
try keychain.deleteGenericPassword(
|
||||
|
@ -99,6 +100,9 @@ public extension Secrets {
|
|||
case .key:
|
||||
try keychain.deleteKey(applicationTag: scopedKey(item: item))
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -32,46 +32,62 @@ public extension AllIdentitiesService {
|
|||
try IdentityService(id: id, database: database, environment: environment)
|
||||
}
|
||||
|
||||
func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Never, Error> {
|
||||
database.createIdentity(id: id, url: instanceURL)
|
||||
}
|
||||
|
||||
func authorizeAndCreateIdentity(id: UUID, url: URL) -> AnyPublisher<Never, Error> {
|
||||
AuthenticationService(url: url, environment: environment)
|
||||
.authenticate()
|
||||
.tryMap {
|
||||
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
|
||||
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
||||
|
||||
do {
|
||||
try secrets.setInstanceURL(url)
|
||||
} catch {
|
||||
return Fail(error: error).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let createIdentityPublisher = database.createIdentity(
|
||||
id: id,
|
||||
url: url,
|
||||
authenticated: authenticated)
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
if authenticated {
|
||||
return AuthenticationService(url: url, environment: environment).authenticate()
|
||||
.tryMap {
|
||||
try secrets.setClientID($0.clientId)
|
||||
try secrets.setClientSecret($0.clientSecret)
|
||||
try secrets.setAccessToken($1.accessToken)
|
||||
}
|
||||
.flatMap { database.createIdentity(id: id, url: url) }
|
||||
.ignoreOutput()
|
||||
.flatMap { createIdentityPublisher }
|
||||
.eraseToAnyPublisher()
|
||||
} else {
|
||||
return createIdentityPublisher
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIdentity(_ identity: Identity) -> AnyPublisher<Never, Error> {
|
||||
let secrets = Secrets(identityID: identity.id, keychain: environment.keychain)
|
||||
let mastodonAPIClient = MastodonAPIClient(session: environment.session, instanceURL: identity.url)
|
||||
|
||||
return database.deleteIdentity(id: identity.id)
|
||||
func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> {
|
||||
database.deleteIdentity(id: id)
|
||||
.collect()
|
||||
.tryMap { _ in
|
||||
DeletionEndpoint.oauthRevoke(
|
||||
.tryMap { _ -> AnyPublisher<Never, Error> in
|
||||
try ContentDatabase.delete(forIdentityID: id)
|
||||
|
||||
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
||||
|
||||
defer { secrets.deleteAllItems() }
|
||||
|
||||
do {
|
||||
return MastodonAPIClient(
|
||||
session: environment.session,
|
||||
instanceURL: try secrets.getInstanceURL())
|
||||
.request(DeletionEndpoint.oauthRevoke(
|
||||
token: try secrets.getAccessToken(),
|
||||
clientID: try secrets.getClientID(),
|
||||
clientSecret: try secrets.getClientSecret())
|
||||
}
|
||||
.flatMap(mastodonAPIClient.request)
|
||||
.collect()
|
||||
.tryMap { _ in
|
||||
try secrets.deleteAllItems()
|
||||
try ContentDatabase.delete(forIdentityID: identity.id)
|
||||
}
|
||||
clientSecret: try secrets.getClientSecret()))
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
} catch {
|
||||
return Empty().eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
.flatMap { $0 }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func updatePushSubscriptions(deviceToken: Data) -> AnyPublisher<Never, Error> {
|
||||
|
|
|
@ -25,7 +25,7 @@ let db: IdentityDatabase = {
|
|||
try! secrets.setInstanceURL(url)
|
||||
try! secrets.setAccessToken(UUID().uuidString)
|
||||
|
||||
_ = db.createIdentity(id: id, url: url)
|
||||
_ = db.createIdentity(id: id, url: url, authenticated: true)
|
||||
.receive(on: ImmediateScheduler.shared)
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ public extension AddIdentityViewModel {
|
|||
return
|
||||
}
|
||||
|
||||
allIdentitiesService.authorizeAndCreateIdentity(id: identityID, url: instanceURL)
|
||||
allIdentitiesService.createIdentity(id: identityID, url: instanceURL, authenticated: true)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.catch { [weak self] error -> Empty<Never, Never> in
|
||||
if case AuthenticationError.canceled = error {
|
||||
|
@ -70,7 +70,7 @@ public extension AddIdentityViewModel {
|
|||
}
|
||||
|
||||
// TODO: Ensure instance has not disabled public preview
|
||||
allIdentitiesService.createIdentity(id: identityID, instanceURL: instanceURL)
|
||||
allIdentitiesService.createIdentity(id: identityID, url: instanceURL, authenticated: false)
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.sink { [weak self] in
|
||||
guard let self = self, case .finished = $0 else { return }
|
||||
|
|
|
@ -6,7 +6,8 @@ import ServiceLayer
|
|||
|
||||
public final class IdentitiesViewModel: ObservableObject {
|
||||
public let currentIdentityID: UUID
|
||||
@Published public var identities = [Identity]()
|
||||
@Published public var authenticated = [Identity]()
|
||||
@Published public var unauthenticated = [Identity]()
|
||||
@Published public var alertItem: AlertItem?
|
||||
|
||||
private let identification: Identification
|
||||
|
@ -16,8 +17,13 @@ public final class IdentitiesViewModel: ObservableObject {
|
|||
self.identification = identification
|
||||
currentIdentityID = identification.identity.id
|
||||
|
||||
identification.service.identitiesObservation()
|
||||
let observation = identification.service.identitiesObservation()
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.assign(to: &$identities)
|
||||
.share()
|
||||
|
||||
observation.map { $0.filter { $0.authenticated } }
|
||||
.assign(to: &$authenticated)
|
||||
observation.map { $0.filter { !$0.authenticated } }
|
||||
.assign(to: &$unauthenticated)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,8 +70,8 @@ public extension RootViewModel {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func deleteIdentity(_ identity: Identity) {
|
||||
allIdentitiesService.deleteIdentity(identity)
|
||||
func deleteIdentity(id: UUID) {
|
||||
allIdentitiesService.deleteIdentity(id: id)
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
|
|
@ -8,8 +8,8 @@ import ServiceLayer
|
|||
public final class TabNavigationViewModel: ObservableObject {
|
||||
@Published public private(set) var identity: Identity
|
||||
@Published public private(set) var recentIdentities = [Identity]()
|
||||
@Published public var timeline = Timeline.home
|
||||
@Published public private(set) var timelinesAndLists = Timeline.nonLists
|
||||
@Published public var timeline: Timeline
|
||||
@Published public private(set) var timelinesAndLists: [Timeline]
|
||||
@Published public var presentingSecondaryNavigation = false
|
||||
@Published public var alertItem: AlertItem?
|
||||
public var selectedTab: Tab? = .timelines
|
||||
|
@ -20,20 +20,34 @@ public final class TabNavigationViewModel: ObservableObject {
|
|||
public init(identification: Identification) {
|
||||
self.identification = identification
|
||||
identity = identification.identity
|
||||
identification.$identity.dropFirst().assign(to: &$identity)
|
||||
timeline = identification.service.isAuthorized ? .home : .local
|
||||
timelinesAndLists = identification.service.isAuthorized
|
||||
? Timeline.authenticatedDefaults
|
||||
: Timeline.unauthenticatedDefaults
|
||||
|
||||
identification.$identity.dropFirst().assign(to: &$identity)
|
||||
identification.service.recentIdentitiesObservation()
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.assign(to: &$recentIdentities)
|
||||
|
||||
if identification.service.isAuthorized {
|
||||
identification.service.listsObservation()
|
||||
.map { Timeline.nonLists + $0 }
|
||||
.map { Timeline.authenticatedDefaults + $0 }
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.assign(to: &$timelinesAndLists)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension TabNavigationViewModel {
|
||||
var tabs: [Tab] {
|
||||
if identification.service.isAuthorized {
|
||||
return Tab.allCases
|
||||
} else {
|
||||
return [.timelines, .explore]
|
||||
}
|
||||
}
|
||||
|
||||
var timelineSubtitle: String {
|
||||
switch timeline {
|
||||
case .home, .list:
|
||||
|
@ -43,28 +57,16 @@ public extension TabNavigationViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
func systemImageName(timeline: Timeline) -> String {
|
||||
switch timeline {
|
||||
case .home: return "house"
|
||||
case .local: return "person.3"
|
||||
case .federated: return "globe"
|
||||
case .list: return "scroll"
|
||||
case .tag: return "number"
|
||||
}
|
||||
}
|
||||
|
||||
func refreshIdentity() {
|
||||
if identification.service.isAuthorized {
|
||||
identification.service.verifyCredentials()
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.sink { _ in }
|
||||
.store(in: &cancellables)
|
||||
|
||||
identification.service.refreshLists()
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.sink { _ in }
|
||||
.store(in: &cancellables)
|
||||
|
||||
identification.service.refreshFilters()
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.sink { _ in }
|
||||
|
@ -92,7 +94,7 @@ public extension TabNavigationViewModel {
|
|||
public extension TabNavigationViewModel {
|
||||
enum Tab: CaseIterable {
|
||||
case timelines
|
||||
case search
|
||||
case explore
|
||||
case notifications
|
||||
case messages
|
||||
}
|
||||
|
|
|
@ -19,12 +19,12 @@ struct AddIdentityView: View {
|
|||
} else {
|
||||
Button("add-identity.log-in",
|
||||
action: viewModel.logInTapped)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.alertItem($viewModel.alertItem)
|
||||
.onReceive(viewModel.addedIdentityID) { id in
|
||||
withAnimation {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import KingfisherSwiftUI
|
||||
import struct ServiceLayer.Identity
|
||||
import SwiftUI
|
||||
import ViewModels
|
||||
|
||||
|
@ -18,9 +19,26 @@ struct IdentitiesView: View {
|
|||
Label("add", systemImage: "plus.circle")
|
||||
})
|
||||
}
|
||||
Section {
|
||||
section(title: "identities.accounts", identities: viewModel.authenticated)
|
||||
section(title: "identities.browsing-anonymously", identities: viewModel.unauthenticated)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension IdentitiesView {
|
||||
@ViewBuilder
|
||||
func section(title: LocalizedStringKey, identities: [Identity]) -> some View {
|
||||
if identities.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
Section(header: Text(title)) {
|
||||
List {
|
||||
ForEach(viewModel.identities) { identity in
|
||||
ForEach(identities) { identity in
|
||||
Button {
|
||||
withAnimation {
|
||||
rootViewModel.newIdentitySelected(id: identity.id)
|
||||
|
@ -31,6 +49,7 @@ struct IdentitiesView: View {
|
|||
options: .downsampled(dimension: 40, scaleFactor: displayScale))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Spacer()
|
||||
if identity.authenticated {
|
||||
if let account = identity.account {
|
||||
CustomEmojiText(
|
||||
text: account.displayName,
|
||||
|
@ -40,6 +59,16 @@ struct IdentitiesView: View {
|
|||
Text(identity.handle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text(identity.handle)
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
if let instance = identity.instance {
|
||||
Text(instance.uri)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
|
@ -54,16 +83,11 @@ struct IdentitiesView: View {
|
|||
.onDelete {
|
||||
guard let index = $0.first else { return }
|
||||
|
||||
rootViewModel.deleteIdentity(viewModel.identities[index])
|
||||
rootViewModel.deleteIdentity(id: identities[index].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ struct SecondaryNavigationView: View {
|
|||
KFImage(tabNavigationViewModel.identity.image,
|
||||
options: .downsampled(dimension: 50, scaleFactor: displayScale))
|
||||
VStack(alignment: .leading) {
|
||||
if tabNavigationViewModel.identity.authenticated {
|
||||
if let account = tabNavigationViewModel.identity.account {
|
||||
CustomEmojiText(
|
||||
text: account.displayName,
|
||||
|
@ -32,6 +33,18 @@ struct SecondaryNavigationView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
} else {
|
||||
Text(tabNavigationViewModel.identity.handle)
|
||||
.font(.headline)
|
||||
if let instance = tabNavigationViewModel.identity.instance {
|
||||
Text(instance.uri)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Text("secondary-navigation.manage-accounts")
|
||||
.font(.subheadline)
|
||||
|
|
|
@ -12,7 +12,7 @@ struct TabNavigationView: View {
|
|||
|
||||
var body: some View {
|
||||
TabView(selection: $viewModel.selectedTab) {
|
||||
ForEach(TabNavigationViewModel.Tab.allCases) { tab in
|
||||
ForEach(viewModel.tabs) { tab in
|
||||
NavigationView {
|
||||
view(tab: tab)
|
||||
}
|
||||
|
@ -65,11 +65,11 @@ private extension TabNavigationView {
|
|||
viewModel.timeline = timeline
|
||||
} label: {
|
||||
Label(timeline.title,
|
||||
systemImage: viewModel.systemImageName(timeline: timeline))
|
||||
systemImage: timeline.systemImageName)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: viewModel.systemImageName(timeline: viewModel.timeline))
|
||||
Image(systemName: viewModel.timeline.systemImageName)
|
||||
})
|
||||
default: Text(tab.title)
|
||||
}
|
||||
|
@ -118,13 +118,23 @@ private extension Timeline {
|
|||
return "#" + tag
|
||||
}
|
||||
}
|
||||
|
||||
var systemImageName: String {
|
||||
switch self {
|
||||
case .home: return "house"
|
||||
case .local: return "person.3"
|
||||
case .federated: return "globe"
|
||||
case .list: return "scroll"
|
||||
case .tag: return "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TabNavigationViewModel.Tab {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .timelines: return "Timelines"
|
||||
case .search: return "Search"
|
||||
case .explore: return "Explore"
|
||||
case .notifications: return "Notifications"
|
||||
case .messages: return "Messages"
|
||||
}
|
||||
|
@ -133,7 +143,7 @@ extension TabNavigationViewModel.Tab {
|
|||
var systemImageName: String {
|
||||
switch self {
|
||||
case .timelines: return "newspaper"
|
||||
case .search: return "magnifyingglass"
|
||||
case .explore: return "magnifyingglass"
|
||||
case .notifications: return "bell"
|
||||
case .messages: return "envelope"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue