Anonymous browsing improvements

This commit is contained in:
Justin Mazzocchi 2020-09-08 22:40:49 -07:00
parent 8229eecc3a
commit 335a006f45
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
18 changed files with 192 additions and 107 deletions

View file

@ -87,7 +87,9 @@ public extension ContentDatabase {
try Timeline.list(list).save($0) 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() .ignoreOutput()
.eraseToAnyPublisher() .eraseToAnyPublisher()
@ -155,7 +157,7 @@ public extension ContentDatabase {
} }
func listsObservation() -> AnyPublisher<[Timeline], Error> { 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) .order(Column("listTitle").collating(.localizedCaseInsensitiveCompare).asc)
.fetchAll) .fetchAll)
.removeDuplicates() .removeDuplicates()

View file

@ -6,6 +6,7 @@ import Mastodon
public struct Identity: Codable, Hashable, Identifiable { public struct Identity: Codable, Hashable, Identifiable {
public let id: UUID public let id: UUID
public let url: URL public let url: URL
public let authenticated: Bool
public let lastUsedAt: Date public let lastUsedAt: Date
public let preferences: Identity.Preferences public let preferences: Identity.Preferences
public let instance: Identity.Instance? public let instance: Identity.Instance?

View file

@ -8,6 +8,7 @@ extension Identity {
self.init( self.init(
id: result.identity.id, id: result.identity.id,
url: result.identity.url, url: result.identity.url,
authenticated: result.identity.authenticated,
lastUsedAt: result.identity.lastUsedAt, lastUsedAt: result.identity.lastUsedAt,
preferences: result.identity.preferences, preferences: result.identity.preferences,
instance: result.instance, instance: result.instance,

View file

@ -33,11 +33,12 @@ public struct IdentityDatabase {
} }
public extension 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( databaseQueue.writePublisher(
updates: IdentityRecord( updates: IdentityRecord(
id: id, id: id,
url: url, url: url,
authenticated: authenticated,
lastUsedAt: Date(), lastUsedAt: Date(),
preferences: Identity.Preferences(), preferences: Identity.Preferences(),
instanceURI: nil, instanceURI: nil,
@ -161,7 +162,7 @@ public extension IdentityDatabase {
func identitiesObservation() -> AnyPublisher<[Identity], Error> { func identitiesObservation() -> AnyPublisher<[Identity], Error> {
ValueObservation.tracking(Self.identitiesRequest().fetchAll) ValueObservation.tracking(Self.identitiesRequest().fetchAll)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseQueue, scheduling: .immediate) .publisher(in: databaseQueue)
.map { $0.map(Identity.init(result:)) } .map { $0.map(Identity.init(result:)) }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -173,7 +174,7 @@ public extension IdentityDatabase {
.limit(9) .limit(9)
.fetchAll) .fetchAll)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseQueue, scheduling: .immediate) .publisher(in: databaseQueue)
.map { $0.map(Identity.init(result:)) } .map { $0.map(Identity.init(result:)) }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -230,6 +231,7 @@ private extension IdentityDatabase {
try db.create(table: "identityRecord", ifNotExists: true) { t in try db.create(table: "identityRecord", ifNotExists: true) { t in
t.column("id", .text).notNull().primaryKey(onConflict: .replace) t.column("id", .text).notNull().primaryKey(onConflict: .replace)
t.column("url", .text).notNull() t.column("url", .text).notNull()
t.column("authenticated", .boolean).notNull()
t.column("lastUsedAt", .datetime).notNull() t.column("lastUsedAt", .datetime).notNull()
t.column("instanceURI", .text) t.column("instanceURI", .text)
.indexed() .indexed()

View file

@ -7,6 +7,7 @@ import Mastodon
struct IdentityRecord: Codable, Hashable, FetchableRecord, PersistableRecord { struct IdentityRecord: Codable, Hashable, FetchableRecord, PersistableRecord {
let id: UUID let id: UUID
let url: URL let url: URL
let authenticated: Bool
let lastUsedAt: Date let lastUsedAt: Date
let preferences: Identity.Preferences let preferences: Identity.Preferences
let instanceURI: String? let instanceURI: String?

View file

@ -8,6 +8,8 @@
"secondary-navigation.manage-accounts" = "Manage Accounts"; "secondary-navigation.manage-accounts" = "Manage Accounts";
"secondary-navigation.lists" = "Lists"; "secondary-navigation.lists" = "Lists";
"secondary-navigation.preferences" = "Preferences"; "secondary-navigation.preferences" = "Preferences";
"identities.accounts" = "Accounts";
"identities.browsing-anonymously" = "Browsing Anonymously";
"lists.new-list-title" = "New List Title"; "lists.new-list-title" = "New List Title";
"preferences" = "Preferences"; "preferences" = "Preferences";
"preferences.posting-reading" = "Posting and Reading"; "preferences.posting-reading" = "Posting and Reading";

View file

@ -11,7 +11,8 @@ public enum Timeline: Hashable {
} }
public extension Timeline { 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 { extension Timeline: Identifiable {

View file

@ -35,7 +35,7 @@ public extension Secrets {
} }
} }
enum SecretsError: Error { public enum SecretsError: Error {
case itemAbsent case itemAbsent
} }
@ -89,15 +89,19 @@ public extension Secrets {
return "x'\(passphraseData.base16EncodedString(options: [.uppercase]))'" return "x'\(passphraseData.base16EncodedString(options: [.uppercase]))'"
} }
func deleteAllItems() throws { func deleteAllItems() {
for item in Secrets.Item.allCases { for item in Secrets.Item.allCases {
switch item.kind { do {
case .genericPassword: switch item.kind {
try keychain.deleteGenericPassword( case .genericPassword:
account: scopedKey(item: item), try keychain.deleteGenericPassword(
service: Self.keychainServiceName) account: scopedKey(item: item),
case .key: service: Self.keychainServiceName)
try keychain.deleteKey(applicationTag: scopedKey(item: item)) case .key:
try keychain.deleteKey(applicationTag: scopedKey(item: item))
}
} catch {
// no-op
} }
} }
} }

View file

@ -32,45 +32,61 @@ public extension AllIdentitiesService {
try IdentityService(id: id, database: database, environment: environment) try IdentityService(id: id, database: database, environment: environment)
} }
func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Never, Error> { func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
database.createIdentity(id: id, url: instanceURL) let secrets = Secrets(identityID: id, keychain: environment.keychain)
}
func authorizeAndCreateIdentity(id: UUID, url: URL) -> AnyPublisher<Never, Error> { do {
AuthenticationService(url: url, environment: environment) try secrets.setInstanceURL(url)
.authenticate() } catch {
.tryMap { return Fail(error: error).eraseToAnyPublisher()
let secrets = Secrets(identityID: id, keychain: environment.keychain) }
try secrets.setInstanceURL(url) let createIdentityPublisher = database.createIdentity(
try secrets.setClientID($0.clientId) id: id,
try secrets.setClientSecret($0.clientSecret) url: url,
try secrets.setAccessToken($1.accessToken) authenticated: authenticated)
}
.flatMap { database.createIdentity(id: id, url: url) }
.ignoreOutput() .ignoreOutput()
.eraseToAnyPublisher() .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 { createIdentityPublisher }
.eraseToAnyPublisher()
} else {
return createIdentityPublisher
}
} }
func deleteIdentity(_ identity: Identity) -> AnyPublisher<Never, Error> { func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> {
let secrets = Secrets(identityID: identity.id, keychain: environment.keychain) database.deleteIdentity(id: id)
let mastodonAPIClient = MastodonAPIClient(session: environment.session, instanceURL: identity.url) .collect()
.tryMap { _ -> AnyPublisher<Never, Error> in
try ContentDatabase.delete(forIdentityID: id)
return database.deleteIdentity(id: identity.id) let secrets = Secrets(identityID: id, keychain: environment.keychain)
.collect()
.tryMap { _ in defer { secrets.deleteAllItems() }
DeletionEndpoint.oauthRevoke(
token: try secrets.getAccessToken(), do {
clientID: try secrets.getClientID(), return MastodonAPIClient(
clientSecret: try secrets.getClientSecret()) session: environment.session,
instanceURL: try secrets.getInstanceURL())
.request(DeletionEndpoint.oauthRevoke(
token: try secrets.getAccessToken(),
clientID: try secrets.getClientID(),
clientSecret: try secrets.getClientSecret()))
.ignoreOutput()
.eraseToAnyPublisher()
} catch {
return Empty().eraseToAnyPublisher()
}
} }
.flatMap(mastodonAPIClient.request) .flatMap { $0 }
.collect()
.tryMap { _ in
try secrets.deleteAllItems()
try ContentDatabase.delete(forIdentityID: identity.id)
}
.ignoreOutput()
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View file

@ -25,7 +25,7 @@ let db: IdentityDatabase = {
try! secrets.setInstanceURL(url) try! secrets.setInstanceURL(url)
try! secrets.setAccessToken(UUID().uuidString) try! secrets.setAccessToken(UUID().uuidString)
_ = db.createIdentity(id: id, url: url) _ = db.createIdentity(id: id, url: url, authenticated: true)
.receive(on: ImmediateScheduler.shared) .receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in } .sink { _ in } receiveValue: { _ in }

View file

@ -33,7 +33,7 @@ public extension AddIdentityViewModel {
return return
} }
allIdentitiesService.authorizeAndCreateIdentity(id: identityID, url: instanceURL) allIdentitiesService.createIdentity(id: identityID, url: instanceURL, authenticated: true)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.catch { [weak self] error -> Empty<Never, Never> in .catch { [weak self] error -> Empty<Never, Never> in
if case AuthenticationError.canceled = error { if case AuthenticationError.canceled = error {
@ -70,7 +70,7 @@ public extension AddIdentityViewModel {
} }
// TODO: Ensure instance has not disabled public preview // 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) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { [weak self] in .sink { [weak self] in
guard let self = self, case .finished = $0 else { return } guard let self = self, case .finished = $0 else { return }

View file

@ -6,7 +6,8 @@ import ServiceLayer
public final class IdentitiesViewModel: ObservableObject { public final class IdentitiesViewModel: ObservableObject {
public let currentIdentityID: UUID public let currentIdentityID: UUID
@Published public var identities = [Identity]() @Published public var authenticated = [Identity]()
@Published public var unauthenticated = [Identity]()
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
private let identification: Identification private let identification: Identification
@ -16,8 +17,13 @@ public final class IdentitiesViewModel: ObservableObject {
self.identification = identification self.identification = identification
currentIdentityID = identification.identity.id currentIdentityID = identification.identity.id
identification.service.identitiesObservation() let observation = identification.service.identitiesObservation()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .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)
} }
} }

View file

@ -70,8 +70,8 @@ public extension RootViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
func deleteIdentity(_ identity: Identity) { func deleteIdentity(id: UUID) {
allIdentitiesService.deleteIdentity(identity) allIdentitiesService.deleteIdentity(id: id)
.sink { _ in } receiveValue: { _ in } .sink { _ in } receiveValue: { _ in }
.store(in: &cancellables) .store(in: &cancellables)
} }

View file

@ -8,8 +8,8 @@ import ServiceLayer
public final class TabNavigationViewModel: ObservableObject { public final class TabNavigationViewModel: ObservableObject {
@Published public private(set) var identity: Identity @Published public private(set) var identity: Identity
@Published public private(set) var recentIdentities = [Identity]() @Published public private(set) var recentIdentities = [Identity]()
@Published public var timeline = Timeline.home @Published public var timeline: Timeline
@Published public private(set) var timelinesAndLists = Timeline.nonLists @Published public private(set) var timelinesAndLists: [Timeline]
@Published public var presentingSecondaryNavigation = false @Published public var presentingSecondaryNavigation = false
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public var selectedTab: Tab? = .timelines public var selectedTab: Tab? = .timelines
@ -20,20 +20,34 @@ public final class TabNavigationViewModel: ObservableObject {
public init(identification: Identification) { public init(identification: Identification) {
self.identification = identification self.identification = identification
identity = identification.identity 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() identification.service.recentIdentitiesObservation()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$recentIdentities) .assign(to: &$recentIdentities)
identification.service.listsObservation() if identification.service.isAuthorized {
.map { Timeline.nonLists + $0 } identification.service.listsObservation()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .map { Timeline.authenticatedDefaults + $0 }
.assign(to: &$timelinesAndLists) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$timelinesAndLists)
}
} }
} }
public extension TabNavigationViewModel { public extension TabNavigationViewModel {
var tabs: [Tab] {
if identification.service.isAuthorized {
return Tab.allCases
} else {
return [.timelines, .explore]
}
}
var timelineSubtitle: String { var timelineSubtitle: String {
switch timeline { switch timeline {
case .home, .list: 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() { func refreshIdentity() {
if identification.service.isAuthorized { if identification.service.isAuthorized {
identification.service.verifyCredentials() identification.service.verifyCredentials()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in } .sink { _ in }
.store(in: &cancellables) .store(in: &cancellables)
identification.service.refreshLists() identification.service.refreshLists()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in } .sink { _ in }
.store(in: &cancellables) .store(in: &cancellables)
identification.service.refreshFilters() identification.service.refreshFilters()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in } .sink { _ in }
@ -92,7 +94,7 @@ public extension TabNavigationViewModel {
public extension TabNavigationViewModel { public extension TabNavigationViewModel {
enum Tab: CaseIterable { enum Tab: CaseIterable {
case timelines case timelines
case search case explore
case notifications case notifications
case messages case messages
} }

View file

@ -19,11 +19,11 @@ struct AddIdentityView: View {
} else { } else {
Button("add-identity.log-in", Button("add-identity.log-in",
action: viewModel.logInTapped) action: viewModel.logInTapped)
Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped)
.frame(maxWidth: .infinity, alignment: .center)
} }
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped)
.frame(maxWidth: .infinity, alignment: .center)
} }
.alertItem($viewModel.alertItem) .alertItem($viewModel.alertItem)
.onReceive(viewModel.addedIdentityID) { id in .onReceive(viewModel.addedIdentityID) { id in

View file

@ -1,6 +1,7 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import KingfisherSwiftUI import KingfisherSwiftUI
import struct ServiceLayer.Identity
import SwiftUI import SwiftUI
import ViewModels import ViewModels
@ -18,9 +19,26 @@ struct IdentitiesView: View {
Label("add", systemImage: "plus.circle") 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 { List {
ForEach(viewModel.identities) { identity in ForEach(identities) { identity in
Button { Button {
withAnimation { withAnimation {
rootViewModel.newIdentitySelected(id: identity.id) rootViewModel.newIdentitySelected(id: identity.id)
@ -31,15 +49,26 @@ struct IdentitiesView: View {
options: .downsampled(dimension: 40, scaleFactor: displayScale)) options: .downsampled(dimension: 40, scaleFactor: displayScale))
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Spacer() Spacer()
if let account = identity.account { if identity.authenticated {
CustomEmojiText( if let account = identity.account {
text: account.displayName, CustomEmojiText(
emoji: account.emojis, text: account.displayName,
textStyle: .headline) emoji: account.emojis,
textStyle: .headline)
}
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)
}
} }
Text(identity.handle)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer() Spacer()
} }
Spacer() Spacer()
@ -54,16 +83,11 @@ struct IdentitiesView: View {
.onDelete { .onDelete {
guard let index = $0.first else { return } guard let index = $0.first else { return }
rootViewModel.deleteIdentity(viewModel.identities[index]) rootViewModel.deleteIdentity(id: identities[index].id)
} }
} }
} }
} }
.toolbar {
ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) {
EditButton()
}
}
} }
} }

View file

@ -21,17 +21,30 @@ struct SecondaryNavigationView: View {
KFImage(tabNavigationViewModel.identity.image, KFImage(tabNavigationViewModel.identity.image,
options: .downsampled(dimension: 50, scaleFactor: displayScale)) options: .downsampled(dimension: 50, scaleFactor: displayScale))
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let account = tabNavigationViewModel.identity.account { if tabNavigationViewModel.identity.authenticated {
CustomEmojiText( if let account = tabNavigationViewModel.identity.account {
text: account.displayName, CustomEmojiText(
emoji: account.emojis, text: account.displayName,
textStyle: .headline) emoji: account.emojis,
textStyle: .headline)
}
Text(tabNavigationViewModel.identity.handle)
.font(.subheadline)
.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)
}
} }
Text(tabNavigationViewModel.identity.handle)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.5)
Spacer() Spacer()
Text("secondary-navigation.manage-accounts") Text("secondary-navigation.manage-accounts")
.font(.subheadline) .font(.subheadline)

View file

@ -12,7 +12,7 @@ struct TabNavigationView: View {
var body: some View { var body: some View {
TabView(selection: $viewModel.selectedTab) { TabView(selection: $viewModel.selectedTab) {
ForEach(TabNavigationViewModel.Tab.allCases) { tab in ForEach(viewModel.tabs) { tab in
NavigationView { NavigationView {
view(tab: tab) view(tab: tab)
} }
@ -65,11 +65,11 @@ private extension TabNavigationView {
viewModel.timeline = timeline viewModel.timeline = timeline
} label: { } label: {
Label(timeline.title, Label(timeline.title,
systemImage: viewModel.systemImageName(timeline: timeline)) systemImage: timeline.systemImageName)
} }
} }
} label: { } label: {
Image(systemName: viewModel.systemImageName(timeline: viewModel.timeline)) Image(systemName: viewModel.timeline.systemImageName)
}) })
default: Text(tab.title) default: Text(tab.title)
} }
@ -118,13 +118,23 @@ private extension Timeline {
return "#" + tag 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 { extension TabNavigationViewModel.Tab {
var title: String { var title: String {
switch self { switch self {
case .timelines: return "Timelines" case .timelines: return "Timelines"
case .search: return "Search" case .explore: return "Explore"
case .notifications: return "Notifications" case .notifications: return "Notifications"
case .messages: return "Messages" case .messages: return "Messages"
} }
@ -133,7 +143,7 @@ extension TabNavigationViewModel.Tab {
var systemImageName: String { var systemImageName: String {
switch self { switch self {
case .timelines: return "newspaper" case .timelines: return "newspaper"
case .search: return "magnifyingglass" case .explore: return "magnifyingglass"
case .notifications: return "bell" case .notifications: return "bell"
case .messages: return "envelope" case .messages: return "envelope"
} }