Server information (Monal-style): - Add ServerInformation model and ServerInfoView reachable from Settings, showing server software (XEP-0092), disco#info identities and features, conference (MUC) services via disco#items, and STUN/TURN via XEP-0215. - Add a curated XEP capability list with per-XEP success/error status derived from server/account disco features and connection flags. - Detect PEP (0163) from account disco pubsub#* features, HTTP Upload (0363) from the main domain plus its components, and add Roster Versioning (0237), Pre-Authenticated Roster Subscription (0379), and SASL SCRAM Downgrade Protection (0474, neutral because Martin does not expose SSDP). Connection statistics: - Add LumaConnectionStatsModule, an XmppStanzaFilter registered before stream management, to count sent / acknowledged / received stanzas. - Track last login and SMACKS session timestamps, and read SASL mechanisms and Client State Indication from the raw stream features. MAM / OMEMO handling: - Prefer a plaintext <body> fallback when OMEMO decryption fails instead of showing "failed to decrypt". - Keep the optimistic text of our own outgoing message when its echo cannot be decrypted back, instead of replacing it with a placeholder. Emoji picker: - Add EmojiCatalog and a cross-platform EmojiPickerView. - Open the picker for reactions (replacing the static quick list) and from the composer's smiley button, inserting the chosen emoji into the draft.
200 lines
9.2 KiB
Swift
200 lines
9.2 KiB
Swift
import PhotosUI
|
|
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@ObservedObject var model: AppModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var showingSignOutConfirmation = false
|
|
@State private var forgetHistory = false
|
|
@State private var avatarItem: PhotosPickerItem?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
if let account = model.account {
|
|
Section("Профиль") {
|
|
HStack(spacing: 14) {
|
|
AvatarView(
|
|
conversation: Conversation(
|
|
jid: account.normalizedJID,
|
|
displayName: account.displayName.isEmpty ? nil : account.displayName
|
|
),
|
|
imageData: model.avatarData(for: account.normalizedJID),
|
|
size: 64
|
|
)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName)
|
|
.font(.headline)
|
|
PhotosPicker(selection: $avatarItem, matching: .images) {
|
|
Label("Обновить аватар", systemImage: "photo.badge.plus")
|
|
}
|
|
.disabled(model.isUpdatingAvatar)
|
|
}
|
|
Spacer()
|
|
if model.isUpdatingAvatar {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Аккаунт") {
|
|
LabeledContent("JID", value: account.normalizedJID)
|
|
LabeledContent("Ресурс", value: account.effectiveResource)
|
|
if let host = account.manualHost {
|
|
LabeledContent("Сервер", value: "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))")
|
|
} else {
|
|
LabeledContent("Обнаружение", value: "DNS SRV")
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Соединение") {
|
|
HStack {
|
|
Label(connectionTitle, systemImage: connectionIcon)
|
|
.foregroundStyle(connectionColor)
|
|
Spacer()
|
|
if model.connectionStatus != .connected {
|
|
Button("Подключить") { Task { await model.reconnect() } }
|
|
}
|
|
}
|
|
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
|
|
NavigationLink {
|
|
ServerInfoView(model: model)
|
|
} label: {
|
|
Label("Информация о сервере", systemImage: "server.rack")
|
|
}
|
|
}
|
|
|
|
Section("Шифрование") {
|
|
Toggle("OMEMO по умолчанию", isOn: Binding(
|
|
get: { model.globalEncryptionEnabled },
|
|
set: { model.setGlobalEncryptionEnabled($0) }
|
|
))
|
|
Text("Для отдельного чата режим можно изменить через значок замка в его панели.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
if !model.globalEncryptionEnabled {
|
|
Label("Новые сообщения без отдельного правила отправляются открытым текстом.", systemImage: "exclamationmark.triangle.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
LabeledContent("Состояние", value: model.isOMEMOReady ? "Активно" : "Подготовка")
|
|
if let fingerprint = model.ownFingerprint {
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
Text("Отпечаток этого устройства")
|
|
.font(.subheadline)
|
|
Text(fingerprint.chunked(every: 8).joined(separator: " "))
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Уведомления") {
|
|
Label("Локальные уведомления включаются после входа", systemImage: "bell.badge")
|
|
Text("Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Section("Приватность чата") {
|
|
Toggle("Показывать статус набора", isOn: Binding(
|
|
get: { model.typingIndicatorsEnabled },
|
|
set: { model.setTypingIndicatorsEnabled($0) }
|
|
))
|
|
Text("Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Section {
|
|
Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory)
|
|
|
|
Button(role: .destructive) {
|
|
showingSignOutConfirmation = true
|
|
} label: {
|
|
Label("Выйти из аккаунта", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Настройки")
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Готово") { dismiss() }
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Выйти из Luma?",
|
|
isPresented: $showingSignOutConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Выйти", role: .destructive) {
|
|
Task {
|
|
await model.signOut(forgetHistory: forgetHistory)
|
|
dismiss()
|
|
}
|
|
}
|
|
Button("Отмена", role: .cancel) {}
|
|
} message: {
|
|
Text(forgetHistory
|
|
? "Пароль и локальная история будут удалены. Серверная история MAM не изменится."
|
|
: "Пароль будет удалён из Keychain. Серверная история MAM не изменится.")
|
|
}
|
|
.onChange(of: avatarItem) { _, item in
|
|
guard let item else { return }
|
|
Task {
|
|
do {
|
|
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
|
await model.updateOwnAvatar(from: data)
|
|
avatarItem = nil
|
|
} catch {
|
|
model.errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 500, minHeight: 560)
|
|
#endif
|
|
}
|
|
|
|
private var connectionTitle: String {
|
|
switch model.connectionStatus {
|
|
case .connected: return "Подключено"
|
|
case .connecting: return "Подключение"
|
|
case .disconnected: return "Отключено"
|
|
}
|
|
}
|
|
|
|
private var connectionIcon: String {
|
|
switch model.connectionStatus {
|
|
case .connected: return "checkmark.circle.fill"
|
|
case .connecting: return "arrow.triangle.2.circlepath"
|
|
case .disconnected: return "xmark.circle.fill"
|
|
}
|
|
}
|
|
|
|
private var connectionColor: Color {
|
|
switch model.connectionStatus {
|
|
case .connected: return .green
|
|
case .connecting: return .orange
|
|
case .disconnected: return .red
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
func chunked(every size: Int) -> [String] {
|
|
guard size > 0 else { return [self] }
|
|
var result: [String] = []
|
|
var cursor = startIndex
|
|
while cursor < endIndex {
|
|
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
|
|
result.append(String(self[cursor..<end]))
|
|
cursor = end
|
|
}
|
|
return result
|
|
}
|
|
}
|