add server info screen, connection stats, and emoji picker
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

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.
This commit is contained in:
wt committed 2026-08-18 21:44:27 +07:00
1 parent a886917dc8
commit 0317277d46
12 files changed
+1081 -24

No files matched your search

+44 -5
View File
@@ -9,6 +9,18 @@ import UniformTypeIdentifiers
import UIKit
#endif
private enum EmojiPickerPresentation: Identifiable {
case reaction(ChatMessage)
case composer
var id: String {
switch self {
case .reaction(let message): return "reaction-\(message.id)"
case .composer: return "composer"
}
}
}
@MainActor
struct ChatView: View {
private static let bottomAnchorID = "luma-chat-timeline-bottom"
@@ -42,6 +54,7 @@ struct ChatView: View {
@State private var selectedMessageIDs: Set<String> = []
@State private var destructiveAction: MessageDestructiveAction?
@State private var replyThreadSelection: ReplyThreadSelection?
@State private var emojiPickerPresentation: EmojiPickerPresentation?
@State private var hasCompletedInitialScroll = false
@State private var isNearTimelineBottom = true
@State private var historyLoadAnchorID: String?
@@ -223,6 +236,11 @@ struct ChatView: View {
clearMessageSelection()
}
}
.sheet(item: $emojiPickerPresentation) { presentation in
EmojiPickerView { emoji in
handleEmojiSelection(emoji, for: presentation)
}
}
.alert(item: $destructiveAction) { action in
Alert(
title: Text(action.title),
@@ -404,6 +422,9 @@ struct ChatView: View {
},
onReact: { emoji in
Task { await model.toggleReaction(emoji, on: message) }
},
onReactPicker: {
emojiPickerPresentation = .reaction(message)
}
)
.id(message.id)
@@ -608,11 +629,16 @@ struct ChatView: View {
.submitLabel(.send)
.onSubmit { sendDraft() }
Image(systemName: "face.smiling")
.font(.system(size: 20, weight: .regular))
.foregroundStyle(.secondary)
.padding(.bottom, 1)
.accessibilityHidden(true)
Button {
emojiPickerPresentation = .composer
} label: {
Image(systemName: "face.smiling")
.font(.system(size: 20, weight: .regular))
.foregroundStyle(.secondary)
.padding(.bottom, 1)
}
.buttonStyle(.plain)
.accessibilityLabel("Эмодзи")
}
.padding(.leading, 14)
.padding(.trailing, 12)
@@ -782,6 +808,7 @@ struct ChatView: View {
}
.animation(.easeInOut(duration: 0.16), value: model.typingText(for: liveConversation))
.accessibilityElement(children: .combine)
.padding()
}
private var canSendToConversation: Bool {
@@ -1385,6 +1412,18 @@ struct ChatView: View {
isComposerFocused = true
}
private func handleEmojiSelection(
_ emoji: String,
for presentation: EmojiPickerPresentation
) {
switch presentation {
case .reaction(let message):
Task { await model.toggleReaction(emoji, on: message) }
case .composer:
draft.append(emoji)
}
}
private func beginMessageSelection(with message: ChatMessage) {
cancelComposerRecording(feedback: false)
dismissKeyboard()
@@ -0,0 +1,92 @@
import SwiftUI
/// A reusable emoji picker: category tabs on the bottom and a grid of emojis
/// for the selected category. Used both for reactions and for inserting an
/// emoji into the composer.
struct EmojiPickerView: View {
let onSelect: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var selectedCategoryID = EmojiCatalog.categories.first?.id ?? ""
private let columns = [GridItem(.adaptive(minimum: 44), spacing: 4)]
private var selectedCategory: EmojiCategory {
EmojiCatalog.categories.first { $0.id == selectedCategoryID }
?? EmojiCatalog.categories[0]
}
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
LazyVGrid(columns: columns, spacing: 6) {
ForEach(selectedCategory.emojis, id: \.self) { emoji in
Button {
onSelect(emoji)
dismiss()
} label: {
Text(emoji)
.font(.system(size: 28))
.frame(width: 46, height: 46)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(emoji)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 12)
}
Divider()
categorySelector
.padding(.vertical, 8)
}
.frame(minHeight: 360)
#if os(macOS)
.frame(minWidth: 380)
#endif
}
private var header: some View {
HStack {
Text("Выберите эмодзи")
.font(.headline)
Spacer()
Button("Готово") { dismiss() }
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var categorySelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(EmojiCatalog.categories) { category in
let isSelected = category.id == selectedCategoryID
Button {
selectedCategoryID = category.id
} label: {
Image(systemName: category.symbol)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 44, height: 44)
.background(
isSelected
? Color.accentColor.opacity(0.15)
: Color.clear,
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
)
}
.buttonStyle(.plain)
.accessibilityLabel(category.title)
}
}
.padding(.horizontal, 12)
}
}
}
@@ -23,6 +23,7 @@ struct MessageBubble: View {
let onDelete: () -> Void
let onReplyTap: (String) -> Void
let onReact: (String) -> Void
let onReactPicker: () -> Void
@State private var replySwipeOffset: CGFloat = 0
@State private var replySwipeArmed = false
@@ -56,11 +57,7 @@ struct MessageBubble: View {
.contextMenu {
if !isSelectionMode {
if message.canBeReactedTo {
Menu {
ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in
Button(emoji) { onReact(emoji) }
}
} label: {
Button(action: onReactPicker) {
Label("Реакция", systemImage: "face.smiling")
}
}
+279
View File
@@ -0,0 +1,279 @@
import SwiftUI
/// "Server information" screen shown from Settings, mirroring Monal's
/// `ServerDetails`: a grouped list of headline + caption entries whose row
/// background is tinted green/orange/red according to the detected status.
struct ServerInfoView: View {
@ObservedObject var model: AppModel
@State private var isLoading = false
var body: some View {
List {
if let info = model.serverInformation {
Section {
statisticsEntries(info)
} header: {
Text("Это статистика вашего соединения.")
}
Section {
softwareEntry(info)
} header: {
Text("Это программное обеспечение, работающее на вашем сервере.")
}
Section {
connectionRows()
} header: {
Text("Параметры подключения к вашему серверу.")
}
Section {
ForEach(info.capabilities) { capability in
entry(
title: capability.title,
detail: capability.detail,
status: capability.status
)
}
} header: {
Text("Современные возможности XMPP, обнаруженные на вашем сервере после входа.")
}
Section {
mucEntries(info)
} header: {
Text("MUC-серверы, обнаруженные на вашем сервере.")
}
Section {
stunTurnEntries(info)
} header: {
Text("STUN и TURN сервисы, объявленные вашим сервером.")
}
Section {
saslEntries(info)
} header: {
Text("Методы аутентификации SASL, которые поддерживает ваш сервер.")
}
} else if isLoading {
Section {
HStack(spacing: 12) {
ProgressView()
Text("Загрузка информации о сервере…")
.foregroundStyle(.secondary)
}
}
} else {
Section {
ContentUnavailableView(
"Нет данных о сервере",
systemImage: "server.rack",
description: Text(
model.connectionStatus == .connected
? "Сервер не ответил на запрос. Попробуйте обновить."
: "Подключитесь к серверу, чтобы увидеть его возможности."
)
)
}
}
}
.navigationTitle(model.account?.domain ?? "Сервер")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await load() }
} label: {
Label("Обновить", systemImage: "arrow.clockwise")
}
.disabled(isLoading)
}
}
.task { await load() }
#if os(macOS)
.listStyle(.inset)
.frame(minWidth: 480, minHeight: 420)
#else
.listStyle(.grouped)
#endif
}
// MARK: - Sections
private func softwareEntry(_ info: ServerInformation) -> some View {
let name = info.software.name ?? "Неизвестный сервер"
let version = info.software.version ?? "неизвестная версия"
let platform = info.software.os.map { ", работает на \($0)" } ?? ""
return VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(.headline)
Text("версия \(version)\(platform)")
.font(.caption)
.foregroundStyle(.secondary)
Link(
"Рекомендации для администраторов серверов",
destination: URL(
string: "https://github.com/monal-im/Monal/wiki/Considerations-for-XMPP-server-admins"
)!
)
.font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear)
}
private func connectionRows() -> some View {
Group {
if let account = model.account {
if let host = account.manualHost {
let port = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222)
LabeledContent("Хост", value: "\(host):\(port)")
LabeledContent(
"Шифрование",
value: account.usesDirectTLS ? "Direct TLS" : "STARTTLS"
)
} else {
LabeledContent("Хост", value: "DNS SRV")
}
LabeledContent("Ресурс", value: account.effectiveResource)
}
}
}
@ViewBuilder
private func mucEntries(_ info: ServerInformation) -> some View {
if info.conferenceServers.isEmpty {
entry(
title: "Нет",
detail: "Этот сервер не предоставляет MUC-серверы.",
status: .error
)
} else {
ForEach(info.conferenceServers) { server in
entry(
title: "Сервер: \(server.jid)",
detail: "\(server.name ?? "<неизвестное имя>") (тип «\(server.type)», категория «\(server.category)»)",
status: server.type == "text" ? .success : .normal
)
}
}
}
@ViewBuilder
private func stunTurnEntries(_ info: ServerInformation) -> some View {
if info.externalServices.isEmpty {
entry(
title: "Нет",
detail: "Этот сервер не предоставляет STUN / TURN сервисы.",
status: .error
)
} else {
ForEach(info.externalServices) { service in
let endpoint = "\(service.host):\(service.port.map(String.init) ?? "-")"
entry(
title: service.type.uppercased(),
detail: endpoint,
status: ["stun", "turn", "stuns", "turns"].contains(service.type.lowercased())
? .success
: .error
)
}
}
}
@ViewBuilder
private func statisticsEntries(_ info: ServerInformation) -> some View {
let stats = info.connectionStats
entry(
title: "Последний вход",
detail: formatDate(stats.lastLogin),
status: .normal
)
entry(
title: "Сессия Stream Management установлена",
detail: formatDate(stats.smacksSessionEstablished),
status: .normal
)
entry(
title: "Станз отправлено / не подтверждено / получено в этой сессии",
detail: "\(stats.sent) / \(stats.unacknowledged) / \(stats.received)",
status: .normal
)
}
@ViewBuilder
private func saslEntries(_ info: ServerInformation) -> some View {
if info.saslMethods.isEmpty {
entry(
title: "Нет",
detail: "Сервер не объявил методы SASL-аутентификации.",
status: .error
)
} else {
ForEach(info.saslMethods, id: \.self) { method in
entry(
title: "Метод: \(method)",
detail: saslDescription(method),
status: method == "PLAIN" ? .warning : .normal
)
}
}
}
private func saslDescription(_ method: String) -> String {
switch method {
case "PLAIN":
return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно."
case "EXTERNAL":
return "Использует TLS-клиентские сертификаты для аутентификации."
case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"):
return "Salted Challenge Response Authentication Mechanism с channel-binding."
case let method where method.hasPrefix("SCRAM-"):
return "Salted Challenge Response Authentication Mechanism на указанном хэше."
default:
return "Неизвестный метод аутентификации."
}
}
private func formatDate(_ date: Date?) -> String {
guard let date else { return "<неизвестно>" }
return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short)
}
// MARK: - Entry
private func entry(
title: String,
detail: String,
status: ServerInformation.CapabilityStatus
) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(status.color)
}
private func load() async {
guard model.connectionStatus == .connected else { return }
isLoading = true
await model.refreshServerInformation()
isLoading = false
}
}
private extension ServerInformation.CapabilityStatus {
var color: Color {
switch self {
case .success: return Color.green.opacity(0.16)
case .normal: return Color.clear
case .warning: return Color.orange.opacity(0.16)
case .error: return Color.red.opacity(0.16)
}
}
}
+5
View File
@@ -58,6 +58,11 @@ struct SettingsView: View {
}
}
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
NavigationLink {
ServerInfoView(model: model)
} label: {
Label("Информация о сервере", systemImage: "server.rack")
}
}
Section("Шифрование") {