mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Residual round after the macOS fix verification, five closures:
A connection refusal or a failed DNS lookup arrived as APIError.unknown,
which no classifier reads — so an account pointed at a downed instance
never went unreachable, never fell back to local data, never armed a
retry. New APIError.connectionFailed carries the system's own sentence
and lands as unreachable/offline in AccountsManager, InstancesManager
and the credential probe; a refused host now classifies in seconds
instead of never.
Subscriptions' periodic retry armed only for networkError, while the
local fallback's yatteeServerRequired — shown exactly when the account's
server could not be reached — waited on a status expiry nothing was
watching. FeedLoadState.warrantsPeriodicRetry gates on the context's
unavailability, so that screen heals on its own too, and an account that
genuinely has no Yattee Server still never loops.
The switcher dropped every context of a switched-off source, leaving the
toolbar badge pointing at a menu its own selection had vanished from.
listedContexts keeps the rows with their status in the title ("— Source
switched off"), isSwitchable refuses the pick, and the current context
keeps its checkmark.
The sidebar footer's switcher row overflowed the 140 pt column minimum:
the width floor now sits on the menu itself and ViewThatFits drops the
caption — the part the icon already says — before the account name loses
characters.
The channel menu exposed a raw SF Symbol name as its accessibility
title; the label is now a real Label rendered iconOnly, so the AX bridge
hears "Subscription Options".
530 lines
19 KiB
Swift
530 lines
19 KiB
Swift
//
|
|
// DataContextSwitcherMenu.swift
|
|
// Yattee
|
|
//
|
|
// The one place the selected data context is switched from.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
// MARK: - Rows
|
|
|
|
/// The switchable contexts, as rows.
|
|
///
|
|
/// Shared by every presentation of the switcher so a context that is offered in
|
|
/// one place is offered — and named — identically in all of them.
|
|
struct DataContextSwitcherRows: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
/// Called after a switch, for presentations that have to close themselves.
|
|
var onSwitch: (() -> Void)?
|
|
|
|
private var validator: DataContextValidator? { appEnvironment?.dataContextValidator }
|
|
|
|
var body: some View {
|
|
ForEach(validator?.listedContexts ?? [.local], id: \.self) { context in
|
|
row(for: context)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func row(for context: DataContext) -> some View {
|
|
// A menu row is a single label — a `VStack` inside one renders as
|
|
// nothing on macOS — so the status travels in the title rather than as
|
|
// a second line. A context that cannot be switched to says so there
|
|
// too: `title(for:in:)` appends the *display* status, which is what
|
|
// turns a login on a switched-off source into "… — Source switched
|
|
// off" rather than a row that looks ordinary and does nothing.
|
|
let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment)
|
|
|
|
#if os(macOS)
|
|
// The mark has to be the menu item's own. A `Label`'s `systemImage` is
|
|
// dropped by a macOS menu row, leaving the selected context
|
|
// indistinguishable from the rest; a `Toggle` is what SwiftUI renders
|
|
// as a checked item, mark character included.
|
|
Toggle(isOn: Binding(
|
|
get: { isCurrent(context) },
|
|
set: { isOn in
|
|
guard isOn else { return }
|
|
switchTo(context)
|
|
}
|
|
)) {
|
|
Text(title)
|
|
}
|
|
.disabled(isRefused(context))
|
|
#else
|
|
Button {
|
|
switchTo(context)
|
|
} label: {
|
|
if isCurrent(context) {
|
|
Label(title, systemImage: "checkmark")
|
|
} else {
|
|
Text(title)
|
|
}
|
|
}
|
|
.disabled(isRefused(context))
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
private func switchTo(_ context: DataContext) {
|
|
// The row is disabled as well; this is what makes the refusal true on
|
|
// any presentation where a disabled control can still be activated.
|
|
guard !isRefused(context) else { return }
|
|
|
|
appEnvironment?.switchDataContext(to: context)
|
|
onSwitch?()
|
|
}
|
|
|
|
private func isCurrent(_ context: DataContext) -> Bool {
|
|
appEnvironment?.accountsManager.currentContext == context
|
|
}
|
|
|
|
/// Listed, but not something the user may pick.
|
|
///
|
|
/// The current context is never refused, whatever its source is doing: it
|
|
/// is already the selection, picking it changes nothing, and a disabled
|
|
/// macOS menu item is not the place to hang the app's only checkmark. It
|
|
/// carries its status in the title instead, which is the part that has to
|
|
/// survive.
|
|
private func isRefused(_ context: DataContext) -> Bool {
|
|
guard let validator, !isCurrent(context) else { return false }
|
|
return !validator.isSwitchable(context)
|
|
}
|
|
}
|
|
|
|
// MARK: - Labels
|
|
|
|
/// Names the switchable contexts.
|
|
enum DataContextSwitcherLabels {
|
|
/// The context's name, plus whatever the user needs to tell it apart.
|
|
@MainActor
|
|
static func title(for context: DataContext, in appEnvironment: AppEnvironment?) -> String {
|
|
guard let appEnvironment else { return String(localized: "subscriptions.account.local") }
|
|
|
|
return title(
|
|
for: context,
|
|
accountsManager: appEnvironment.accountsManager,
|
|
validator: appEnvironment.dataContextValidator,
|
|
username: { appEnvironment.accountCredentialsManager(for: $0)?.username(for: $0) }
|
|
)
|
|
}
|
|
|
|
/// The same label, built from only what it needs.
|
|
///
|
|
/// Taking the pieces rather than the environment is what lets the naming
|
|
/// rules be exercised on their own: which suffix a name gets depends on the
|
|
/// other accounts, the stored identifier and the host, and on nothing else.
|
|
@MainActor
|
|
static func title(
|
|
for context: DataContext,
|
|
accountsManager: AccountsManager,
|
|
validator: DataContextValidator,
|
|
username: (Account) -> String?
|
|
) -> String {
|
|
let name = validator.displayName(for: context)
|
|
|
|
guard case let .account(id) = context, let account = accountsManager.account(id: id) else {
|
|
return name
|
|
}
|
|
|
|
var title = name
|
|
|
|
let peers = peers(sharing: name, accountsManager: accountsManager, validator: validator)
|
|
if peers.count > 1 {
|
|
title = disambiguated(
|
|
name,
|
|
for: account,
|
|
among: peers,
|
|
accountsManager: accountsManager,
|
|
username: username
|
|
)
|
|
}
|
|
|
|
let status = accountsManager.displayStatus(for: account)
|
|
guard status != .signedIn else { return title }
|
|
|
|
return "\(title) — \(AccountStatusBadge.text(for: status))"
|
|
}
|
|
|
|
/// What a shared name needs added to it before it names one account.
|
|
///
|
|
/// The identifier comes first: two logins on the *same* instance share a
|
|
/// host, so a host suffix would repeat the server and still leave the user
|
|
/// choosing between two identical rows. The host alone is what is left when
|
|
/// the identifier is not on this device — tvOS never receives one. Neither
|
|
/// is repeated when the name already carries it: the default name for an
|
|
/// account *is* `identifier@host`, so appending it unguarded produced two
|
|
/// rows reading `X · X`.
|
|
///
|
|
/// The source is the last resort and the only one that can name what two
|
|
/// logins of the same server user on two copies of the same server differ
|
|
/// by — the thing the user chose when adding the second source.
|
|
static func disambiguated(_ name: String, username: String?, host: String?, source: String? = nil) -> String {
|
|
var parts: [String] = []
|
|
|
|
if let username, !username.isEmpty, !name.localizedCaseInsensitiveContains(username) {
|
|
if let host, !host.isEmpty, !name.localizedCaseInsensitiveContains(host) {
|
|
parts.append("\(username)@\(host)")
|
|
} else {
|
|
parts.append(username)
|
|
}
|
|
} else if let host, !host.isEmpty, !name.localizedCaseInsensitiveContains(host) {
|
|
parts.append(host)
|
|
}
|
|
|
|
if let source, !source.isEmpty, name.localizedCaseInsensitiveCompare(source) != .orderedSame {
|
|
parts.append(source)
|
|
}
|
|
|
|
guard !parts.isEmpty else { return name }
|
|
return "\(name) · \(parts.joined(separator: " · "))"
|
|
}
|
|
|
|
/// The identifier and the host first, and the source only once they have
|
|
/// failed: two logins on one server are told apart by who they are, and
|
|
/// naming the server as well would add a word that is the same on both
|
|
/// rows. It is when that suffix still collides — the same server user
|
|
/// signed in on two sources pointing at the same host — that the source is
|
|
/// the only thing left that differs.
|
|
@MainActor
|
|
private static func disambiguated(
|
|
_ name: String,
|
|
for account: Account,
|
|
among peers: [Account],
|
|
accountsManager: AccountsManager,
|
|
username: (Account) -> String?
|
|
) -> String {
|
|
func withoutSource(_ peer: Account) -> String {
|
|
disambiguated(
|
|
name,
|
|
username: username(peer),
|
|
host: accountsManager.instance(for: peer)?.url.host
|
|
)
|
|
}
|
|
|
|
let own = withoutSource(account)
|
|
let collides = peers.contains { $0.id != account.id && withoutSource($0) == own }
|
|
guard collides else { return own }
|
|
|
|
return disambiguated(
|
|
name,
|
|
username: username(account),
|
|
host: accountsManager.instance(for: account)?.url.host,
|
|
source: accountsManager.instance(for: account)?.displayName
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
private static func peers(
|
|
sharing name: String,
|
|
accountsManager: AccountsManager,
|
|
validator: DataContextValidator
|
|
) -> [Account] {
|
|
accountsManager.selectableAccounts.filter { validator.displayName(for: $0) == name }
|
|
}
|
|
}
|
|
|
|
// MARK: - Glyph
|
|
|
|
/// How the switcher's icon reads.
|
|
///
|
|
/// Shared by every presentation of the switcher — the toolbar menu, the
|
|
/// settings row, the tvOS button — so the same account health cannot look
|
|
/// healthy in one of them and not in another.
|
|
enum DataContextSwitcherGlyph {
|
|
/// Whether the icon should ask to be noticed.
|
|
///
|
|
/// Only the states the user can actually clear: signing in again, turning
|
|
/// the instance back on, or switching away. A context that is merely still
|
|
/// resolving is not one of them — badging `checking` would put an
|
|
/// exclamation mark on every cold launch, before the Keychain has answered.
|
|
static func needsAttention(_ validity: DataContextValidity) -> Bool {
|
|
switch validity {
|
|
case .valid:
|
|
return false
|
|
case .removed:
|
|
return true
|
|
case let .temporarilyUnavailable(reason):
|
|
switch reason {
|
|
case .instanceDisabled, .credentialRejected, .unreachable: return true
|
|
case .checking, .instancePending, .credentialMissing: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The filled glyph says an account is selected; the badge says that
|
|
/// account cannot be used right now, which is the whole reason the user
|
|
/// would reach for this control.
|
|
static func systemImage(for context: DataContext, validity: DataContextValidity) -> String {
|
|
guard !context.isLocal else { return "person.crop.circle" }
|
|
|
|
if needsAttention(validity) { return "person.crop.circle.badge.exclamationmark" }
|
|
return "person.crop.circle.fill"
|
|
}
|
|
|
|
/// Orange — the colour the settings entry already badges attention with —
|
|
/// and no tint at all otherwise, so a healthy switcher keeps whatever
|
|
/// styling the toolbar or the list gives it.
|
|
static func tint(for context: DataContext, validity: DataContextValidity) -> Color? {
|
|
guard !context.isLocal, needsAttention(validity) else { return nil }
|
|
return .orange
|
|
}
|
|
|
|
@MainActor
|
|
static func systemImage(in appEnvironment: AppEnvironment?) -> String {
|
|
guard let appEnvironment else { return "person.crop.circle" }
|
|
|
|
return systemImage(
|
|
for: appEnvironment.accountsManager.currentContext,
|
|
validity: appEnvironment.dataContextValidator.validity
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
static func tint(in appEnvironment: AppEnvironment?) -> Color? {
|
|
guard let appEnvironment else { return nil }
|
|
|
|
return tint(
|
|
for: appEnvironment.accountsManager.currentContext,
|
|
validity: appEnvironment.dataContextValidator.validity
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The switcher's icon, health included.
|
|
///
|
|
/// Hidden from assistive technology on purpose: every presentation names the
|
|
/// switcher in words beside it, and an unlabelled `Image` announces its symbol
|
|
/// — a control that read "Account" while healthy started reading
|
|
/// "person.crop.circle.badge.exclamationmark" the moment it badged.
|
|
struct DataContextSwitcherIcon: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
let image = Image(systemName: DataContextSwitcherGlyph.systemImage(in: appEnvironment))
|
|
|
|
Group {
|
|
if let tint = DataContextSwitcherGlyph.tint(in: appEnvironment) {
|
|
image.foregroundStyle(tint)
|
|
} else {
|
|
image
|
|
}
|
|
}
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Menu
|
|
|
|
#if !os(tvOS)
|
|
/// The toolbar switcher: one tap, no confirmation — switching is reversible.
|
|
struct DataContextSwitcherMenu: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
Menu {
|
|
DataContextSwitcherRows()
|
|
} label: {
|
|
Label {
|
|
Text(String(localized: "accounts.switcher.title"))
|
|
} icon: {
|
|
DataContextSwitcherIcon()
|
|
}
|
|
}
|
|
.help(contextTitle)
|
|
.accessibilityLabel(Text(String(localized: "accounts.switcher.title")))
|
|
.accessibilityValue(Text(contextTitle))
|
|
}
|
|
|
|
private var contextTitle: String {
|
|
DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)
|
|
}
|
|
|
|
private var currentContext: DataContext {
|
|
appEnvironment?.accountsManager.currentContext ?? .local
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
/// A settings row naming the current context, with the switcher attached.
|
|
struct DataContextSwitcherSettingsRow: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
#if os(tvOS)
|
|
@State private var showingPicker = false
|
|
#endif
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
Button {
|
|
showingPicker = true
|
|
} label: {
|
|
rowLabel
|
|
}
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
.fullScreenCover(isPresented: $showingPicker) {
|
|
DataContextSwitcherPicker()
|
|
}
|
|
#elseif os(macOS)
|
|
// A macOS menu label renders its first text and nothing else — the
|
|
// trailing name was dropped at every width, leaving the row silent
|
|
// about which account is selected. So the name is the menu's *own*
|
|
// label, and the row carries only what names the control.
|
|
// `LabeledContent` overflows rather than compress its label, so at the
|
|
// sidebar's 140 pt minimum a captioned row (caption + spacing + the
|
|
// name's floor ≈ 158 pt) juts past both edges. The caption is the one
|
|
// part the icon already says, so it is the part that goes.
|
|
ViewThatFits(in: .horizontal) {
|
|
macOSRow(captioned: true)
|
|
macOSRow(captioned: false)
|
|
}
|
|
#else
|
|
Menu {
|
|
DataContextSwitcherRows()
|
|
} label: {
|
|
rowLabel
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
@ViewBuilder private func macOSRow(captioned: Bool) -> some View {
|
|
LabeledContent {
|
|
Menu {
|
|
DataContextSwitcherRows()
|
|
} label: {
|
|
Text(contextTitle)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
// The floor must sit on the menu itself: a borderless menu button
|
|
// lays out its label alone, so a frame inside it never reaches the
|
|
// row's layout, and the caption would keep its ideal width while
|
|
// the name shrank to an ellipsis. Out here the menu claims the
|
|
// width and the caption is what gives way.
|
|
.frame(minWidth: Self.minimumNameWidth, alignment: .trailing)
|
|
.layoutPriority(1)
|
|
.help(contextTitle)
|
|
.accessibilityLabel(Text(String(localized: "accounts.switcher.title")))
|
|
.accessibilityValue(Text(contextTitle))
|
|
} label: {
|
|
Label {
|
|
if captioned {
|
|
Text(String(localized: "accounts.switcher.title"))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
} icon: {
|
|
DataContextSwitcherIcon()
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// What the trailing name may never be squeezed below.
|
|
///
|
|
/// A row this narrow — the macOS sidebar's footer at a dragged-in column
|
|
/// width — hands the leading label its ideal width first and leaves the
|
|
/// name whatever is left, and a `Text` will shrink all the way to a lone
|
|
/// ellipsis before it complains. A floor turns that around: the name keeps
|
|
/// enough characters to be recognised, and the word naming the control —
|
|
/// which the icon beside it already says — is what truncates instead.
|
|
private static let minimumNameWidth: CGFloat = 80
|
|
|
|
#if !os(macOS)
|
|
private var rowLabel: some View {
|
|
HStack {
|
|
Label {
|
|
Text(String(localized: "accounts.switcher.title"))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
} icon: {
|
|
DataContextSwitcherIcon()
|
|
}
|
|
Spacer(minLength: 8)
|
|
Text(contextTitle)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(minWidth: Self.minimumNameWidth, alignment: .trailing)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private var contextTitle: String {
|
|
DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)
|
|
}
|
|
|
|
private var currentContext: DataContext {
|
|
appEnvironment?.accountsManager.currentContext ?? .local
|
|
}
|
|
}
|
|
|
|
// MARK: - tvOS picker
|
|
|
|
#if os(tvOS)
|
|
/// The tvOS switcher, presented in a `fullScreenCover`: menus are unusable
|
|
/// there, and a sheet is a card too small to read.
|
|
struct DataContextSwitcherPicker: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
Text(String(localized: "accounts.switcher.title"))
|
|
.font(.title2.bold())
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
DataContextSwitcherRows {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
|
|
Button(String(localized: "common.close")) {
|
|
dismiss()
|
|
}
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
}
|
|
.frame(maxWidth: 900)
|
|
.padding(.horizontal, 80)
|
|
.padding(.vertical, 60)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The button that presents the picker, for placing beside other tvOS controls.
|
|
struct DataContextSwitcherButton: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@State private var showingPicker = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
showingPicker = true
|
|
} label: {
|
|
Label {
|
|
Text(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment))
|
|
} icon: {
|
|
DataContextSwitcherIcon()
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.fullScreenCover(isPresented: $showingPicker) {
|
|
DataContextSwitcherPicker()
|
|
}
|
|
}
|
|
|
|
private var currentContext: DataContext {
|
|
appEnvironment?.accountsManager.currentContext ?? .local
|
|
}
|
|
}
|
|
#endif
|