Tell the accounts apart, and let a disabled source come back

This commit is contained in:
Arkadiusz Fal
2026-09-07 18:53:21 +02:00
parent 5ac6b7188d
commit 02efc71e7c
15 changed files with 533 additions and 317 deletions

View File

@@ -202,8 +202,20 @@ struct AccountsListView: View {
@ViewBuilder
private func accountRow(_ account: Account, instance: Instance?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title(for: account, instance: instance))
.lineLimit(1)
HStack(spacing: 6) {
// The same checkmark the switcher marks the selected context
// with — this screen offers "Use this account" on every other
// row, and the row it is missing from has to say why.
if isCurrentContext(account) {
Image(systemName: "checkmark")
.font(.caption.weight(.semibold))
.foregroundStyle(.tint)
.accessibilityLabel(String(localized: "accounts.current"))
}
Text(title(for: account, instance: instance))
.lineLimit(1)
}
if instance == nil {
// An orphan has no status worth reporting: its capabilities are
@@ -282,13 +294,23 @@ struct AccountsListView: View {
}
private func loginSheet(_ login: AccountsListLogin) -> some View {
InstanceLoginView(instance: login.instance) { credential, username in
InstanceLoginView(
instance: login.instance,
initialUsername: storedUsername(for: login.target)
) { credential, username in
guard let appEnvironment else { return false }
return AccountSignInHandler(appEnvironment: appEnvironment, instance: login.instance)
.storeLogin(credential: credential, username: username, target: login.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)
}
// MARK: - Presentation
/// The orphan's own label, never the validator's: with no instance to

View File

@@ -329,13 +329,13 @@ private struct EditRemoteServerContent: View {
.presentationCompactAdaptation(.sheet)
#if os(tvOS)
.fullScreenCover(item: $loginTarget) { target in
InstanceLoginView(instance: instance) { credential, username 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) { credential, username in
InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in
storeLogin(credential: credential, username: username, target: target)
}
}
@@ -417,6 +417,13 @@ private struct EditRemoteServerContent: View {
return stored
}
/// 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 loadServerInfo() async {
guard instance.type == .yatteeServer, let appEnvironment else { return }

View File

@@ -85,8 +85,20 @@ struct InstanceAccountsSection: View {
@ViewBuilder
private func accountRow(_ account: Account) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(displayName(for: account))
.lineLimit(1)
HStack(spacing: 6) {
// The same checkmark the switcher marks the selected context
// with — the row's menu offers "Use this account" on every
// other account, and the row it is missing from has to say why.
if isCurrentContext(account) {
Image(systemName: "checkmark")
.font(.caption.weight(.semibold))
.foregroundStyle(.tint)
.accessibilityLabel(String(localized: "accounts.current"))
}
Text(displayName(for: account))
.lineLimit(1)
}
AccountStatusBadge(status: status(for: account))
}

View File

@@ -19,6 +19,14 @@ struct InstanceLoginView: View {
/// one would be a dead end there.
var isOptionalStep: Bool = false
/// The identifier to start the form with, when there is one to start from.
///
/// Signing in again is almost always the same person proving themselves
/// once more, and the identifier they used is already on the device — beside
/// the credential, in the Keychain. Retyping it is work the app can do.
/// A new login has nobody to name and passes nothing.
var initialUsername: String?
/// Receives the credential **and** the identifier the user signed in with,
/// and reports whether it was stored.
///
@@ -143,30 +151,50 @@ struct InstanceLoginView: View {
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
.onAppear {
// Only ever fills an untouched field: the sheet's body runs again
// for every keystroke, and a prefill that reasserted itself would
// fight the user editing it.
if let initialUsername, !initialUsername.isEmpty, username.isEmpty {
username = initialUsername
}
}
}
// MARK: - Computed Properties
/// Returns the appropriate label for the username field based on instance type.
/// What the first field is called on this backend.
///
/// Named per backend rather than genericised: Invidious signs in by email,
/// and calling that "Username" is how a user ends up typing the wrong thing.
/// Every case is spelled out so a backend that gains accounts —
/// `AccountCapabilities.supportsAccounts` is what decides that — arrives
/// here rather than falling through to somebody else's wording.
private var usernameFieldLabel: String {
switch instance.type {
case .piped:
return String(localized: "login.username")
case .invidious:
return String(localized: "login.email")
default:
case .yatteeServer:
return String(localized: "login.username")
case .peertube:
return String(localized: "login.username")
}
}
/// Returns the appropriate footer text based on instance type.
/// What this form is signing in to, said in the user's terms.
private var footerText: String {
switch instance.type {
case .piped:
return String(localized: "login.footer.pipedAccount \(instance.displayName)")
case .invidious:
return String(localized: "login.footer.invidiousAccount \(instance.displayName)")
default:
case .yatteeServer:
return String(localized: "login.footer.yatteeServerAccount \(instance.displayName)")
case .peertube:
// No account concept at all, so no sign-in is offered — and if one
// ever were, an empty footer explains nothing.
return ""
}
}

View File

@@ -1,233 +0,0 @@
//
// InstancePickerSheet.swift
// Yattee
//
// Quick instance picker sheet for switching between backends.
//
import SwiftUI
struct InstancePickerSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
var body: some View {
NavigationStack {
List {
if let instancesManager {
// Enabled instances
Section(String(localized: "instance.enabled")) {
ForEach(instancesManager.enabledInstances, id: \.url) { instance in
let isActive = instance.id == instancesManager.activeInstance?.id
PickerInstanceRow(instance: instance, isEnabled: true, isPrimary: isActive) {
instancesManager.setActive(instance)
}
}
}
// Disabled instances
let disabledInstances = instancesManager.instances.filter { !$0.isEnabled }
if !disabledInstances.isEmpty {
Section(String(localized: "instance.disabled")) {
ForEach(disabledInstances, id: \.url) { instance in
PickerInstanceRow(instance: instance, isEnabled: false, isPrimary: false) {
instancesManager.toggleEnabled(instance)
}
}
}
}
// Add instance
Section {
NavigationLink {
QuickAddInstanceView()
} label: {
Label(String(localized: "instance.add"), systemImage: "plus.circle")
}
}
}
}
.navigationTitle(String(localized: "instance.picker.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
sheetCloseToolbarItem { dismiss() }
}
}
.presentationDetents([.medium, .large])
#if os(macOS)
.frame(minWidth: 450, minHeight: 500)
#endif
}
}
// MARK: - Picker Instance Row
private struct PickerInstanceRow: View {
let instance: Instance
let isEnabled: Bool
let isPrimary: Bool
let onTap: () -> Void
var body: some View {
HStack(spacing: 12) {
// Type icon
Image(systemName: instanceIcon)
.font(.title3)
.foregroundStyle(isEnabled ? .primary : .secondary)
.frame(width: 32)
// Info
VStack(alignment: .leading, spacing: 2) {
Text(instance.displayName)
.font(.body)
.foregroundStyle(isEnabled ? .primary : .secondary)
HStack(spacing: 4) {
Text(instance.type.displayName)
.font(.caption)
.foregroundStyle(.secondary)
Text(verbatim: "•")
.font(.caption)
.foregroundStyle(.tertiary)
Text(instance.url.host ?? "")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
Spacer()
// Checkmark for primary, or plus for disabled
if isEnabled {
if isPrimary {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
} else {
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
.onTapGesture {
onTap()
}
}
private var instanceIcon: String {
switch instance.type {
case .invidious:
return "play.rectangle"
case .piped:
return "waveform"
case .peertube:
return "film"
case .yatteeServer:
return "server.rack"
}
}
}
// MARK: - Quick Add Instance View
private struct QuickAddInstanceView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
@State private var urlString = ""
@State private var isDetecting = false
@State private var errorMessage: String?
var body: some View {
Form {
Section {
TextField(String(localized: "instance.url.placeholder"), text: $urlString)
.textContentType(.URL)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
#endif
} header: {
Text(String(localized: "instance.url.header"))
} footer: {
Text(String(localized: "instance.url.footer"))
}
if let error = errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
.navigationTitle(String(localized: "instance.add"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button {
addInstance()
} label: {
if isDetecting {
ProgressView()
.controlSize(.small)
} else {
Text(String(localized: "common.add"))
}
}
.disabled(urlString.isEmpty || isDetecting)
}
}
}
private func addInstance() {
guard let appEnvironment else { return }
// Normalize URL
var normalizedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if !normalizedURL.hasPrefix("http://") && !normalizedURL.hasPrefix("https://") {
normalizedURL = "https://" + normalizedURL
}
guard let url = URL(string: normalizedURL) else {
errorMessage = String(localized: "instance.error.invalidURL")
return
}
isDetecting = true
errorMessage = nil
Task {
let type = await appEnvironment.instanceDetector.detect(url: url)
await MainActor.run {
if let type {
let instance = Instance(type: type, url: url, name: url.host ?? normalizedURL, isEnabled: true)
appEnvironment.instancesManager.addInstance(instance)
dismiss()
} else {
errorMessage = String(localized: "instance.error.detectionFailed")
isDetecting = false
}
}
}
}
}
// MARK: - Preview
#Preview {
InstancePickerSheet()
.appEnvironment(.preview)
}

View File

@@ -39,6 +39,19 @@ struct SourceRow: View {
return appEnvironment?.needsCredentials(for: instance) ?? false
}
/// Whether one of this source's logins is being refused by the server.
///
/// The instance-level statuses know nothing about accounts, so a rejected
/// login would otherwise show up nowhere the user looks for their sources.
private var hasRejectedAccount: Bool {
guard case .remoteServer(let instance) = source,
let accountsManager = appEnvironment?.accountsManager else { return false }
return accountsManager.accounts(forInstance: instance.id).contains {
accountsManager.status(for: $0.id) == .credentialRejected
}
}
var body: some View {
#if os(tvOS)
Button(action: onEdit) {
@@ -99,7 +112,16 @@ struct SourceRow: View {
@ViewBuilder
private var statusView: some View {
if needsPassword || needsCredentials {
// A refused login comes first: it names one thing the user has to do,
// and two warnings stacked on one row say less than either alone.
if hasRejectedAccount {
Label(
AccountStatusBadge.text(for: .credentialRejected),
systemImage: "person.crop.circle.badge.exclamationmark"
)
.font(.caption2)
.foregroundStyle(AccountStatusBadge.color(for: .credentialRejected))
} else if needsPassword || needsCredentials {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)

View File

@@ -415,9 +415,31 @@ struct SourcesListView: View {
appEnvironment?.needsCredentials(for: instance) ?? false
}
/// Whether one of this source's logins is being refused by the server.
///
/// The instance-level statuses below know nothing about accounts, so a
/// rejected login would otherwise show up nowhere the user looks for their
/// sources.
private func hasRejectedAccount(_ instance: Instance) -> Bool {
guard let accountsManager = appEnvironment?.accountsManager else { return false }
return accountsManager.accounts(forInstance: instance.id).contains {
accountsManager.status(for: $0.id) == .credentialRejected
}
}
@ViewBuilder
private func instanceStatusView(for instance: Instance) -> some View {
if needsCredentials(instance) {
// A refused login comes first: it names one thing the user has to do,
// and two warnings stacked on one row say less than either alone.
if hasRejectedAccount(instance) {
Label(
AccountStatusBadge.text(for: .credentialRejected),
systemImage: "person.crop.circle.badge.exclamationmark"
)
.font(.caption2)
.foregroundStyle(AccountStatusBadge.color(for: .credentialRejected))
} else if needsCredentials(instance) {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)