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
@@ -41,6 +41,7 @@ final class AppModel: ObservableObject {
|
||||
@Published var selectedConversationID: String?
|
||||
@Published private(set) var isOMEMOReady = false
|
||||
@Published private(set) var ownFingerprint: String?
|
||||
@Published private(set) var serverInformation: ServerInformation?
|
||||
@Published private(set) var isArchiveSyncing = false
|
||||
@Published private(set) var isLoadingOlderHistory = false
|
||||
@Published private(set) var hasMoreOlderHistory = true
|
||||
@@ -1621,6 +1622,15 @@ final class AppModel: ObservableObject {
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func refreshServerInformation() async {
|
||||
do {
|
||||
serverInformation = try await xmpp.fetchServerInformation()
|
||||
} catch {
|
||||
// The screen shows its own error state; keep any previously loaded
|
||||
// snapshot rather than clearing it on a transient refresh failure.
|
||||
}
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
@@ -2521,6 +2531,25 @@ final class AppModel: ObservableObject {
|
||||
merged.security = previous.security
|
||||
merged.encryptionFingerprint = previous.encryptionFingerprint
|
||||
}
|
||||
if message.security == .decryptionFailed,
|
||||
previous.security != .decryptionFailed,
|
||||
!previous.body.isEmpty
|
||||
{
|
||||
// Our own outgoing message was rendered optimistically with the
|
||||
// text the user typed. Its server echo (carbon or MAM) cannot
|
||||
// be decrypted back (no encrypt-to-self key, or a consumed
|
||||
// session), so keep the original content instead of replacing
|
||||
// it with a "failed to decrypt" placeholder.
|
||||
merged.body = previous.body
|
||||
merged.security = previous.security
|
||||
merged.encryptionFingerprint = previous.encryptionFingerprint
|
||||
merged.kind = previous.kind
|
||||
merged.remoteAttachmentURL = previous.remoteAttachmentURL
|
||||
merged.localFilename = previous.localFilename
|
||||
merged.mimeType = previous.mimeType
|
||||
merged.duration = previous.duration
|
||||
merged.byteCount = previous.byteCount
|
||||
}
|
||||
messages[index] = merged
|
||||
if let stanzaID = merged.stanzaID {
|
||||
messageIndexByStanzaKey[
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
|
||||
/// A category of emojis shown in the emoji picker.
|
||||
struct EmojiCategory: Identifiable, Equatable, Sendable {
|
||||
let title: String
|
||||
let symbol: String
|
||||
let emojis: [String]
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
/// Curated, widely-supported emoji set grouped into categories. Used by both
|
||||
/// the reaction picker and the composer's emoji picker.
|
||||
enum EmojiCatalog {
|
||||
static let categories: [EmojiCategory] = [
|
||||
EmojiCategory(
|
||||
title: "Смайлы",
|
||||
symbol: "face.smiling",
|
||||
emojis: [
|
||||
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🥲", "☺️",
|
||||
"😊", "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", "😗",
|
||||
"😙", "😚", "😋", "😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓",
|
||||
"😎", "🥸", "🤩", "🥳", "😏", "😒", "😞", "😔", "😟", "😕",
|
||||
"🙁", "☹️", "😣", "😖", "😫", "😩", "🥺", "😢", "😭", "😤",
|
||||
"😠", "😡", "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰",
|
||||
"😥", "😓", "🤗", "🤔", "🫣", "🤭", "🫢", "🤫", "🤥", "😶",
|
||||
"😐", "😑", "😬", "🙄", "😯", "😦", "😧", "😮", "😲", "🥱",
|
||||
"😴", "🤤", "😪", "😵", "🤐", "🥴", "🤢", "🤮", "🤧", "😷",
|
||||
"🤒", "🤕", "🤑", "🤠", "😈", "👿", "👹", "👺", "🤡", "💩",
|
||||
"👻", "💀", "☠️", "👽", "👾", "🤖", "🎃", "😺", "😸", "😹",
|
||||
"😻", "😼", "😽", "🙀", "😿", "😾", "❤️", "🧡", "💛", "💚",
|
||||
"💙", "💜", "🖤", "🤍", "🤎", "💔", "❤️🔥", "❤️🩹", "💕", "💞",
|
||||
"💓", "💗", "💖", "💘", "💝", "💟", "❣️", "💌", "💢", "💥",
|
||||
"💫", "💦", "💨", "🕳️", "💬", "💭", "🗯️",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Жесты",
|
||||
symbol: "hand.raised",
|
||||
emojis: [
|
||||
"👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞",
|
||||
"🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️",
|
||||
"👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "🫶", "👐",
|
||||
"🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦵", "🦶",
|
||||
"👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁️", "👅", "👄",
|
||||
"👶", "🧒", "👦", "👧", "🧑", "👱", "👨", "🧔", "👩", "🧓",
|
||||
"👴", "👵", "🙍", "🙎", "🙅", "🙆", "💁", "🙋", "🧏", "🙇",
|
||||
"🤦", "🤷", "💆", "💇", "🚶", "🧍", "🧎", "🏃", "💃", "🕺",
|
||||
"🧖", "🧗", "🤺", "🏇", "⛷️", "🏂", "🏌️", "🏄", "🚣", "🏊",
|
||||
"⛹️", "🏋️", "🚴", "🚵", "🤸", "🤼", "🤽", "🤾", "🤹", "🧘",
|
||||
"🛀", "🛌", "🧑🤝🧑", "👭", "👫", "👬", "💏", "💑", "👪",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Животные и природа",
|
||||
symbol: "leaf",
|
||||
emojis: [
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐻❄️", "🐨",
|
||||
"🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", "🙉", "🙊", "🐔",
|
||||
"🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺",
|
||||
"🐗", "🐴", "🦄", "🐝", "🪱", "🐛", "🦋", "🐌", "🐞", "🐜",
|
||||
"🪰", "🪲", "🪳", "🦟", "🦗", "🕷️", "🕸️", "🦂", "🐢", "🐍",
|
||||
"🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠",
|
||||
"🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍",
|
||||
"🦧", "🦣", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🦬",
|
||||
"🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌",
|
||||
"🐕", "🐩", "🦮", "🐈", "🐈⬛", "🪶", "🐓", "🦃", "🦤", "🦚",
|
||||
"🦜", "🦢", "🦩", "🕊️", "🐇", "🦝", "🦨", "🦡", "🦫", "🦦",
|
||||
"🦥", "🐁", "🐀", "🐿️", "🦔", "🐾", "🐉", "🐲", "🌵", "🎄",
|
||||
"🌲", "🌳", "🌴", "🌱", "🌿", "☘️", "🍀", "🎍", "🎋", "🍃",
|
||||
"🍂", "🍁", "🍄", "🐚", "🌾", "💐", "🌷", "🌹", "🥀", "🌺",
|
||||
"🌸", "🌼", "🌻", "🌞", "🌝", "🌛", "🌜", "🌚", "🌕", "🌖",
|
||||
"🌗", "🌘", "🌑", "🌒", "🌓", "🌔", "🌙", "🌎", "🌍", "🌏",
|
||||
"💫", "⭐", "🌟", "✨", "⚡", "☄️", "💥", "🔥", "🌪️", "🌈",
|
||||
"☀️", "🌤️", "⛅", "🌥️", "☁️", "🌦️", "🌧️", "⛈️", "🌩️", "🌨️",
|
||||
"❄️", "☃️", "⛄", "🌬️", "💨", "💧", "💦", "☔", "☂️", "🌊", "🌫️",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Еда и напитки",
|
||||
symbol: "fork.knife",
|
||||
emojis: [
|
||||
"🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐",
|
||||
"🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑",
|
||||
"🥦", "🥬", "🥒", "🌶️", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅",
|
||||
"🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳",
|
||||
"🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔",
|
||||
"🍟", "🍕", "🫓", "🥪", "🥙", "🧆", "🌮", "🌯", "🫔", "🥗",
|
||||
"🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟",
|
||||
"🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡",
|
||||
"🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬",
|
||||
"🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "🫖",
|
||||
"☕", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷",
|
||||
"🥃", "🍸", "🍹", "🧉", "🍾", "🧊", "🥄", "🍴", "🍽️", "🥣",
|
||||
"🥡", "🥢",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Активности",
|
||||
symbol: "soccerball",
|
||||
emojis: [
|
||||
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱",
|
||||
"🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳",
|
||||
"🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛼", "🛷",
|
||||
"⛸️", "🥌", "🎿", "⛷️", "🏂", "🪂", "🏋️", "🤼", "🤸", "⛹️",
|
||||
"🤺", "🤾", "🏌️", "🏇", "🧘", "🏄", "🏊", "🤽", "🚣", "🧗",
|
||||
"🚵", "🚴", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖️", "🏵️", "🎗️",
|
||||
"🎫", "🎟️", "🎪", "🤹", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧",
|
||||
"🎼", "🎹", "🥁", "🪘", "🎷", "🎺", "🪗", "🎸", "🪕", "🎻",
|
||||
"🎲", "♟️", "🎯", "🎳", "🎮", "🎰", "🧩",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Путешествия",
|
||||
symbol: "airplane",
|
||||
emojis: [
|
||||
"🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐",
|
||||
"🛻", "🚚", "🚛", "🚜", "🛵", "🏍️", "🛺", "🚲", "🛴", "🛹",
|
||||
"🚨", "🚔", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🚃", "🚋",
|
||||
"🚞", "🚝", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚉",
|
||||
"✈️", "🛫", "🛬", "🛩️", "💺", "🛰️", "🚀", "🛸", "🚁", "🛶",
|
||||
"⛵", "🚤", "🛥️", "🛳️", "⛴️", "🚢", "⚓", "🪝", "🚧", "🚦",
|
||||
"🚥", "🚏", "🗺️", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟️", "🎡",
|
||||
"🎢", "🎠", "⛲", "⛱️", "🏖️", "🏝️", "🏜️", "🌋", "⛰️", "🏔️",
|
||||
"🗻", "🏕️", "⛺", "🛖", "🏠", "🏡", "🏘️", "🏚️", "🏗️", "🏭",
|
||||
"🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩",
|
||||
"💒", "🏛️", "⛪", "🕌", "🕍", "🛕", "🕋", "⛩️", "🛤️", "🛣️",
|
||||
"🗾", "🎑", "🏞️", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆",
|
||||
"🏙️", "🌃", "🌌", "🌉", "🌁",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Предметы",
|
||||
symbol: "lightbulb",
|
||||
emojis: [
|
||||
"⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", "🖲️", "🕹️",
|
||||
"🗜️", "💽", "💾", "💿", "📀", "📼", "📷", "📸", "📹", "🎥",
|
||||
"📽️", "🎞️", "📞", "☎️", "📟", "📠", "📺", "📻", "🎙️", "🎚️",
|
||||
"🎛️", "🧭", "⏱️", "⏲️", "⏰", "🕰️", "⌛", "⏳", "📡", "🔋",
|
||||
"🔌", "💡", "🔦", "🕯️", "🪔", "🧯", "🛢️", "💸", "💵", "💴",
|
||||
"💶", "💷", "🪙", "💰", "💳", "💎", "⚖️", "🪜", "🧰", "🪛",
|
||||
"🔧", "🔨", "⚒️", "🛠️", "⛏️", "🪚", "🔩", "⚙️", "🪤", "🧱",
|
||||
"⛓️", "🧲", "🔫", "💣", "🧨", "🪓", "🔪", "🗡️", "⚔️", "🛡️",
|
||||
"🚬", "⚰️", "🪦", "⚱️", "🏺", "🔮", "📿", "🧿", "💈", "⚗️",
|
||||
"🔭", "🔬", "🕳️", "🩹", "🩺", "💊", "💉", "🩸", "🧬", "🦠",
|
||||
"🧫", "🧪", "🌡️", "🧹", "🪠", "🧺", "🧻", "🚽", "🚰", "🚿",
|
||||
"🛁", "🛀", "🧼", "🪥", "🪒", "🧽", "🪣", "🧴", "🛎️", "🔑",
|
||||
"🗝️", "🚪", "🪑", "🛋️", "🛏️", "🛌", "🧸", "🪆", "🖼️", "🪞",
|
||||
"🪟", "🛍️", "🛒", "🎁", "🎈", "🎏", "🎀", "🪄", "🪅", "🎊",
|
||||
"🎉", "🎎", "🏮", "🎐", "🧧", "✉️", "📩", "📨", "📧", "💌",
|
||||
"📥", "📤", "📦", "🏷️", "🪧", "📪", "📫", "📬", "📭", "📮",
|
||||
"📯", "📜", "📃", "📄", "📑", "🧾", "📊", "📈", "📉", "🗒️",
|
||||
"🗓️", "📆", "📅", "🗑️", "📇", "🗃️", "🗳️", "🗄️", "📋", "📁",
|
||||
"📂", "🗂️", "🗞️", "📰", "📓", "📔", "📒", "📕", "📗", "📘",
|
||||
"📙", "📚", "📖", "🔖", "🧷", "🔗", "📎", "🖇️", "📐", "📏",
|
||||
"🧮", "📌", "📍", "✂️", "🖊️", "🖋️", "✒️", "🖌️", "🖍️", "📝",
|
||||
"✏️", "🔍", "🔎", "🔏", "🔐", "🔒", "🔓",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Символы",
|
||||
symbol: "heart",
|
||||
emojis: [
|
||||
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔",
|
||||
"❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "♥️",
|
||||
"♠️", "♣️", "♦️", "🃏", "🀄", "🎴", "🔇", "🔈", "🔉", "🔊",
|
||||
"📢", "📣", "📯", "🔔", "🔕", "🎼", "🎵", "🎶", "🎙️", "🎚️",
|
||||
"🎛️", "🎤", "🎧", "📻", "🎷", "🪗", "🎸", "🎹", "🎺", "🎻",
|
||||
"🥁", "🪘", "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌",
|
||||
"💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "💽", "💾", "💿", "📀",
|
||||
"🧮", "🎥", "🎞️", "📽️", "🎬", "📺", "📷", "📸", "📹", "📼",
|
||||
"🔍", "🔎", "🕯️", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖",
|
||||
"📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰",
|
||||
"🗞️", "📑", "🔖", "🏷️", "💰", "🪙", "💴", "💵", "💶", "💷",
|
||||
"💸", "💳", "🧾", "✉️", "📧", "📨", "📩", "📤", "📥", "📦",
|
||||
"📫", "📪", "📬", "📭", "📮", "🗳️", "✏️", "✒️", "🖋️", "🖊️",
|
||||
"🖌️", "🖍️", "📝", "💼", "📁", "📂", "🗂️", "📅", "📆", "🗒️",
|
||||
"🗓️", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇️",
|
||||
"📏", "📐", "✂️", "🗃️", "🗄️", "🗑️", "🔒", "🔓", "🔏", "🔐",
|
||||
"🔑", "🗝️", "🔨", "🪓", "⛏️", "⚒️", "🛠️", "🗡️", "⚔️", "🔫",
|
||||
"🪃", "🏹", "🛡️", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️",
|
||||
"🦯", "🔗", "⛓️", "🪝", "🧰", "🧲", "🪜", "⚗️", "🧪", "🧫",
|
||||
"🧬", "🔬", "🔭", "📡", "💉", "🩸", "💊", "🩹", "🩺", "🚪",
|
||||
"🛏️", "🛋️", "🪑", "🚽", "🪠", "🚿", "🛁", "🪒", "🧴", "🧷",
|
||||
"🧹", "🧺", "🧻", "🧼", "🪥", "🧽", "🧯", "🛒", "🚬", "⚰️",
|
||||
"🪦", "⚱️", "🗿", "🪧", "🪪", "🆗", "🆕", "🆒", "🆓", "🆙",
|
||||
"🆖", "🆚", "🈁", "🈂️", "🈷️", "🈶", "🈯", "🉐", "🈹", "🈚",
|
||||
"🈲", "🉑", "🈸", "🈴", "🈳", "㊗️", "㊙️", "🈺", "🈵", "🔴",
|
||||
"🟠", "🟡", "🟢", "🔵", "🟣", "🟤", "⚫", "⚪", "🟥", "🟧",
|
||||
"🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜", "◼️", "◻️", "◾",
|
||||
"◽", "▪️", "▫️", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "💠",
|
||||
"🔘", "🔳", "🔲",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
static var allEmojis: [String] {
|
||||
categories.flatMap(\.emojis)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
|
||||
/// Snapshot of everything the "server details" screen shows, modelled after
|
||||
/// Monal's `ServerDetails`: server software (XEP-0092), disco#info identities
|
||||
/// and features (XEP-0030), conference (MUC) services, STUN/TURN services
|
||||
/// (XEP-0215) and a few connection-level capability flags.
|
||||
struct ServerInformation: Equatable, Sendable {
|
||||
enum CapabilityStatus: Equatable, Sendable {
|
||||
case success, normal, warning, error
|
||||
}
|
||||
|
||||
struct Software: Equatable, Sendable {
|
||||
let name: String?
|
||||
let version: String?
|
||||
let os: String?
|
||||
}
|
||||
|
||||
struct Identity: Equatable, Sendable, Identifiable {
|
||||
let category: String
|
||||
let type: String
|
||||
let name: String?
|
||||
|
||||
var id: String { "\(category)/\(type)" }
|
||||
|
||||
var title: String { "\(category) / \(type)" }
|
||||
}
|
||||
|
||||
struct ConferenceServer: Equatable, Sendable, Identifiable {
|
||||
let jid: String
|
||||
let name: String?
|
||||
let type: String
|
||||
let category: String
|
||||
|
||||
var id: String { jid }
|
||||
}
|
||||
|
||||
struct ExternalService: Equatable, Sendable, Identifiable {
|
||||
let type: String
|
||||
let host: String
|
||||
let port: UInt16?
|
||||
let transport: String?
|
||||
|
||||
var id: String { "\(type)|\(host)|\(port.map(String.init) ?? "")" }
|
||||
}
|
||||
|
||||
struct ConnectionStats: Equatable, Sendable {
|
||||
let lastLogin: Date?
|
||||
let smacksSessionEstablished: Date?
|
||||
let sent: UInt32
|
||||
let acknowledged: UInt32
|
||||
let received: UInt32
|
||||
|
||||
var unacknowledged: UInt32 {
|
||||
sent > acknowledged ? sent - acknowledged : 0
|
||||
}
|
||||
}
|
||||
|
||||
struct Capability: Equatable, Sendable, Identifiable {
|
||||
let title: String
|
||||
let detail: String
|
||||
let status: CapabilityStatus
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
let serverDomain: String
|
||||
let software: Software
|
||||
let identities: [Identity]
|
||||
let serverFeatures: [String]
|
||||
let accountFeatures: [String]
|
||||
let conferenceServers: [ConferenceServer]
|
||||
let externalServices: [ExternalService]
|
||||
let connectionStats: ConnectionStats
|
||||
let saslMethods: [String]
|
||||
let supportsStreamManagement: Bool
|
||||
let supportsCarbons: Bool
|
||||
let supportsClientState: Bool
|
||||
let supportsHTTPUpload: Bool
|
||||
let supportsRosterVersioning: Bool
|
||||
let supportsRosterPreApproval: Bool
|
||||
|
||||
/// The curated XEP list Monal shows, with a status derived from the server
|
||||
/// and account disco features plus the connection-level flags.
|
||||
var capabilities: [Capability] {
|
||||
let server = Set(serverFeatures)
|
||||
let account = Set(accountFeatures)
|
||||
let supportsPubSub = account.contains { $0.hasPrefix("http://jabber.org/protocol/pubsub") }
|
||||
return [
|
||||
Capability(
|
||||
title: "XEP-0163 Personal Eventing Protocol",
|
||||
detail: "Публикация событий состояния аккаунта (аватары, статусы) через publish-subscribe.",
|
||||
status: supportsPubSub ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0191 Blocking Command",
|
||||
detail: "Расширение XMPP для блокировки контактов.",
|
||||
status: server.contains("urn:xmpp:blocking") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0198 Stream Management",
|
||||
detail: "Возобновление потока при обрыве соединения — быстрее переподключение и экономия заряда.",
|
||||
status: supportsStreamManagement ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0199 XMPP Ping",
|
||||
detail: "Пинг уровня приложения поверх XML-потока.",
|
||||
status: server.contains("urn:xmpp:ping") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0215 External Service Discovery",
|
||||
detail: "Обнаружение внешних сервисов (STUN/TURN) для аудио- и видеозвонков.",
|
||||
status: server.contains("urn:xmpp:extdisco:2") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0237 Roster Versioning",
|
||||
detail: "Версионирование ростера, чтобы сервер не отправлял его повторно, если он не менялся.",
|
||||
status: supportsRosterVersioning ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0280 Message Carbons",
|
||||
detail: "Синхронизация сообщений между всеми вашими устройствами.",
|
||||
status: supportsCarbons ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0313 Message Archive Management",
|
||||
detail: "Доступ к архиву сообщений на сервере.",
|
||||
status: account.contains("urn:xmpp:mam:2") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0352 Client State Indication",
|
||||
detail: "Указывает, активно ли устройство. Экономит заряд.",
|
||||
status: supportsClientState ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0357 Push Notifications",
|
||||
detail: "Push-уведомления через Apple даже когда приложение выгружено.",
|
||||
status: account.contains("urn:xmpp:push:0") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0363 HTTP File Upload",
|
||||
detail: "Загрузка файлов на сервер для обмена с собеседниками.",
|
||||
status: supportsHTTPUpload ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0379 Pre-Authenticated Roster Subscription",
|
||||
detail: "Ссылки для предварительно аутентифицированной подписки на присутствие.",
|
||||
status: supportsRosterPreApproval ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0474 SASL SCRAM Downgrade Protection",
|
||||
detail: "Защита SASL/SASL2-рукопожатия от понижения метода и channel-binding. Определяется во время SCRAM-аутентификации, а не рекламируется статически.",
|
||||
status: .normal
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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("Шифрование") {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user