From 3043dd3a0c985b47b91e42f83eb3ef1af148adaf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 14:25:03 +0100 Subject: [PATCH] fix: restructure macOS connections settings --- CHANGELOG.md | 1 + .../Sources/Clawdis/ConnectionsSettings.swift | 777 +++++++++++------- 2 files changed, 482 insertions(+), 296 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2690803..0c7b06af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm. - Agent tools: scope the Discord tool to Discord surface runs. - Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style. +- macOS Connections: move to sidebar + detail layout with structured sections and header actions. - macOS onboarding: increase window height so the permissions page fits without scrolling. - Thinking: default to low for reasoning-capable models when no /think or config default is set. - Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace. diff --git a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift index da506953c..d94c471eb 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI struct ConnectionsSettings: View { - private enum ConnectionProvider: String, CaseIterable, Identifiable { + private enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable { case whatsapp case telegram case discord @@ -20,9 +20,40 @@ struct ConnectionsSettings: View { case .imessage: 4 } } + + var title: String { + switch self { + case .whatsapp: "WhatsApp" + case .telegram: "Telegram" + case .discord: "Discord" + case .signal: "Signal" + case .imessage: "iMessage" + } + } + + var detailTitle: String { + switch self { + case .whatsapp: "WhatsApp Web" + case .telegram: "Telegram Bot" + case .discord: "Discord Bot" + case .signal: "Signal REST" + case .imessage: "iMessage (imsg)" + } + } + + var systemImage: String { + switch self { + case .whatsapp: "message" + case .telegram: "paperplane" + case .discord: "bubble.left.and.bubble.right" + case .signal: "antenna.radiowaves.left.and.right" + case .imessage: "message.fill" + } + } } @Bindable var store: ConnectionsStore + @State private var selectedProvider: ConnectionProvider? = nil @State private var showTelegramToken = false @State private var showDiscordToken = false @@ -31,47 +62,189 @@ struct ConnectionsSettings: View { } var body: some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 14) { - self.header - ForEach(self.orderedProviders) { provider in - self.providerSection(provider) + NavigationSplitView { + self.sidebar + } detail: { + self.detail + } + .onAppear { + self.store.start() + self.ensureSelection() + } + .onChange(of: self.orderedProviders) { _, _ in + self.ensureSelection() + } + .onDisappear { self.store.stop() } + } + + private var sidebar: some View { + List(selection: self.$selectedProvider) { + if !self.enabledProviders.isEmpty { + Section("Configured") { + ForEach(self.enabledProviders) { provider in + self.sidebarRow(provider) + .tag(provider) + } } + } + + if !self.availableProviders.isEmpty { + Section("Available") { + ForEach(self.availableProviders) { provider in + self.sidebarRow(provider) + .tag(provider) + } + } + } + } + .listStyle(.sidebar) + .frame(minWidth: 210, idealWidth: 230, maxWidth: 260) + } + + private var detail: some View { + Group { + if let provider = self.selectedProvider { + self.providerDetail(provider) + } else { + self.emptyDetail + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var emptyDetail: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Connections") + .font(.title3.weight(.semibold)) + Text("Select a provider to view status and settings.") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 24) + .padding(.vertical, 18) + } + + private func providerDetail(_ provider: ConnectionProvider) -> some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.detailHeader(for: provider) + Divider() + self.providerSection(provider) Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) .padding(.vertical, 18) } - .onAppear { self.store.start() } - .onDisappear { self.store.stop() } } - private var header: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Connections") - .font(.title3.weight(.semibold)) - Text("Link and monitor messaging providers.") - .font(.callout) - .foregroundStyle(.secondary) + private func sidebarRow(_ provider: ConnectionProvider) -> some View { + HStack(spacing: 8) { + Circle() + .fill(self.providerTint(provider)) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(provider.title) + Text(self.providerSummary(provider)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private func detailHeader(for provider: ConnectionProvider) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Label(provider.detailTitle, systemImage: provider.systemImage) + .font(.title3.weight(.semibold)) + self.statusBadge( + self.providerSummary(provider), + color: self.providerTint(provider)) + Spacer() + self.providerHeaderActions(provider) + } + + HStack(spacing: 10) { + Text("Last check \(self.providerLastCheckText(provider))") + .font(.caption) + .foregroundStyle(.secondary) + if self.providerHasError(provider) { + Text("Error") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red.opacity(0.15)) + .foregroundStyle(.red) + .clipShape(Capsule()) + } + } + + if let details = self.providerDetails(provider) { + Text(details) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } - private var whatsAppSection: some View { - GroupBox("WhatsApp") { + private func statusBadge(_ text: String, color: Color) -> some View { + Text(text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(color.opacity(0.16)) + .foregroundStyle(color) + .clipShape(Capsule()) + } + + private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View { + GroupBox(title) { VStack(alignment: .leading, spacing: 10) { - self.providerHeader( - title: "WhatsApp Web", - color: self.whatsAppTint, - subtitle: self.whatsAppSummary) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } - if let details = self.whatsAppDetails { - Text(details) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + @ViewBuilder + private func providerHeaderActions(_ provider: ConnectionProvider) -> some View { + HStack(spacing: 8) { + if provider == .whatsapp { + Button("Logout") { + Task { await self.store.logoutWhatsApp() } } + .buttonStyle(.bordered) + .disabled(self.store.whatsappBusy) + } + if provider == .telegram { + Button("Logout") { + Task { await self.store.logoutTelegram() } + } + .buttonStyle(.bordered) + .disabled(self.store.telegramBusy) + } + + Button { + Task { await self.store.refresh(probe: true) } + } label: { + if self.store.isRefreshing { + ProgressView().controlSize(.small) + } else { + Text("Refresh") + } + } + .buttonStyle(.bordered) + .disabled(self.store.isRefreshing) + } + .controlSize(.small) + } + + private var whatsAppSection: some View { + VStack(alignment: .leading, spacing: 16) { + self.formSection("Linking") { if let message = self.store.whatsappLoginMessage { Text(message) .font(.caption) @@ -105,52 +278,16 @@ struct ConnectionsSettings: View { } .buttonStyle(.bordered) .disabled(self.store.whatsappBusy) - - Spacer() - - Button("Logout") { - Task { await self.store.logoutWhatsApp() } - } - .buttonStyle(.bordered) - .disabled(self.store.whatsappBusy) - - Button("Refresh") { - Task { await self.store.refresh(probe: true) } - } - .buttonStyle(.bordered) - .disabled(self.store.isRefreshing) } .font(.caption) } - .frame(maxWidth: .infinity, alignment: .leading) } } private var telegramSection: some View { - GroupBox("Telegram") { - VStack(alignment: .leading, spacing: 10) { - self.providerHeader( - title: "Telegram Bot", - color: self.telegramTint, - subtitle: self.telegramSummary) - - if let details = self.telegramDetails { - Text(details) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - if let status = self.store.configStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Divider().padding(.vertical, 2) - - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + VStack(alignment: .leading, spacing: 16) { + self.formSection("Authentication") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Bot token") if self.showTelegramToken { @@ -166,6 +303,11 @@ struct ConnectionsSettings: View { .toggleStyle(.switch) .disabled(self.isTelegramTokenLocked) } + } + } + + self.formSection("Access") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Require mention") Toggle("", isOn: self.$store.telegramRequireMention) @@ -177,11 +319,11 @@ struct ConnectionsSettings: View { TextField("123456789, @team", text: self.$store.telegramAllowFrom) .textFieldStyle(.roundedBorder) } - GridRow { - self.gridLabel("Proxy") - TextField("socks5://localhost:9050", text: self.$store.telegramProxy) - .textFieldStyle(.roundedBorder) - } + } + } + + self.formSection("Webhook") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Webhook URL") TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl) @@ -198,71 +340,49 @@ struct ConnectionsSettings: View { .textFieldStyle(.roundedBorder) } } - - if self.isTelegramTokenLocked { - Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 12) { - Button { - Task { await self.store.saveTelegramConfig() } - } label: { - if self.store.isSavingConfig { - ProgressView().controlSize(.small) - } else { - Text("Save") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.store.isSavingConfig) - - Spacer() - - Button("Logout") { - Task { await self.store.logoutTelegram() } - } - .buttonStyle(.bordered) - .disabled(self.store.telegramBusy) - - Button("Refresh") { - Task { await self.store.refresh(probe: true) } - } - .buttonStyle(.bordered) - .disabled(self.store.isRefreshing) - } - .font(.caption) } - .frame(maxWidth: .infinity, alignment: .leading) + + self.formSection("Network") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { + GridRow { + self.gridLabel("Proxy") + TextField("socks5://localhost:9050", text: self.$store.telegramProxy) + .textFieldStyle(.roundedBorder) + } + } + } + + if self.isTelegramTokenLocked { + Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.") + .font(.caption) + .foregroundStyle(.secondary) + } + + self.configStatusMessage + + HStack(spacing: 12) { + Button { + Task { await self.store.saveTelegramConfig() } + } label: { + if self.store.isSavingConfig { + ProgressView().controlSize(.small) + } else { + Text("Save") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.store.isSavingConfig) + + Spacer() + } + .font(.caption) } } private var discordSection: some View { - GroupBox("Discord") { - VStack(alignment: .leading, spacing: 10) { - self.providerHeader( - title: "Discord Bot", - color: self.discordTint, - subtitle: self.discordSummary) - - if let details = self.discordDetails { - Text(details) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - if let status = self.store.configStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Divider().padding(.vertical, 2) - - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + VStack(alignment: .leading, spacing: 16) { + self.formSection("Authentication") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Enabled") Toggle("", isOn: self.$store.discordEnabled) @@ -284,6 +404,11 @@ struct ConnectionsSettings: View { .toggleStyle(.switch) .disabled(self.isDiscordTokenLocked) } + } + } + + self.formSection("Messages") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Allow DMs from") TextField("123456789, username#1234", text: self.$store.discordAllowFrom) @@ -306,6 +431,20 @@ struct ConnectionsSettings: View { TextField("channelId1, channelId2", text: self.$store.discordGroupChannels) .textFieldStyle(.roundedBorder) } + GridRow { + self.gridLabel("Reply to mode") + Picker("", selection: self.$store.discordReplyToMode) { + Text("off").tag("off") + Text("first").tag("first") + Text("all").tag("all") + } + .labelsHidden() + } + } + } + + self.formSection("Limits") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Media max MB") TextField("8", text: self.$store.discordMediaMaxMb) @@ -321,17 +460,13 @@ struct ConnectionsSettings: View { TextField("2000", text: self.$store.discordTextChunkLimit) .textFieldStyle(.roundedBorder) } + } + } + + self.formSection("Slash command") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { - self.gridLabel("Reply to mode") - Picker("", selection: self.$store.discordReplyToMode) { - Text("off").tag("off") - Text("first").tag("first") - Text("all").tag("all") - } - .labelsHidden() - } - GridRow { - self.gridLabel("Slash command") + self.gridLabel("Enabled") Toggle("", isOn: self.$store.discordSlashEnabled) .labelsHidden() .toggleStyle(.checkbox) @@ -342,24 +477,20 @@ struct ConnectionsSettings: View { .textFieldStyle(.roundedBorder) } GridRow { - self.gridLabel("Slash session prefix") + self.gridLabel("Session prefix") TextField("discord:slash", text: self.$store.discordSlashSessionPrefix) .textFieldStyle(.roundedBorder) } GridRow { - self.gridLabel("Slash ephemeral") + self.gridLabel("Ephemeral") Toggle("", isOn: self.$store.discordSlashEphemeral) .labelsHidden() .toggleStyle(.checkbox) } } + } - Divider().padding(.vertical, 2) - - Text("Guilds") - .font(.caption) - .foregroundStyle(.secondary) - + GroupBox("Guilds") { VStack(alignment: .leading, spacing: 12) { ForEach($store.discordGuilds) { $guild in VStack(alignment: .leading, spacing: 10) { @@ -372,7 +503,7 @@ struct ConnectionsSettings: View { .buttonStyle(.bordered) } - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Slug") TextField("optional slug", text: $guild.slug) @@ -426,14 +557,11 @@ struct ConnectionsSettings: View { } .buttonStyle(.bordered) } + .frame(maxWidth: .infinity, alignment: .leading) + } - Divider().padding(.vertical, 2) - - Text("Tool actions") - .font(.caption) - .foregroundStyle(.secondary) - - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GroupBox("Tool actions") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Reactions") Toggle("", isOn: self.$store.discordActionReactions) @@ -525,65 +653,40 @@ struct ConnectionsSettings: View { .toggleStyle(.checkbox) } } - - if self.isDiscordTokenLocked { - Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 12) { - Button { - Task { await self.store.saveDiscordConfig() } - } label: { - if self.store.isSavingConfig { - ProgressView().controlSize(.small) - } else { - Text("Save") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.store.isSavingConfig) - - Spacer() - - Button("Refresh") { - Task { await self.store.refresh(probe: true) } - } - .buttonStyle(.bordered) - .disabled(self.store.isRefreshing) - } - .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) + + if self.isDiscordTokenLocked { + Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.") + .font(.caption) + .foregroundStyle(.secondary) + } + + self.configStatusMessage + + HStack(spacing: 12) { + Button { + Task { await self.store.saveDiscordConfig() } + } label: { + if self.store.isSavingConfig { + ProgressView().controlSize(.small) + } else { + Text("Save") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.store.isSavingConfig) + + Spacer() + } + .font(.caption) } } private var signalSection: some View { - GroupBox("Signal") { - VStack(alignment: .leading, spacing: 10) { - self.providerHeader( - title: "Signal REST", - color: self.signalTint, - subtitle: self.signalSummary) - - if let details = self.signalDetails { - Text(details) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - if let status = self.store.configStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Divider().padding(.vertical, 2) - - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + VStack(alignment: .leading, spacing: 16) { + self.formSection("Connection") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Enabled") Toggle("", isOn: self.$store.signalEnabled) @@ -615,6 +718,11 @@ struct ConnectionsSettings: View { TextField("signal-cli", text: self.$store.signalCliPath) .textFieldStyle(.roundedBorder) } + } + } + + self.formSection("Behavior") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Auto start") Toggle("", isOn: self.$store.signalAutoStart) @@ -649,6 +757,11 @@ struct ConnectionsSettings: View { .labelsHidden() .toggleStyle(.checkbox) } + } + } + + self.formSection("Access & limits") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Allow from") TextField("12345, +1555", text: self.$store.signalAllowFrom) @@ -660,59 +773,33 @@ struct ConnectionsSettings: View { .textFieldStyle(.roundedBorder) } } - - HStack(spacing: 12) { - Button { - Task { await self.store.saveSignalConfig() } - } label: { - if self.store.isSavingConfig { - ProgressView().controlSize(.small) - } else { - Text("Save") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.store.isSavingConfig) - - Spacer() - - Button("Refresh") { - Task { await self.store.refresh(probe: true) } - } - .buttonStyle(.bordered) - .disabled(self.store.isRefreshing) - } - .font(.caption) } - .frame(maxWidth: .infinity, alignment: .leading) + + self.configStatusMessage + + HStack(spacing: 12) { + Button { + Task { await self.store.saveSignalConfig() } + } label: { + if self.store.isSavingConfig { + ProgressView().controlSize(.small) + } else { + Text("Save") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.store.isSavingConfig) + + Spacer() + } + .font(.caption) } } private var imessageSection: some View { - GroupBox("iMessage") { - VStack(alignment: .leading, spacing: 10) { - self.providerHeader( - title: "iMessage (imsg)", - color: self.imessageTint, - subtitle: self.imessageSummary) - - if let details = self.imessageDetails { - Text(details) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - if let status = self.store.configStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Divider().padding(.vertical, 2) - - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + VStack(alignment: .leading, spacing: 16) { + self.formSection("Connection") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Enabled") Toggle("", isOn: self.$store.imessageEnabled) @@ -739,6 +826,11 @@ struct ConnectionsSettings: View { .labelsHidden() .pickerStyle(.menu) } + } + } + + self.formSection("Behavior") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { self.gridLabel("Region") TextField("US", text: self.$store.imessageRegion) @@ -761,31 +853,26 @@ struct ConnectionsSettings: View { .textFieldStyle(.roundedBorder) } } - - HStack(spacing: 12) { - Button { - Task { await self.store.saveIMessageConfig() } - } label: { - if self.store.isSavingConfig { - ProgressView().controlSize(.small) - } else { - Text("Save") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.store.isSavingConfig) - - Spacer() - - Button("Refresh") { - Task { await self.store.refresh(probe: true) } - } - .buttonStyle(.bordered) - .disabled(self.store.isRefreshing) - } - .font(.caption) } - .frame(maxWidth: .infinity, alignment: .leading) + + self.configStatusMessage + + HStack(spacing: 12) { + Button { + Task { await self.store.saveIMessageConfig() } + } label: { + if self.store.isSavingConfig { + ProgressView().controlSize(.small) + } else { + Text("Save") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.store.isSavingConfig) + + Spacer() + } + .font(.caption) } } @@ -1025,6 +1112,24 @@ struct ConnectionsSettings: View { } } + private var enabledProviders: [ConnectionProvider] { + self.orderedProviders.filter { self.providerEnabled($0) } + } + + private var availableProviders: [ConnectionProvider] { + self.orderedProviders.filter { !self.providerEnabled($0) } + } + + private func ensureSelection() { + guard let selected = self.selectedProvider else { + self.selectedProvider = self.orderedProviders.first + return + } + if !self.orderedProviders.contains(selected) { + self.selectedProvider = self.orderedProviders.first + } + } + private func providerEnabled(_ provider: ConnectionProvider) -> Bool { switch provider { case .whatsapp: @@ -1061,26 +1166,106 @@ struct ConnectionsSettings: View { } } - private func providerHeader(title: String, color: Color, subtitle: String) -> some View { - HStack(spacing: 10) { - Circle() - .fill(color) - .frame(width: 10, height: 10) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.headline) - Text(subtitle) - .font(.caption) - .foregroundStyle(color) - } - Spacer() + @ViewBuilder + private var configStatusMessage: some View { + if let status = self.store.configStatus { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func providerTint(_ provider: ConnectionProvider) -> Color { + switch provider { + case .whatsapp: + self.whatsAppTint + case .telegram: + self.telegramTint + case .discord: + self.discordTint + case .signal: + self.signalTint + case .imessage: + self.imessageTint + } + } + + private func providerSummary(_ provider: ConnectionProvider) -> String { + switch provider { + case .whatsapp: + self.whatsAppSummary + case .telegram: + self.telegramSummary + case .discord: + self.discordSummary + case .signal: + self.signalSummary + case .imessage: + self.imessageSummary + } + } + + private func providerDetails(_ provider: ConnectionProvider) -> String? { + switch provider { + case .whatsapp: + self.whatsAppDetails + case .telegram: + self.telegramDetails + case .discord: + self.discordDetails + case .signal: + self.signalDetails + case .imessage: + self.imessageDetails + } + } + + private func providerLastCheckText(_ provider: ConnectionProvider) -> String { + guard let date = self.providerLastCheck(provider) else { return "never" } + return relativeAge(from: date) + } + + private func providerLastCheck(_ provider: ConnectionProvider) -> Date? { + switch provider { + case .whatsapp: + guard let status = self.store.snapshot?.whatsapp else { return nil } + return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) + case .telegram: + return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt) + case .discord: + return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt) + case .signal: + return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt) + case .imessage: + return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt) + } + } + + private func providerHasError(_ provider: ConnectionProvider) -> Bool { + switch provider { + case .whatsapp: + guard let status = self.store.snapshot?.whatsapp else { return false } + return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true + case .telegram: + guard let status = self.store.snapshot?.telegram else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case .discord: + guard let status = self.store.snapshot?.discord else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case .signal: + guard let status = self.store.snapshot?.signal else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case .imessage: + guard let status = self.store.snapshot?.imessage else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false } } private func gridLabel(_ text: String) -> some View { Text(text) .font(.callout.weight(.semibold)) - .frame(width: 120, alignment: .leading) + .frame(width: 140, alignment: .leading) } private func date(fromMs ms: Double?) -> Date? {