mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-14 04:59:56 +00:00
* android, desktop, ios: drop stale mention map entry when @name is edited in place The compose-state mentions map was only pruned when fewer @name tokens were parsed from the text than were in the map. Editing an inserted @Name token in place (without re-picking from the picker) keeps the count at 1 while the parsed name no longer matches the stored key, so the stale entry was sent and the core rejected it with SEInvalidMention. Pruning now triggers whenever any map key is absent from the parsed mention names; the modified token is then sent as an unresolved formatted @ token. * android, desktop, ios: filter mentions at send time so in-place edits round-trip Treat the compose-state mentions map as a sticky cache of name -> memberId bindings recorded by the picker. The previous removeUnusedMentions pruned it on every text change, which dropped the binding the instant a letter was removed from an inserted @name token. Now the map is no longer mutated by text edits: memberMentions, the picker max-reached check, and the mentionMemberName disambiguator all filter against the names currently parsed from the message, so deleting and retyping the original characters re-resolves the original member, and the SEInvalidMention error from an in-place edit no longer occurs. * android, desktop, ios: cap memberMentions at MAX so stale cache entries cannot overflow the server limit With the sticky cache, manually typing an @name that happens to match a stale cache entry could push memberMentions above maxSndMentions and have the core reject the send with SEInvalidMention. The getter now walks parsedMessage in text order and stops at MAX_NUMBER_OF_MENTIONS; later @-tokens past the cap become visual-only formatting and the message still goes through. Also hoists the iOS activeMentions computation out of the per-row ForEach so it runs once per picker open rather than once per row. * android, desktop, ios: simplify mention restoration to minimal surgical diff Reverts the unnecessary mentionMemberName changes (the original mentions.containsKey behaviour preserves bindings better than the parsed-only variant), switches the picker checks to memberMentions.size (no helper variable needed since the getter already caps at MAX), and collapses the Kotlin memberMentions getter into a flat chain. * android, desktop, ios: gate picker mention-id and max-reached banner by memberMentions With the sticky cache, the picker's "currently bound member" highlight and the showMaxReachedBox banner-suppression clause read mentions[name] directly, so a stale cache entry made one row clickable (as a no-op) at the MAX limit and could suppress the banner when the user is actually adding a new mention. Both now gate the lookup by membership in the capped memberMentions, restoring the pre-fix UX where at MAX all rows are disabled and the banner shows when adding past the limit.
262 lines
10 KiB
Swift
262 lines
10 KiB
Swift
//
|
|
// GroupMentions.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by Diogo Cunha on 30/01/2025.
|
|
// Copyright © 2025 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SimpleXChat
|
|
|
|
let MENTION_START: Character = "@"
|
|
let QUOTE: Character = "'"
|
|
let MEMBER_ROW_SIZE: CGFloat = 60
|
|
let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8
|
|
|
|
struct GroupMentionsView: View {
|
|
@EnvironmentObject var m: ChatModel
|
|
@EnvironmentObject var theme: AppTheme
|
|
var im: ItemsModel
|
|
var groupInfo: GroupInfo
|
|
@Binding var composeState: ComposeState
|
|
@Binding var selectedRange: NSRange
|
|
@Binding var keyboardVisible: Bool
|
|
|
|
@State private var isVisible = false
|
|
@State private var currentMessage: String = ""
|
|
@State private var mentionName: String = ""
|
|
@State private var mentionRange: NSRange?
|
|
@State private var mentionMemberId: String?
|
|
@State private var sortedMembers: [GMember] = []
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottom) {
|
|
if isVisible {
|
|
let filtered = filteredMembers()
|
|
if filtered.count > 0 {
|
|
Color.white.opacity(0.01)
|
|
.edgesIgnoringSafeArea(.all)
|
|
.onTapGesture {
|
|
isVisible = false
|
|
}
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
Divider()
|
|
let scroll = ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(Array(filtered.enumerated()), id: \.element.wrapped.groupMemberId) { index, member in
|
|
let mentioned = mentionMemberId == member.wrapped.memberId
|
|
let disabled = composeState.memberMentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
|
|
ZStack(alignment: .bottom) {
|
|
memberRowView(member.wrapped, mentioned)
|
|
.contentShape(Rectangle())
|
|
.disabled(disabled)
|
|
.opacity(disabled ? 0.6 : 1)
|
|
.onTapGesture {
|
|
memberSelected(member)
|
|
}
|
|
.padding(.horizontal)
|
|
.frame(height: MEMBER_ROW_SIZE)
|
|
|
|
Divider()
|
|
.padding(.leading)
|
|
.padding(.leading, 48)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count)))
|
|
.background(theme.colors.background)
|
|
|
|
if #available(iOS 16.0, *) {
|
|
scroll.scrollDismissesKeyboard(.never)
|
|
} else {
|
|
scroll
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: composeState.parsedMessage) { parsedMsg in
|
|
currentMessage = composeState.message
|
|
messageChanged(currentMessage, parsedMsg, selectedRange)
|
|
}
|
|
.onChange(of: selectedRange) { r in
|
|
// This condition is needed to prevent messageChanged called twice,
|
|
// because composeState.formattedText triggers later when message changes.
|
|
// The condition is only true if position changed without text change
|
|
if currentMessage == composeState.message {
|
|
messageChanged(currentMessage, composeState.parsedMessage, r)
|
|
}
|
|
}
|
|
.onAppear {
|
|
currentMessage = composeState.message
|
|
}
|
|
}
|
|
|
|
func contextMemberFilter(_ member: GroupMember) -> Bool {
|
|
switch im.secondaryIMFilter {
|
|
case nil:
|
|
return true
|
|
case let .groupChatScopeContext(groupScopeInfo):
|
|
switch (groupScopeInfo) {
|
|
case let .memberSupport(groupMember_):
|
|
if let scopeMember = groupMember_ {
|
|
return member.memberRole >= .moderator || member.groupMemberId == scopeMember.groupMemberId
|
|
} else {
|
|
return member.memberRole >= .moderator
|
|
}
|
|
case .reports:
|
|
return false
|
|
}
|
|
case .msgContentTagContext:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func filteredMembers() -> [GMember] {
|
|
let s = mentionName.lowercased()
|
|
return sortedMembers.filter {
|
|
contextMemberFilter($0.wrapped)
|
|
&& (s.isEmpty || $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s))
|
|
}
|
|
}
|
|
|
|
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
|
|
if let (ft, r) = selectedMarkdown(parsedMsg, range) {
|
|
switch ft.format {
|
|
case let .mention(name):
|
|
isVisible = true
|
|
mentionName = name
|
|
mentionRange = r
|
|
mentionMemberId = composeState.memberMentions[name] != nil ? composeState.mentions[name]?.memberId : nil
|
|
if !m.membersLoaded {
|
|
Task {
|
|
await m.loadGroupMembers(groupInfo)
|
|
sortMembers()
|
|
}
|
|
}
|
|
return
|
|
case .none: () //
|
|
let pos = range.location
|
|
if range.length == 0, let (at, atRange) = getCharacter(msg, pos - 1), at == "@" {
|
|
let prevChar = getCharacter(msg, pos - 2)?.char
|
|
if prevChar == nil || prevChar == " " || prevChar == "\n" {
|
|
isVisible = true
|
|
mentionName = ""
|
|
mentionRange = atRange
|
|
mentionMemberId = nil
|
|
Task {
|
|
await m.loadGroupMembers(groupInfo)
|
|
sortMembers()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
default: ()
|
|
}
|
|
}
|
|
closeMemberList()
|
|
}
|
|
|
|
private func sortMembers() {
|
|
sortedMembers = m.groupMembers.filter({ m in
|
|
let status = m.wrapped.memberStatus
|
|
return status != .memLeft && status != .memRemoved && status != .memInvited
|
|
})
|
|
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
|
|
}
|
|
|
|
private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? {
|
|
if pos < 0 || pos >= s.count { return nil }
|
|
let r = NSRange(location: pos, length: 1)
|
|
return if let range = Range(r, in: s) {
|
|
(s[range], r)
|
|
} else {
|
|
nil
|
|
}
|
|
}
|
|
|
|
private func selectedMarkdown(_ parsedMsg: [FormattedText], _ range: NSRange) -> (FormattedText, NSRange)? {
|
|
if parsedMsg.isEmpty { return nil }
|
|
var i = 0
|
|
var pos: Int = 0
|
|
while i < parsedMsg.count && pos + parsedMsg[i].text.count < range.location {
|
|
pos += parsedMsg[i].text.count
|
|
i += 1
|
|
}
|
|
// the second condition will be true when two markdowns are selected
|
|
return i >= parsedMsg.count || range.location + range.length > pos + parsedMsg[i].text.count
|
|
? nil
|
|
: (parsedMsg[i], NSRange(location: pos, length: parsedMsg[i].text.count))
|
|
}
|
|
|
|
private func memberSelected(_ member: GMember) {
|
|
if let range = mentionRange, mentionMemberId == nil || mentionMemberId != member.wrapped.memberId {
|
|
addMemberMention(member, range)
|
|
}
|
|
}
|
|
|
|
private func addMemberMention(_ member: GMember, _ r: NSRange) {
|
|
guard let range = Range(r, in: composeState.message) else { return }
|
|
var mentions = composeState.mentions
|
|
var newName: String
|
|
if let mm = mentions.first(where: { $0.value.memberId == member.wrapped.memberId }) {
|
|
newName = mm.key
|
|
} else {
|
|
newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName)
|
|
}
|
|
mentions[newName] = CIMention(groupMember: member.wrapped)
|
|
var msgMention = newName.contains(" ") || newName.last?.isPunctuation == true
|
|
? "@'\(newName)'"
|
|
: "@\(newName)"
|
|
var newPos = r.location + msgMention.count
|
|
let newMsgLength = composeState.message.count + msgMention.count - r.length
|
|
print(newPos)
|
|
print(newMsgLength)
|
|
if newPos == newMsgLength {
|
|
msgMention += " "
|
|
newPos += 1
|
|
}
|
|
composeState = composeState.copy(
|
|
message: composeState.message.replacingCharacters(in: range, with: msgMention),
|
|
mentions: mentions
|
|
)
|
|
selectedRange = NSRange(location: newPos, length: 0)
|
|
closeMemberList()
|
|
keyboardVisible = true
|
|
}
|
|
|
|
private func closeMemberList() {
|
|
isVisible = false
|
|
mentionName = ""
|
|
mentionRange = nil
|
|
mentionMemberId = nil
|
|
}
|
|
|
|
private func memberRowView(_ member: GroupMember, _ mentioned: Bool) -> some View {
|
|
return HStack{
|
|
MemberProfileImage(member, size: 38)
|
|
.padding(.trailing, 2)
|
|
VStack(alignment: .leading) {
|
|
let t = Text(member.localAliasAndFullName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
|
(member.verified ? memberVerifiedShield() + t : t)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
if mentioned {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
|
|
func memberVerifiedShield() -> Text {
|
|
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
|
.font(.caption)
|
|
.baselineOffset(2)
|
|
.kerning(-2)
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
}
|