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

@@ -0,0 +1,68 @@
import Foundation
import Martin
/// Counts XEP-0198 stanza statistics (sent / acknowledged / received) at the
/// stream level so the server-details screen can mirror Monal's connection
/// statistics. Registered as an `XmppStanzaFilter` *before* stream management
/// so it can also observe the server's `<a h='N'>` acknowledgements.
final class LumaConnectionStatsModule: XmppModuleBase, XmppModule, XmppStanzaFilter {
static let ID = "lumaConnectionStats"
static let smNamespace = "urn:xmpp:sm:3"
let criteria = Criteria.empty()
let features: [String] = []
private let lock = NSLock()
private var sent: UInt32 = 0
private var received: UInt32 = 0
private var acknowledged: UInt32 = 0
struct Snapshot: Equatable, Sendable {
let sent: UInt32
let acknowledged: UInt32
let received: UInt32
var unacknowledged: UInt32 {
sent > acknowledged ? sent - acknowledged : 0
}
}
var snapshot: Snapshot {
lock.lock()
defer { lock.unlock() }
return Snapshot(sent: sent, acknowledged: acknowledged, received: received)
}
func processIncoming(stanza: Stanza) -> Bool {
if stanza.xmlns == Self.smNamespace, stanza.name == "a" {
if let raw = stanza.getAttribute("h"), let value = UInt32(raw) {
lock.lock()
acknowledged = value
lock.unlock()
}
// Let StreamManagementModule consume the acknowledgement too.
return false
}
if Self.isCountable(stanza) {
lock.lock()
received += 1
lock.unlock()
}
return false
}
func processOutgoing(stanza: Stanza) {
guard Self.isCountable(stanza) else { return }
lock.lock()
sent += 1
lock.unlock()
}
func process(stanza: Stanza) throws {
// Criteria is empty, so this module is never selected by the dispatcher.
}
private static func isCountable(_ stanza: Stanza) -> Bool {
stanza.name == "iq" || stanza.name == "message" || stanza.name == "presence"
}
}
+170 -12
View File
@@ -263,6 +263,9 @@ final class XMPPService {
private let callEngine: LumaCallEngine
private var client: XMPPClient?
private var omemoStorage: LumaOMEMOStore?
private var connectionStatsModule: LumaConnectionStatsModule?
private var lastLoginDate: Date?
private var smacksSessionEstablishedDate: Date?
private var cancellables: Set<AnyCancellable> = []
private var account: AccountConfiguration?
private var archiveSyncStarted = false
@@ -435,6 +438,12 @@ final class XMPPService {
do {
try await client.loginAndWait()
let now = Date()
lastLoginDate = now
let streamManagement = client.module(.streamManagement)
if streamManagement.ackEnabled || streamManagement.resumptionEnabled {
smacksSessionEstablishedDate = now
}
eventHandler?(.connection(.connected))
fetchAvatar(for: account.normalizedJID)
startArchiveSyncIfAvailable()
@@ -493,6 +502,9 @@ final class XMPPService {
roomRealJIDByNickname.removeAll()
omemoConfiguredRoomJIDs.removeAll()
knownChatStatePeers.removeAll()
connectionStatsModule = nil
lastLoginDate = nil
smacksSessionEstablishedDate = nil
eventHandler?(.connection(.disconnected(reason: nil)))
}
@@ -1354,6 +1366,131 @@ final class XMPPService {
ownFingerprint: omemoStorage?.ownFingerprint))
}
/// Queries the server's capabilities to populate the "server information"
/// settings screen: XEP-0030 disco#info (server + account), XEP-0092
/// software version, conference (MUC) services via disco#items, and
/// STUN/TURN services via XEP-0215.
func fetchServerInformation() async throws -> ServerInformation {
guard let client, client.state == .connected(), let account else {
throw LumaXMPPError.notConnected
}
let disco = client.module(.disco)
let versionModule = client.module(.softwareVersion)
let serverDomain = account.domain ?? client.userBareJid.domain
// Discover features, software version, server components and external
// services in parallel. The version/components/services queries are
// best-effort: not every server implements those XEPs, so they degrade
// to nil instead of failing the whole screen.
async let serverInfo = disco.serverFeatures()
async let accountInfo = disco.accountFeatures()
async let software = try? versionModule.checkSoftwareVersion(
for: JID(serverDomain)
)
async let components = try? disco.serverComponents()
async let services = try? client.module(.externalServiceDiscovery)
.discover(from: nil, type: nil)
let server = try await serverInfo
let accountDisco = try await accountInfo
let version = await software
let componentItems = await components
let extServices = await services
// Resolve each server component to its disco identity. Keep conference
// (MUC) services, and also detect HTTP File Upload when it is announced
// on a component (e.g. upload.<domain>) rather than the main domain.
var conferenceServers: [ServerInformation.ConferenceServer] = []
var uploadOnComponent = false
if let componentItems {
for item in componentItems.items {
guard let info = try? await disco.info(for: item.jid) else { continue }
if info.features.contains("urn:xmpp:http:upload:0")
|| info.features.contains("http://jabber.org/protocol/httpupload")
{
uploadOnComponent = true
}
for identity in info.identities where identity.category == "conference" {
conferenceServers.append(
ServerInformation.ConferenceServer(
jid: item.jid.stringValue,
name: identity.name,
type: identity.type,
category: identity.category
)
)
}
}
}
let externalServices = (extServices ?? []).filter {
["stun", "turn", "stuns", "turns"].contains($0.type.lowercased())
}.map {
ServerInformation.ExternalService(
type: $0.type,
host: $0.host,
port: $0.port,
transport: $0.transport?.rawValue
)
}
// SASL mechanisms and Client State Indication are advertised in the raw
// stream <features>, not in disco#info, so read them from there.
let streamFeaturesElement = client.module(.streamFeatures).streamFeatures.element
let saslMechanisms = streamFeaturesElement?
.findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")?
.children
.filter { $0.name == "mechanism" }
.compactMap { $0.value } ?? []
let supportsCSI = streamFeaturesElement?
.findChild(name: "csi", xmlns: "urn:xmpp:csi:0") != nil
let supportsRosterVersioning = streamFeaturesElement?
.findChild(name: "ver", xmlns: "urn:xmpp:features:rosterver") != nil
let supportsRosterPreApproval = streamFeaturesElement?
.findChild(name: "sub", xmlns: "urn:xmpp:features:pre-approval") != nil
let uploadOnServer = server.features.contains("urn:xmpp:http:upload:0")
|| server.features.contains("http://jabber.org/protocol/httpupload")
let supportsHTTPUpload = uploadOnServer || uploadOnComponent
let stats = connectionStatsModule?.snapshot
?? LumaConnectionStatsModule.Snapshot(sent: 0, acknowledged: 0, received: 0)
return ServerInformation(
serverDomain: serverDomain,
software: ServerInformation.Software(
name: version?.name,
version: version?.version,
os: version?.os
),
identities: server.identities.map {
ServerInformation.Identity(
category: $0.category,
type: $0.type,
name: $0.name
)
},
serverFeatures: server.features,
accountFeatures: accountDisco.features,
conferenceServers: conferenceServers,
externalServices: externalServices,
connectionStats: ServerInformation.ConnectionStats(
lastLogin: lastLoginDate,
smacksSessionEstablished: smacksSessionEstablishedDate,
sent: stats.sent,
acknowledged: stats.acknowledged,
received: stats.received
),
saslMethods: saslMechanisms,
supportsStreamManagement: client.module(.streamManagement).isAvailable,
supportsCarbons: client.module(.messageCarbons).isAvailable,
supportsClientState: supportsCSI,
supportsHTTPUpload: supportsHTTPUpload,
supportsRosterVersioning: supportsRosterVersioning,
supportsRosterPreApproval: supportsRosterPreApproval
)
}
func addToRoster(jid: String, name: String?) {
guard let client, client.state == .connected() else { return }
client.module(.roster).addItem(
@@ -1414,6 +1551,11 @@ final class XMPPService {
) {
_ = client.modulesManager.register(AuthModule())
_ = client.modulesManager.register(StreamFeaturesModule())
// Registered before StreamManagementModule so it can observe the
// server's `<a h='N'>` acknowledgements for the stanza statistics.
connectionStatsModule = client.modulesManager.register(
LumaConnectionStatsModule()
)
_ = client.modulesManager.register(StreamManagementModule())
_ = client.modulesManager.register(SaslModule())
_ = client.modulesManager.register(ResourceBinderModule())
@@ -1770,15 +1912,25 @@ final class XMPPService {
fingerprint = nil
contentMessage = message
default:
// Includes `.duplicateMessage` (our own message without an
// encrypt-to-self key, or a key already consumed by a previous
// session): the body can no longer be recovered here. Still
// emit the message (as an undecryptable bubble) instead of
// dropping it, so history does not silently stop. Genuine
// duplicates are filtered later in AppModel by stanza/origin ID.
security = .decryptionFailed
fingerprint = nil
contentMessage = nil
// Some clients include a plaintext <body> fallback alongside the
// OMEMO <encrypted> payload. When decryption fails, prefer that
// fallback so a readable message is never shown as undecryptable.
if message.body?.isEmpty == false {
security = .plaintext
fingerprint = nil
contentMessage = message
} else {
// Includes `.duplicateMessage` (our own message without an
// encrypt-to-self key, or a key already consumed by a
// previous session): the body can no longer be recovered
// here. Still emit the message (as an undecryptable bubble)
// instead of dropping it, so history does not silently
// stop. Genuine duplicates are filtered later in AppModel
// by stanza/origin ID.
security = .decryptionFailed
fingerprint = nil
contentMessage = nil
}
}
}
@@ -2032,9 +2184,15 @@ final class XMPPService {
)
return
}
security = .decryptionFailed
fingerprint = nil
contentMessage = nil
if message.body?.isEmpty == false {
security = .plaintext
fingerprint = nil
contentMessage = message
} else {
security = .decryptionFailed
fingerprint = nil
contentMessage = nil
}
}
}
} else if encrypted {