Add refresh, accounts, and link actions to the instance browse menu

The three-dots menu now offers Refresh, an accounts entry (sign in when
nobody is, otherwise a submenu that switches the data context, adds a
login, or signs out the active account with the shared removal
confirmation), and Copy URL / Open in Browser on iOS and macOS.
Login and sign-out reuse InstanceLoginView, AccountSignInHandler, and
the same confirmation strings as the edit sheet's accounts section.
This commit is contained in:
Arkadiusz Fal
2026-09-17 23:59:37 +02:00
parent d742fec41a
commit 045e0ba574
2 changed files with 201 additions and 2 deletions

View File

@@ -5547,6 +5547,17 @@
},
"Initializing player..." : {
},
"instance.browse.copyURL" : {
"comment" : "Menu item to copy the browsed instance's URL",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copy URL"
}
}
}
},
"instance.browse.edit %@" : {
"comment" : "Menu item to edit the browsed instance, with its name",
@@ -5569,6 +5580,17 @@
}
}
},
"instance.browse.openInBrowser" : {
"comment" : "Menu item to open the browsed instance's URL in the browser",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open in Browser"
}
}
}
},
"instance.browse.remove %@" : {
"comment" : "Menu item to remove the browsed instance, with its name",
"localizations" : {
@@ -5590,6 +5612,17 @@
}
}
},
"instance.browse.signOut %@" : {
"comment" : "Menu item to sign out and remove an account, with its name",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sign Out %@"
}
}
}
},
"instances.type.invidious" : {
"comment" : "Instance type: Invidious",
"localizations" : {

View File

@@ -10,6 +10,7 @@ import SwiftUI
struct InstanceBrowseView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
let instance: Instance
let initialTab: BrowseTab?
@@ -54,6 +55,12 @@ struct InstanceBrowseView: View {
@State private var showViewOptions = false
@State private var showEditInstance = false
@State private var showRemoveConfirmation = false
// Account actions raised from the three-dots menu. Same shapes as the
// edit sheet's accounts section: a nil-account login target means
// "create one", and the sign-out is held until its confirmation.
@State private var loginTarget: AccountLoginTarget?
@State private var pendingSignOut: Account?
@State private var viewWidth: CGFloat = 0
@State private var watchEntriesMap: [String: WatchEntry] = [:]
@State private var hasInitializedTab = false
@@ -129,10 +136,53 @@ struct InstanceBrowseView: View {
)
}
/// Shared content of the three-dots menu: instance management on top,
/// view options below.
/// All accounts on this instance, in the accounts submenu.
private var instanceAccounts: [Account] {
appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? []
}
/// The account this screen's authenticated tabs actually use.
private var effectiveAccount: Account? {
appEnvironment?.account(forInstance: instance)
}
private func accountDisplayName(_ account: Account) -> String {
appEnvironment?.dataContextValidator.displayName(for: account) ?? instance.displayName
}
/// Shared content of the three-dots menu: refresh and accounts on top,
/// link actions and instance management below, view options last.
@ViewBuilder
private var instanceMenuContent: some View {
Button {
Task { await startContentLoad(forceRefresh: true) }
} label: {
Label(String(localized: "common.refresh"), systemImage: "arrow.clockwise")
}
if instance.supportsAuthentication {
Divider()
accountsMenuContent
}
#if !os(tvOS)
Divider()
Button {
copyInstanceURL()
} label: {
Label(String(localized: "instance.browse.copyURL"), systemImage: "doc.on.doc")
}
Button {
openURL(instance.url)
} label: {
Label(String(localized: "instance.browse.openInBrowser"), systemImage: "safari")
}
#endif
Divider()
Button {
showEditInstance = true
} label: {
@@ -154,6 +204,66 @@ struct InstanceBrowseView: View {
}
}
/// Sign-in entry when nobody is signed in; otherwise an accounts submenu
/// that switches the data context, adds a login, or signs the current one
/// out. Switching goes through the global context — the same semantics as
/// the switcher — which this view already reacts to.
@ViewBuilder
private var accountsMenuContent: some View {
if instanceAccounts.isEmpty {
Button {
loginTarget = .newAccount
} label: {
Label(String(localized: "login.loginToAccount"), systemImage: "person.badge.plus")
}
} else {
Menu {
ForEach(instanceAccounts) { account in
Button {
appEnvironment?.switchDataContext(to: .account(account.id))
} label: {
if account.id == effectiveAccount?.id {
Label(accountDisplayName(account), systemImage: "checkmark")
} else {
Text(accountDisplayName(account))
}
}
.disabled(account.id == effectiveAccount?.id)
}
Divider()
Button {
loginTarget = .newAccount
} label: {
Label(String(localized: "accounts.addAccount"), systemImage: "person.badge.plus")
}
if let active = effectiveAccount {
Button(role: .destructive) {
pendingSignOut = active
} label: {
Label(
String(localized: "instance.browse.signOut \(accountDisplayName(active))"),
systemImage: "rectangle.portrait.and.arrow.forward"
)
}
}
} label: {
Label(String(localized: "accounts.title"), systemImage: "person.crop.circle")
}
}
}
private func copyInstanceURL() {
#if os(iOS)
UIPasteboard.general.string = instance.url.absoluteString
#elseif os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(instance.url.absoluteString, forType: .string)
#endif
}
/// View options button lives on the leading edge on macOS, trailing elsewhere.
private var viewOptionsPlacement: ToolbarItemPlacement {
#if os(macOS)
@@ -466,6 +576,36 @@ struct InstanceBrowseView: View {
Text(accounts)
}
}
#if os(tvOS)
.fullScreenCover(item: $loginTarget) { target in
InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in
storeLogin(credential: credential, username: username, target: target)
}
}
#else
.sheet(item: $loginTarget) { target in
InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in
storeLogin(credential: credential, username: username, target: target)
}
}
#endif
.confirmationDialog(
String(localized: "accounts.remove.confirm.title \(pendingSignOut.map(accountDisplayName) ?? instance.displayName)"),
isPresented: Binding(
get: { pendingSignOut != nil },
set: { if !$0 { pendingSignOut = nil } }
),
titleVisibility: .visible
) {
Button(String(localized: "common.remove"), role: .destructive) {
confirmSignOut()
}
Button(String(localized: "common.cancel"), role: .cancel) {
pendingSignOut = nil
}
} message: {
Text(String(localized: "accounts.remove.confirm.message"))
}
.task {
// Initialize search view model
if let appEnvironment {
@@ -1361,6 +1501,32 @@ struct InstanceBrowseView: View {
isLoggedIn = appEnvironment?.isSignedIn(to: instance) ?? false
}
/// Files a successful login against an account, mirroring the edit sheet.
@discardableResult
private func storeLogin(credential: String, username: String, target: AccountLoginTarget) -> Bool {
guard let appEnvironment else { return false }
return AccountSignInHandler(appEnvironment: appEnvironment, instance: instance)
.storeLogin(credential: credential, username: username, target: target)
}
/// The identifier an existing account last signed in with, when this device
/// holds it. A new account has nobody to name yet.
private func storedUsername(for target: AccountLoginTarget) -> String? {
guard let account = target.account else { return nil }
return appEnvironment?.accountCredentialsManager(for: account)?.username(for: account)
}
private func confirmSignOut() {
guard let appEnvironment, let account = pendingSignOut else { return }
defer { pendingSignOut = nil }
// Re-resolved rather than trusted: the dialog can outlive the account,
// which another device may have deleted while it was up.
guard let account = appEnvironment.accountsManager.account(id: account.id) else { return }
appEnvironment.signOutAndRemoveAccount(account)
}
private func loadWatchEntries() {
watchEntriesMap = appEnvironment?.dataManager.watchEntriesMap() ?? [:]
}