add server info screen, connection stats, and emoji picker
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:
1 parent
a886917dc8
commit
0317277d46
12 files changed
+1081
-24
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,11 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
|
||||
NavigationLink {
|
||||
ServerInfoView(model: model)
|
||||
} label: {
|
||||
Label("Информация о сервере", systemImage: "server.rack")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Шифрование") {
|
||||
|
||||
Reference in new issue
Block a user