fix: restructure macOS connections settings
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
- 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: scope the Discord tool to Discord surface runs.
|
||||||
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
|
- 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.
|
- 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.
|
- 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.
|
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ConnectionsSettings: View {
|
struct ConnectionsSettings: View {
|
||||||
private enum ConnectionProvider: String, CaseIterable, Identifiable {
|
private enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable {
|
||||||
case whatsapp
|
case whatsapp
|
||||||
case telegram
|
case telegram
|
||||||
case discord
|
case discord
|
||||||
@@ -20,9 +20,40 @@ struct ConnectionsSettings: View {
|
|||||||
case .imessage: 4
|
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
|
@Bindable var store: ConnectionsStore
|
||||||
|
@State private var selectedProvider: ConnectionProvider? = nil
|
||||||
@State private var showTelegramToken = false
|
@State private var showTelegramToken = false
|
||||||
@State private var showDiscordToken = false
|
@State private var showDiscordToken = false
|
||||||
|
|
||||||
@@ -31,47 +62,189 @@ struct ConnectionsSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
NavigationSplitView {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
self.sidebar
|
||||||
self.header
|
} detail: {
|
||||||
ForEach(self.orderedProviders) { provider in
|
self.detail
|
||||||
self.providerSection(provider)
|
}
|
||||||
|
.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)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
}
|
}
|
||||||
.onAppear { self.store.start() }
|
|
||||||
.onDisappear { self.store.stop() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
HStack(spacing: 8) {
|
||||||
Text("Connections")
|
Circle()
|
||||||
.font(.title3.weight(.semibold))
|
.fill(self.providerTint(provider))
|
||||||
Text("Link and monitor messaging providers.")
|
.frame(width: 8, height: 8)
|
||||||
.font(.callout)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.foregroundStyle(.secondary)
|
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 {
|
private func statusBadge(_ text: String, color: Color) -> some View {
|
||||||
GroupBox("WhatsApp") {
|
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) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
self.providerHeader(
|
content()
|
||||||
title: "WhatsApp Web",
|
}
|
||||||
color: self.whatsAppTint,
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
subtitle: self.whatsAppSummary)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let details = self.whatsAppDetails {
|
@ViewBuilder
|
||||||
Text(details)
|
private func providerHeaderActions(_ provider: ConnectionProvider) -> some View {
|
||||||
.font(.caption)
|
HStack(spacing: 8) {
|
||||||
.foregroundStyle(.secondary)
|
if provider == .whatsapp {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
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 {
|
if let message = self.store.whatsappLoginMessage {
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -105,52 +278,16 @@ struct ConnectionsSettings: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(self.store.whatsappBusy)
|
.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)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var telegramSection: some View {
|
private var telegramSection: some View {
|
||||||
GroupBox("Telegram") {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
self.formSection("Authentication") {
|
||||||
self.providerHeader(
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
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) {
|
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Bot token")
|
self.gridLabel("Bot token")
|
||||||
if self.showTelegramToken {
|
if self.showTelegramToken {
|
||||||
@@ -166,6 +303,11 @@ struct ConnectionsSettings: View {
|
|||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
.disabled(self.isTelegramTokenLocked)
|
.disabled(self.isTelegramTokenLocked)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Access") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Require mention")
|
self.gridLabel("Require mention")
|
||||||
Toggle("", isOn: self.$store.telegramRequireMention)
|
Toggle("", isOn: self.$store.telegramRequireMention)
|
||||||
@@ -177,11 +319,11 @@ struct ConnectionsSettings: View {
|
|||||||
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
|
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
|
||||||
.textFieldStyle(.roundedBorder)
|
.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 {
|
GridRow {
|
||||||
self.gridLabel("Webhook URL")
|
self.gridLabel("Webhook URL")
|
||||||
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
|
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
|
||||||
@@ -198,71 +340,49 @@ struct ConnectionsSettings: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.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 {
|
private var discordSection: some View {
|
||||||
GroupBox("Discord") {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
self.formSection("Authentication") {
|
||||||
self.providerHeader(
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
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) {
|
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Enabled")
|
self.gridLabel("Enabled")
|
||||||
Toggle("", isOn: self.$store.discordEnabled)
|
Toggle("", isOn: self.$store.discordEnabled)
|
||||||
@@ -284,6 +404,11 @@ struct ConnectionsSettings: View {
|
|||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
.disabled(self.isDiscordTokenLocked)
|
.disabled(self.isDiscordTokenLocked)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Messages") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Allow DMs from")
|
self.gridLabel("Allow DMs from")
|
||||||
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
||||||
@@ -306,6 +431,20 @@ struct ConnectionsSettings: View {
|
|||||||
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
|
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
|
||||||
.textFieldStyle(.roundedBorder)
|
.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 {
|
GridRow {
|
||||||
self.gridLabel("Media max MB")
|
self.gridLabel("Media max MB")
|
||||||
TextField("8", text: self.$store.discordMediaMaxMb)
|
TextField("8", text: self.$store.discordMediaMaxMb)
|
||||||
@@ -321,17 +460,13 @@ struct ConnectionsSettings: View {
|
|||||||
TextField("2000", text: self.$store.discordTextChunkLimit)
|
TextField("2000", text: self.$store.discordTextChunkLimit)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Slash command") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Reply to mode")
|
self.gridLabel("Enabled")
|
||||||
Picker("", selection: self.$store.discordReplyToMode) {
|
|
||||||
Text("off").tag("off")
|
|
||||||
Text("first").tag("first")
|
|
||||||
Text("all").tag("all")
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
}
|
|
||||||
GridRow {
|
|
||||||
self.gridLabel("Slash command")
|
|
||||||
Toggle("", isOn: self.$store.discordSlashEnabled)
|
Toggle("", isOn: self.$store.discordSlashEnabled)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
@@ -342,24 +477,20 @@ struct ConnectionsSettings: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Slash session prefix")
|
self.gridLabel("Session prefix")
|
||||||
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
|
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Slash ephemeral")
|
self.gridLabel("Ephemeral")
|
||||||
Toggle("", isOn: self.$store.discordSlashEphemeral)
|
Toggle("", isOn: self.$store.discordSlashEphemeral)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Divider().padding(.vertical, 2)
|
GroupBox("Guilds") {
|
||||||
|
|
||||||
Text("Guilds")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ForEach($store.discordGuilds) { $guild in
|
ForEach($store.discordGuilds) { $guild in
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
@@ -372,7 +503,7 @@ struct ConnectionsSettings: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Slug")
|
self.gridLabel("Slug")
|
||||||
TextField("optional slug", text: $guild.slug)
|
TextField("optional slug", text: $guild.slug)
|
||||||
@@ -426,14 +557,11 @@ struct ConnectionsSettings: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
Divider().padding(.vertical, 2)
|
GroupBox("Tool actions") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
Text("Tool actions")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Reactions")
|
self.gridLabel("Reactions")
|
||||||
Toggle("", isOn: self.$store.discordActionReactions)
|
Toggle("", isOn: self.$store.discordActionReactions)
|
||||||
@@ -525,65 +653,40 @@ struct ConnectionsSettings: View {
|
|||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
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 {
|
private var signalSection: some View {
|
||||||
GroupBox("Signal") {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
self.formSection("Connection") {
|
||||||
self.providerHeader(
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
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) {
|
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Enabled")
|
self.gridLabel("Enabled")
|
||||||
Toggle("", isOn: self.$store.signalEnabled)
|
Toggle("", isOn: self.$store.signalEnabled)
|
||||||
@@ -615,6 +718,11 @@ struct ConnectionsSettings: View {
|
|||||||
TextField("signal-cli", text: self.$store.signalCliPath)
|
TextField("signal-cli", text: self.$store.signalCliPath)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Behavior") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Auto start")
|
self.gridLabel("Auto start")
|
||||||
Toggle("", isOn: self.$store.signalAutoStart)
|
Toggle("", isOn: self.$store.signalAutoStart)
|
||||||
@@ -649,6 +757,11 @@ struct ConnectionsSettings: View {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Access & limits") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Allow from")
|
self.gridLabel("Allow from")
|
||||||
TextField("12345, +1555", text: self.$store.signalAllowFrom)
|
TextField("12345, +1555", text: self.$store.signalAllowFrom)
|
||||||
@@ -660,59 +773,33 @@ struct ConnectionsSettings: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.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 {
|
private var imessageSection: some View {
|
||||||
GroupBox("iMessage") {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
self.formSection("Connection") {
|
||||||
self.providerHeader(
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
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) {
|
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Enabled")
|
self.gridLabel("Enabled")
|
||||||
Toggle("", isOn: self.$store.imessageEnabled)
|
Toggle("", isOn: self.$store.imessageEnabled)
|
||||||
@@ -739,6 +826,11 @@ struct ConnectionsSettings: View {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Behavior") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Region")
|
self.gridLabel("Region")
|
||||||
TextField("US", text: self.$store.imessageRegion)
|
TextField("US", text: self.$store.imessageRegion)
|
||||||
@@ -761,31 +853,26 @@ struct ConnectionsSettings: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.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 {
|
private func providerEnabled(_ provider: ConnectionProvider) -> Bool {
|
||||||
switch provider {
|
switch provider {
|
||||||
case .whatsapp:
|
case .whatsapp:
|
||||||
@@ -1061,26 +1166,106 @@ struct ConnectionsSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
|
@ViewBuilder
|
||||||
HStack(spacing: 10) {
|
private var configStatusMessage: some View {
|
||||||
Circle()
|
if let status = self.store.configStatus {
|
||||||
.fill(color)
|
Text(status)
|
||||||
.frame(width: 10, height: 10)
|
.font(.caption)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.foregroundStyle(.secondary)
|
||||||
Text(title)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.font(.headline)
|
}
|
||||||
Text(subtitle)
|
}
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(color)
|
private func providerTint(_ provider: ConnectionProvider) -> Color {
|
||||||
}
|
switch provider {
|
||||||
Spacer()
|
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 {
|
private func gridLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.frame(width: 120, alignment: .leading)
|
.frame(width: 140, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func date(fromMs ms: Double?) -> Date? {
|
private func date(fromMs ms: Double?) -> Date? {
|
||||||
|
|||||||
Reference in New Issue
Block a user