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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user