mirror of
https://github.com/yattee/yattee.git
synced 2026-11-04 02:47:18 +00:00
Tell the accounts apart, and let a disabled source come back
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user