mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 19:50:30 +00:00
ios: resolve SimpleX names on search, show each result once
The keyboard Search key on the chat list now also resolves a typed SimpleX name online, alongside the directory search, and shows what it finds as a Names part between the local chats and the directory results. Resolution never raises an alert: apiConnectPlan gains an onFailure mode, and existing callers keep alerting by default. Each directory entry is looked up in the store (store-only, as entries are now group or channel links only) so a chat the user already sees locally, or found by name, is not shown again; names already in the local list are hidden the same way. A directory row is matched to a name only by the link the core resolved, never by a profile field the name's owner writes, so a name cannot hide a genuine directory row. The "Search in Directory" row, retry and "Show more" keep the names already shown. One helper drives the spinner from whether anything is in flight, and every publish checks the search generation, so a late reply after a text change or cancel writes nothing. The first-search alert now also names the server that resolves names. Run: 002-ios-directory-search-ui, slice 3
This commit is contained in:
@@ -98,3 +98,89 @@ func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults?
|
||||
}
|
||||
return DirectorySearchResults(entries: entries, cursor: r.searchCursor)
|
||||
}
|
||||
|
||||
func planLocalChatId(_ plan: ConnectionPlan) -> ChatId? {
|
||||
switch plan {
|
||||
case let .contactAddress(contactAddressPlan):
|
||||
switch contactAddressPlan {
|
||||
case let .known(contact), let .connectingProhibit(contact), let .contactViaAddress(contact): return contact.id
|
||||
case .ok, .ownLink, .connectingConfirmReconnect: return nil
|
||||
}
|
||||
case let .groupLink(groupLinkPlan):
|
||||
switch groupLinkPlan {
|
||||
case let .known(groupInfo), let .ownLink(groupInfo): return groupInfo.id
|
||||
case let .connectingProhibit(groupInfo_): return groupInfo_?.id
|
||||
case .ok, .connectingConfirmReconnect, .noRelays, .updateRequired: return nil
|
||||
}
|
||||
case let .invitationLink(invitationLinkPlan):
|
||||
switch invitationLinkPlan {
|
||||
case let .known(contact): return contact.id
|
||||
case let .connecting(contact_): return contact_?.id
|
||||
case .ok, .ownLink: return nil
|
||||
}
|
||||
case .error: return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct NameSearchResult: Identifiable {
|
||||
let target: String
|
||||
let simplexName: String
|
||||
let profileName: String?
|
||||
let descr: String?
|
||||
let image: String?
|
||||
let iconName: String
|
||||
let localChatId: ChatId?
|
||||
// the link the core resolved the name to, never a profile field the name's owner wrote
|
||||
let resolvedLink: String?
|
||||
|
||||
var id: String { target }
|
||||
|
||||
init?(target: String, result: ConnectionPlanResult) {
|
||||
let profile: (any NamedChat)?
|
||||
switch result.connectionPlan {
|
||||
case let .contactAddress(contactAddressPlan):
|
||||
switch contactAddressPlan {
|
||||
case let .ok(contactSLinkData_, _): profile = contactSLinkData_?.profile
|
||||
case let .contactViaAddress(contact), let .connectingProhibit(contact), let .known(contact): profile = contact.profile
|
||||
case .connectingConfirmReconnect: profile = nil
|
||||
case .ownLink: return nil
|
||||
}
|
||||
iconName = "person.crop.circle.fill"
|
||||
resolvedLink = nil
|
||||
case let .groupLink(groupLinkPlan):
|
||||
let groupProfile: GroupProfile?
|
||||
switch groupLinkPlan {
|
||||
case let .ok(_, groupSLinkData_, _), let .noRelays(groupSLinkData_), let .updateRequired(groupSLinkData_): groupProfile = groupSLinkData_?.groupProfile
|
||||
case let .known(groupInfo), let .ownLink(groupInfo): groupProfile = groupInfo.groupProfile
|
||||
case let .connectingProhibit(groupInfo_): groupProfile = groupInfo_?.groupProfile
|
||||
case .connectingConfirmReconnect: groupProfile = nil
|
||||
}
|
||||
profile = groupProfile
|
||||
iconName = groupProfile?.isChannel == true ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill"
|
||||
resolvedLink = result.connLink.connShortLink
|
||||
case .invitationLink, .error:
|
||||
return nil
|
||||
}
|
||||
self.target = target
|
||||
simplexName = result.planSimplexName?.shortStr ?? target
|
||||
profileName = profile?.displayName
|
||||
descr = profile?.shortDescr
|
||||
image = profile?.image
|
||||
localChatId = planLocalChatId(result.connectionPlan)
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryResult: Identifiable {
|
||||
let entry: DirectorySearchEntry
|
||||
let localChatId: ChatId?
|
||||
|
||||
var id: String { entry.id }
|
||||
}
|
||||
|
||||
struct OnlineSearchRows {
|
||||
var names: [NameSearchResult]
|
||||
var directory: [DirectoryResult]
|
||||
var footer: DirectoryFooter?
|
||||
|
||||
var isEmpty: Bool { names.isEmpty && directory.isEmpty && footer == nil }
|
||||
}
|
||||
|
||||
@@ -1057,7 +1057,11 @@ func apiSearchDirectory(_ text: String, cursor: JSONValue?) async -> DirectorySe
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ConnectionPlanResult? {
|
||||
enum ConnectPlanFailure {
|
||||
case alert, log
|
||||
}
|
||||
|
||||
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>, onFailure: ConnectPlanFailure = .alert) async -> ConnectionPlanResult? {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiConnectPlan: no current user")
|
||||
return nil
|
||||
@@ -1068,7 +1072,12 @@ func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, l
|
||||
}
|
||||
// a .never (typing) search that matches nothing locally is not an error to surface
|
||||
if case .error(.error(.notResolvedLocally)) = r { return nil }
|
||||
if let r { await apiConnectResponseAlert(r) }
|
||||
if let r {
|
||||
switch onFailure {
|
||||
case .alert: await apiConnectResponseAlert(r)
|
||||
case .log: logger.debug("apiConnectPlan: \(String(describing: r))")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -447,9 +447,11 @@ struct ChatListView: View {
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.padding(.horizontal)
|
||||
if onlineSearch.showResults {
|
||||
let rows = onlineSearch.rows(shownChatIds: [])
|
||||
List {
|
||||
directoryRows()
|
||||
if !onlineSearch.loading && onlineSearch.entries.isEmpty && onlineSearch.footer == nil {
|
||||
nameRows(rows.names)
|
||||
directoryRows(rows)
|
||||
if !onlineSearch.searching && rows.isEmpty {
|
||||
Text("No chats found")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -478,26 +480,34 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func directoryRows() -> some View {
|
||||
let footer = onlineSearch.footer
|
||||
if !onlineSearch.entries.isEmpty || footer != nil {
|
||||
Text("Directory")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
ForEach(onlineSearch.entries) { entry in
|
||||
DirectorySearchRow(entry: entry)
|
||||
@ViewBuilder private func nameRows(_ names: [NameSearchResult]) -> some View {
|
||||
if !names.isEmpty {
|
||||
searchPartHeader("Names")
|
||||
ForEach(names) { result in
|
||||
NameSearchRow(result: result)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowBackground(Color.clear)
|
||||
.onTapGesture {
|
||||
searchFocussed = false
|
||||
planAndConnect(entry.link, theme: theme, dismiss: false, cleanup: nil)
|
||||
planAndConnect(result.target, theme: theme, dismiss: false, cleanup: nil)
|
||||
}
|
||||
}
|
||||
switch footer {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func directoryRows(_ rows: OnlineSearchRows) -> some View {
|
||||
if !rows.directory.isEmpty || rows.footer != nil {
|
||||
searchPartHeader("Directory")
|
||||
ForEach(rows.directory) { result in
|
||||
DirectorySearchRow(entry: result.entry)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowBackground(Color.clear)
|
||||
.onTapGesture {
|
||||
searchFocussed = false
|
||||
planAndConnect(result.entry.link, theme: theme, dismiss: false, cleanup: nil)
|
||||
}
|
||||
}
|
||||
switch rows.footer {
|
||||
case .retry: directoryRetryRow()
|
||||
case .showMore: directoryShowMoreRow()
|
||||
case nil: EmptyView()
|
||||
@@ -505,6 +515,16 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func searchPartHeader(_ title: LocalizedStringKey) -> some View {
|
||||
Text(title)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private func openGetStake() {
|
||||
getStakeBannerTapped = true
|
||||
showGetStakeSheet = true
|
||||
@@ -512,6 +532,7 @@ struct ChatListView: View {
|
||||
|
||||
private var chatListContent: some View {
|
||||
let cs = filteredChats()
|
||||
let rows = onlineSearch.rows(shownChatIds: Set(cs.map { $0.id }))
|
||||
return ZStack {
|
||||
ScrollViewReader { scrollProxy in
|
||||
List {
|
||||
@@ -614,7 +635,8 @@ struct ChatListView: View {
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
}
|
||||
}
|
||||
directoryRows()
|
||||
nameRows(rows.names)
|
||||
directoryRows(rows)
|
||||
if !addressCreationCardShown && hasConversations {
|
||||
ConnectBannerCard()
|
||||
.padding(.vertical, 6)
|
||||
@@ -641,8 +663,8 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// this covers the whole list, so it must not hide the directory section or its own messages
|
||||
if cs.isEmpty && !chatModel.chats.isEmpty && !onlineSearch.loading && onlineSearch.entries.isEmpty && onlineSearch.footer == nil {
|
||||
// this covers the whole list, so it must not hide the online results or their messages
|
||||
if cs.isEmpty && !chatModel.chats.isEmpty && !onlineSearch.searching && rows.isEmpty {
|
||||
noChatsView()
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -840,7 +862,7 @@ struct ChatListSearchBar: View {
|
||||
VStack(spacing: 12) {
|
||||
// a typed name shows a row to connect to it (as on Android mobile): with the reachable toolbar it
|
||||
// replaces the tags above the search field; in top bar mode the tags stay and it moves below (end of VStack)
|
||||
if oneHandUI, let candidate = connectNameCandidate {
|
||||
if oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
|
||||
ConnectByNameRow(
|
||||
name: candidate,
|
||||
searchText: $searchText,
|
||||
@@ -864,7 +886,7 @@ struct ChatListSearchBar: View {
|
||||
.focused($searchFocussed)
|
||||
.frame(maxWidth: .infinity)
|
||||
.submitLabel(.search)
|
||||
.onSubmit(runDirectorySearch)
|
||||
.onSubmit(runOnlineSearch)
|
||||
if connectProgressManager.showConnectProgress != nil {
|
||||
ProgressView()
|
||||
}
|
||||
@@ -891,7 +913,7 @@ struct ChatListSearchBar: View {
|
||||
toggleFilterButton()
|
||||
}
|
||||
}
|
||||
if !oneHandUI, let candidate = connectNameCandidate {
|
||||
if !oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
|
||||
ConnectByNameRow(
|
||||
name: candidate,
|
||||
searchText: $searchText,
|
||||
@@ -1015,7 +1037,14 @@ struct ChatListSearchBar: View {
|
||||
private func runDirectorySearch() {
|
||||
let text = searchTrimmed
|
||||
guard !text.isEmpty, !searchShowingSimplexLink else { return }
|
||||
confirmDirectorySearch { Task { await onlineSearch.search(text) } }
|
||||
confirmDirectorySearch { Task { await onlineSearch.searchDirectory(text) } }
|
||||
}
|
||||
|
||||
private func runOnlineSearch() {
|
||||
let text = searchTrimmed
|
||||
guard !text.isEmpty, !searchShowingSimplexLink else { return }
|
||||
let name = connectNameCandidate
|
||||
confirmDirectorySearch { Task { await onlineSearch.search(text, name: name) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ enum DirectoryFooter {
|
||||
case retry, showMore
|
||||
}
|
||||
|
||||
// Directory results are not chats and are never saved: they live as long as the search text does.
|
||||
// Online results are not chats and are never saved: they live as long as the search text does.
|
||||
@MainActor
|
||||
class OnlineSearchModel: ObservableObject {
|
||||
@Published private(set) var entries: [DirectorySearchEntry] = []
|
||||
@Published private(set) var names: [NameSearchResult] = []
|
||||
@Published private(set) var directory: [DirectoryResult] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var resolvingNames = false
|
||||
@Published private(set) var failed = false
|
||||
// tells "nothing searched yet" apart from "searched and found nothing"
|
||||
@Published private(set) var searched = false
|
||||
@@ -26,32 +28,63 @@ class OnlineSearchModel: ObservableObject {
|
||||
private var searchedText = ""
|
||||
// bumped on every reset, so a reply that arrives too late cannot refill a list already cleared
|
||||
private var generation = 0
|
||||
private var spinnerShown = false
|
||||
|
||||
var showResults: Bool { loading || searched }
|
||||
var searching: Bool { loading || resolvingNames }
|
||||
var showResults: Bool { searching || searched || !names.isEmpty }
|
||||
var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .showMore : nil }
|
||||
|
||||
func reset() {
|
||||
generation += 1
|
||||
entries = []
|
||||
names = []
|
||||
directory = []
|
||||
cursor = nil
|
||||
loading = false
|
||||
resolvingNames = false
|
||||
failed = false
|
||||
searched = false
|
||||
searchedText = ""
|
||||
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
|
||||
updateSpinner()
|
||||
}
|
||||
|
||||
// a way out of the wait, not out of the results: the page already shown stays
|
||||
// a way out of the wait, not out of the results: what is already shown stays
|
||||
func cancelRequest() {
|
||||
generation += 1
|
||||
loading = false
|
||||
resolvingNames = false
|
||||
updateSpinner()
|
||||
}
|
||||
|
||||
func search(_ text: String) async {
|
||||
func search(_ text: String, name: String?) async {
|
||||
let text = text.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty, !(loading && text == searchedText) else { return }
|
||||
guard !text.isEmpty, !(searching && text == searchedText) else { return }
|
||||
reset()
|
||||
searchedText = text
|
||||
if let name {
|
||||
resolvingNames = true
|
||||
updateSpinner()
|
||||
// captured here: the async let initializer runs later, in the child task, possibly after a reset
|
||||
let gen = generation
|
||||
async let resolution: Void = resolveNames(name, generation: gen)
|
||||
await request(append: false)
|
||||
await resolution
|
||||
} else {
|
||||
await request(append: false)
|
||||
}
|
||||
}
|
||||
|
||||
func searchDirectory(_ text: String) async {
|
||||
let text = text.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty, !(loading && text == searchedText) else { return }
|
||||
if text == searchedText {
|
||||
directory = []
|
||||
cursor = nil
|
||||
failed = false
|
||||
searched = false
|
||||
} else {
|
||||
reset()
|
||||
searchedText = text
|
||||
}
|
||||
await request(append: false)
|
||||
}
|
||||
|
||||
@@ -64,34 +97,83 @@ class OnlineSearchModel: ObservableObject {
|
||||
func retry() async {
|
||||
if cursor != nil {
|
||||
await loadMore()
|
||||
} else {
|
||||
await search(searchedText)
|
||||
} else if !loading {
|
||||
await request(append: false)
|
||||
}
|
||||
}
|
||||
|
||||
func rows(shownChatIds: Set<ChatId>) -> OnlineSearchRows {
|
||||
let nameChatIds = Set(names.compactMap { $0.localChatId })
|
||||
let nameLinks = Set(names.compactMap { $0.resolvedLink })
|
||||
func isAmong(_ ids: Set<ChatId>, _ id: ChatId?) -> Bool {
|
||||
id.map { ids.contains($0) } ?? false
|
||||
}
|
||||
return OnlineSearchRows(
|
||||
names: names.filter { !isAmong(shownChatIds, $0.localChatId) },
|
||||
directory: directory.filter {
|
||||
!isAmong(shownChatIds, $0.localChatId) && !isAmong(nameChatIds, $0.localChatId) && !nameLinks.contains($0.entry.link)
|
||||
},
|
||||
footer: footer
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveNames(_ name: String, generation gen: Int) async {
|
||||
let targets = name.hasPrefix("@") || name.hasPrefix("#") ? [name] : ["@" + name, "#" + name]
|
||||
var results: [NameSearchResult] = []
|
||||
for target in targets {
|
||||
// checked before each target: a cancelled or replaced search must not send more names to the server
|
||||
guard gen == generation else { return }
|
||||
if let r = await apiConnectPlan(connLink: target, resolveMode: .unknown, inProgress: BoxedValue(false), onFailure: .log),
|
||||
let result = NameSearchResult(target: target, result: r) {
|
||||
results.append(result)
|
||||
}
|
||||
}
|
||||
guard gen == generation else { return }
|
||||
resolvingNames = false
|
||||
updateSpinner()
|
||||
names = results
|
||||
}
|
||||
|
||||
private func request(append: Bool) async {
|
||||
let gen = generation
|
||||
loading = true
|
||||
failed = false
|
||||
ConnectProgressManager.shared.startConnectProgress(
|
||||
NSLocalizedString("Searching directory…", comment: "in progress text"),
|
||||
owner: .directorySearch
|
||||
) { [weak self] in
|
||||
Task { @MainActor in self?.cancelRequest() }
|
||||
}
|
||||
updateSpinner()
|
||||
let r = await apiSearchDirectory(searchedText, cursor: cursor)
|
||||
var page: [DirectoryResult] = []
|
||||
if let r {
|
||||
var seen = Set(directory.map { $0.id })
|
||||
for entry in r.entries {
|
||||
guard seen.insert(entry.id).inserted else { continue }
|
||||
let plan = await apiConnectPlan(connLink: entry.link, resolveMode: .never, inProgress: BoxedValue(false), onFailure: .log)
|
||||
page.append(DirectoryResult(entry: entry, localChatId: plan.flatMap { planLocalChatId($0.connectionPlan) }))
|
||||
}
|
||||
}
|
||||
guard gen == generation else { return }
|
||||
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
|
||||
loading = false
|
||||
searched = true
|
||||
updateSpinner()
|
||||
guard let r else {
|
||||
failed = true
|
||||
return
|
||||
}
|
||||
cursor = r.cursor
|
||||
let known = Set(entries.map { $0.id })
|
||||
let fresh = r.entries.filter { !known.contains($0.id) }
|
||||
entries = append ? entries + fresh : fresh
|
||||
directory = append ? directory + page : page
|
||||
}
|
||||
|
||||
private func updateSpinner() {
|
||||
guard searching != spinnerShown else { return }
|
||||
spinnerShown = searching
|
||||
if spinnerShown {
|
||||
ConnectProgressManager.shared.startConnectProgress(
|
||||
NSLocalizedString("Searching directory…", comment: "in progress text"),
|
||||
owner: .directorySearch
|
||||
) { [weak self] in
|
||||
Task { @MainActor in self?.cancelRequest() }
|
||||
}
|
||||
} else {
|
||||
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +250,33 @@ struct DirectorySearchRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct NameSearchRow: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var result: NameSearchResult
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProfileImage(imageStr: result.image, iconName: result.iconName, size: 42)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let profileName = result.profileName {
|
||||
(Text(profileName).fontWeight(.bold) + Text(verbatim: " (\(result.simplexName))").foregroundColor(theme.colors.secondary))
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text(result.simplexName).fontWeight(.bold).lineLimit(1)
|
||||
}
|
||||
if let descr = result.descr, !descr.isEmpty {
|
||||
Text(descr)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryShowMoreRow: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var onlineSearch: OnlineSearchModel
|
||||
@@ -208,7 +317,7 @@ func confirmDirectorySearch(_ search: @escaping () -> Void) {
|
||||
private func showDirectorySearchAlert(onSearch: @escaping () -> Void) {
|
||||
showAlert(
|
||||
NSLocalizedString("Search in Directory?", comment: "alert title"),
|
||||
message: NSLocalizedString("The text in the search field will be sent to SimpleX Directory to find public groups and channels.\n\nNo contact is created and your profile is not sent.", comment: "alert message"),
|
||||
message: NSLocalizedString("The text in the search field will be sent to SimpleX Directory to find public groups and channels. If it is a SimpleX name, it will also be sent to your server that resolves names.\n\nNo contact is created and your profile is not sent.", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert action"), style: .cancel),
|
||||
UIAlertAction(
|
||||
|
||||
Reference in New Issue
Block a user