mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 00:02:28 +00:00
ios: online search shows one list of directory results with Open, and asks to enable Directory search
The Search key and "Search in Directory" search the directory only; a SimpleX name is looked up with "Connect to …", which now stays next to the results. The first search asks to enable Directory search, a new switch in Privacy settings. Results form one list without headers: your chats first, then the others at the chat row's icon size with Open, which opens the prepared chat without the "Open channel?" alert, or "Already joining" for a pending join. The load row says how many results are left; a long press shows Open. The prepared channel's banner shows its subscriber count. Run: 011-search-redesign, slice 3
This commit is contained in:
1 parent
cf30d58579
commit
c92d3a4113
8 files changed
+216
-302
No files matched your search
@@ -73,12 +73,14 @@ private struct DirectorySearchResponse: Decodable {
|
||||
var type: String
|
||||
var entries: [JSONValue]?
|
||||
var searchCursor: JSONValue?
|
||||
var moreCount: JSONValue?
|
||||
}
|
||||
|
||||
struct DirectorySearchResults {
|
||||
var entries: [DirectorySearchEntry]
|
||||
// opaque: stored and echoed back on the next request, never inspected
|
||||
var cursor: JSONValue?
|
||||
var moreCount: Int?
|
||||
}
|
||||
|
||||
func directorySearchRequestJSON(_ text: String, _ cursor: JSONValue?) -> String {
|
||||
@@ -96,7 +98,9 @@ func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults?
|
||||
let entries = (r.entries ?? []).compactMap { (e: JSONValue) -> DirectorySearchEntry? in
|
||||
decodeJSONValue(e)
|
||||
}
|
||||
return DirectorySearchResults(entries: entries, cursor: r.searchCursor)
|
||||
// only shown as text, so a count out of range is dropped rather than failing the response
|
||||
let moreCount: Int? = if case let .number(n)? = r.moreCount, n >= 0, n <= Double(Int32.max), n == n.rounded() { Int(n) } else { nil }
|
||||
return DirectorySearchResults(entries: entries, cursor: r.searchCursor, moreCount: moreCount)
|
||||
}
|
||||
|
||||
func planLocalChatId(_ plan: ConnectionPlan) -> ChatId? {
|
||||
@@ -122,66 +126,33 @@ func planLocalChatId(_ plan: ConnectionPlan) -> ChatId? {
|
||||
}
|
||||
}
|
||||
|
||||
struct NameSearchResult: Identifiable {
|
||||
let target: String
|
||||
let simplexName: String
|
||||
let profileName: String?
|
||||
let descr: String?
|
||||
let image: String?
|
||||
let iconName: String
|
||||
var localChatId: ChatId?
|
||||
// the link the core resolved the name to, never a profile field the name's owner wrote
|
||||
let resolvedLink: String?
|
||||
enum DirectoryResultState {
|
||||
case yourChat(ChatId)
|
||||
case joining
|
||||
case open
|
||||
}
|
||||
|
||||
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)
|
||||
// no plan means the store doesn't know the link, or the plan failed and Open's online plan will show why
|
||||
func directoryResultState(_ plan: ConnectionPlan?) -> DirectoryResultState {
|
||||
if let chatId = plan.flatMap(planLocalChatId) { return .yourChat(chatId) }
|
||||
switch plan {
|
||||
case .groupLink(.connectingConfirmReconnect)?, .groupLink(.connectingProhibit(nil))?: return .joining
|
||||
default: return .open
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryResult: Identifiable {
|
||||
let entry: DirectorySearchEntry
|
||||
var localChatId: ChatId?
|
||||
var state: DirectoryResultState
|
||||
|
||||
var id: String { entry.id }
|
||||
}
|
||||
|
||||
struct OnlineSearchRows {
|
||||
var names: [NameSearchResult]
|
||||
var directory: [DirectoryResult]
|
||||
var yourChats: [ChatId]
|
||||
var results: [DirectoryResult]
|
||||
var pageInChats: Bool
|
||||
var footer: DirectoryFooter?
|
||||
|
||||
var isEmpty: Bool { names.isEmpty && directory.isEmpty && !pageInChats && footer == nil }
|
||||
var isEmpty: Bool { yourChats.isEmpty && results.isEmpty && !pageInChats && footer == nil }
|
||||
}
|
||||
@@ -1027,6 +1027,14 @@ struct ChatView: View {
|
||||
.frame(maxWidth: 260)
|
||||
}
|
||||
|
||||
if case let .group(groupInfo, _) = chat.chatInfo,
|
||||
groupInfo.nextConnectPrepared && groupInfo.useRelays,
|
||||
let count = groupInfo.groupSummary.publicMemberCount {
|
||||
Text(subscriberCountStr(count))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
ProfileDescriptionView(shortDescr: chat.chatInfo.shortDescr, description: chat.chatInfo.profileDescription)
|
||||
.padding(.horizontal)
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ struct ChatListView: View {
|
||||
@StateObject private var chatTagsModel = ChatTagsModel.shared
|
||||
@StateObject private var onlineSearch = OnlineSearchModel()
|
||||
@State private var scrollToItemId: ChatItem.ID? = nil
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
|
||||
// iOS 15 is required it to show/hide toolbar while chat is hidden/visible
|
||||
@State private var viewOnScreen = true
|
||||
@@ -449,7 +450,6 @@ struct ChatListView: View {
|
||||
if onlineSearch.showResults {
|
||||
let rows = onlineSearch.rows(shownChatIds: [])
|
||||
List {
|
||||
nameRows(rows.names)
|
||||
directoryRows(rows)
|
||||
if !onlineSearch.searching && rows.isEmpty {
|
||||
Text("No chats found")
|
||||
@@ -480,67 +480,30 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func nameRows(_ names: [NameSearchResult]) -> some View {
|
||||
if !names.isEmpty {
|
||||
searchPart("Names") {
|
||||
ForEach(names) { result in
|
||||
NameSearchRow(result: result)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowBackground(Color.clear)
|
||||
.onTapGesture {
|
||||
searchFocussed = false
|
||||
planAndConnect(result.target, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func directoryRows(_ rows: OnlineSearchRows) -> some View {
|
||||
if !rows.directory.isEmpty || rows.pageInChats || rows.footer != nil {
|
||||
searchPart("Directory") {
|
||||
ForEach(rows.directory) { result in
|
||||
if let chat = result.localChatId.flatMap({ chatModel.getChat($0) }) {
|
||||
chatListNavLink(chat)
|
||||
} else {
|
||||
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: { Task { await onlineSearch.refreshLocalChatIds() } })
|
||||
}
|
||||
}
|
||||
}
|
||||
if rows.pageInChats { directoryPageInChatsRow() }
|
||||
switch rows.footer {
|
||||
case .retry: directoryRetryRow()
|
||||
case .showMore: directoryShowMoreRow()
|
||||
case nil: EmptyView()
|
||||
}
|
||||
}
|
||||
ForEach(rows.yourChats.compactMap { chatModel.getChat($0) }, id: \.viewId) { chat in
|
||||
chatListNavLink(chat)
|
||||
}
|
||||
let imageSize = dynamicSize(userFont).profileImageSize
|
||||
ForEach(rows.results) { result in
|
||||
// the menu's preview is drawn upright only when the menu is attached before the row is flipped
|
||||
DirectorySearchRow(entry: result.entry, imageSize: imageSize, state: result.state)
|
||||
.modifier(DirectoryResultMenu(result: result, imageSize: imageSize, onOpen: { openResult(result) }))
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowBackground(Color.clear)
|
||||
.onTapGesture { openResult(result) }
|
||||
}
|
||||
if rows.pageInChats { directoryPageInChatsRow() }
|
||||
switch rows.footer {
|
||||
case .retry: directoryRetryRow()
|
||||
case .loadMore: directoryLoadMoreRow()
|
||||
case nil: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// the list is flipped in one-hand UI, so there the header follows its rows to be drawn above them
|
||||
@ViewBuilder private func searchPart<Content: View>(_ title: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
|
||||
if oneHandUI {
|
||||
content()
|
||||
searchPartHeader(title)
|
||||
} else {
|
||||
searchPartHeader(title)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
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 openResult(_ result: DirectoryResult) {
|
||||
searchFocussed = false
|
||||
openDirectoryResult(result, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshStates() } })
|
||||
}
|
||||
|
||||
private func openGetStake() {
|
||||
@@ -636,7 +599,6 @@ struct ChatListView: View {
|
||||
.onAppear { chatModel.chatListBanner = .getStake }
|
||||
}
|
||||
ForEach(cs, id: \.viewId) { chat in chatListNavLink(chat) }
|
||||
nameRows(rows.names)
|
||||
directoryRows(rows)
|
||||
if showsCards && !addressCreationCardShown && hasConversations {
|
||||
ConnectBannerCard()
|
||||
@@ -697,8 +659,8 @@ struct ChatListView: View {
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
@ViewBuilder private func directoryShowMoreRow() -> some View {
|
||||
DirectoryShowMoreRow(onlineSearch: onlineSearch)
|
||||
@ViewBuilder private func directoryLoadMoreRow() -> some View {
|
||||
DirectoryLoadMoreRow(onlineSearch: onlineSearch)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
@@ -887,7 +849,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, onlineSearch.names.isEmpty {
|
||||
if oneHandUI, let candidate = connectNameCandidate {
|
||||
ConnectByNameRow(
|
||||
name: candidate,
|
||||
searchText: $searchText,
|
||||
@@ -895,10 +857,10 @@ struct ChatListSearchBar: View {
|
||||
searchFocussed: $searchFocussed,
|
||||
dismiss: false
|
||||
)
|
||||
searchInDirectoryRow()
|
||||
if !onlineSearch.showResults { searchInDirectoryRow() }
|
||||
// a SimpleX link connects on its own, so the directory is not offered for one
|
||||
} else if oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink {
|
||||
searchInDirectoryRow()
|
||||
if !onlineSearch.showResults { searchInDirectoryRow() }
|
||||
} else {
|
||||
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
|
||||
}
|
||||
@@ -911,7 +873,7 @@ struct ChatListSearchBar: View {
|
||||
.focused($searchFocussed)
|
||||
.frame(maxWidth: .infinity)
|
||||
.submitLabel(.search)
|
||||
.onSubmit(runOnlineSearch)
|
||||
.onSubmit(runDirectorySearch)
|
||||
if connectProgressManager.showConnectProgress != nil {
|
||||
ProgressView()
|
||||
}
|
||||
@@ -938,7 +900,7 @@ struct ChatListSearchBar: View {
|
||||
toggleFilterButton()
|
||||
}
|
||||
}
|
||||
if !oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
|
||||
if !oneHandUI, let candidate = connectNameCandidate {
|
||||
ConnectByNameRow(
|
||||
name: candidate,
|
||||
searchText: $searchText,
|
||||
@@ -947,7 +909,7 @@ struct ChatListSearchBar: View {
|
||||
dismiss: false
|
||||
)
|
||||
}
|
||||
if !oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink {
|
||||
if !oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink, !onlineSearch.showResults {
|
||||
searchInDirectoryRow()
|
||||
}
|
||||
}
|
||||
@@ -1058,19 +1020,12 @@ struct ChatListSearchBar: View {
|
||||
SearchInDirectoryRow(searchFocussed: $searchFocussed, onSearch: runDirectorySearch)
|
||||
}
|
||||
|
||||
// whatever is typed here gets sent to the directory, so ask before the first time
|
||||
// whatever is typed here gets sent to the directory, so ask until Directory search is enabled
|
||||
private func runDirectorySearch() {
|
||||
let text = searchTrimmed
|
||||
guard !text.isEmpty, !searchShowingSimplexLink else { return }
|
||||
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) } }
|
||||
}
|
||||
}
|
||||
|
||||
// Row shown when the search text is a SimpleX name — in place of the list tags in the chat list, below
|
||||
|
||||
@@ -10,16 +10,15 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum DirectoryFooter {
|
||||
case retry, showMore
|
||||
case retry, loadMore
|
||||
}
|
||||
|
||||
// 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 names: [NameSearchResult] = []
|
||||
@Published private(set) var directory: [DirectoryResult] = []
|
||||
@Published private(set) var moreCount: Int? = nil
|
||||
@Published private(set) var loading = false { didSet { updateSpinner() } }
|
||||
@Published private(set) var resolvingNames = false { didSet { updateSpinner() } }
|
||||
@Published private(set) var failed = false
|
||||
// tells "nothing searched yet" apart from "searched and found nothing"
|
||||
@Published private(set) var searched = false
|
||||
@@ -31,18 +30,17 @@ class OnlineSearchModel: ObservableObject {
|
||||
private var generation = 0
|
||||
private var spinnerShown = false
|
||||
|
||||
var searching: Bool { loading || resolvingNames }
|
||||
var showResults: Bool { searching || searched || !names.isEmpty }
|
||||
var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .showMore : nil }
|
||||
var searching: Bool { loading }
|
||||
var showResults: Bool { loading || searched }
|
||||
var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .loadMore : nil }
|
||||
|
||||
func reset() {
|
||||
generation += 1
|
||||
names = []
|
||||
directory = []
|
||||
moreCount = nil
|
||||
cursor = nil
|
||||
lastPageIds = []
|
||||
loading = false
|
||||
resolvingNames = false
|
||||
failed = false
|
||||
searched = false
|
||||
searchedText = ""
|
||||
@@ -52,24 +50,6 @@ class OnlineSearchModel: ObservableObject {
|
||||
func cancelRequest() {
|
||||
generation += 1
|
||||
loading = false
|
||||
resolvingNames = false
|
||||
}
|
||||
|
||||
func search(_ text: String, name: String?) async {
|
||||
let text = text.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty, !(searching && text == searchedText) else { return }
|
||||
reset()
|
||||
searchedText = text
|
||||
if let name {
|
||||
resolvingNames = true
|
||||
// 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 {
|
||||
@@ -77,6 +57,7 @@ class OnlineSearchModel: ObservableObject {
|
||||
guard !text.isEmpty, !(loading && text == searchedText) else { return }
|
||||
if text == searchedText {
|
||||
directory = []
|
||||
moreCount = nil
|
||||
cursor = nil
|
||||
lastPageIds = []
|
||||
failed = false
|
||||
@@ -93,7 +74,7 @@ class OnlineSearchModel: ObservableObject {
|
||||
await request(append: true)
|
||||
}
|
||||
|
||||
// a failed "Show more" keeps its cursor, so the retry resumes the page rather than starting over
|
||||
// a failed "Load more" keeps its cursor, so the retry resumes the page rather than starting over
|
||||
func retry() async {
|
||||
if cursor != nil {
|
||||
await loadMore()
|
||||
@@ -103,68 +84,50 @@ class OnlineSearchModel: ObservableObject {
|
||||
}
|
||||
|
||||
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
|
||||
var yourChats: [ChatId] = []
|
||||
var results: [DirectoryResult] = []
|
||||
for result in directory {
|
||||
switch result.state {
|
||||
case let .yourChat(chatId) where ChatModel.shared.getChat(chatId) != nil:
|
||||
if !shownChatIds.contains(chatId) && !yourChats.contains(chatId) {
|
||||
yourChats.append(chatId)
|
||||
}
|
||||
case .yourChat:
|
||||
results.append(DirectoryResult(entry: result.entry, state: .open))
|
||||
case .joining, .open:
|
||||
results.append(result)
|
||||
}
|
||||
}
|
||||
let lastPage = directory.filter { lastPageIds.contains($0.id) }
|
||||
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)
|
||||
},
|
||||
// a result hidden only because a name resolved to its link is on screen, but not one of your chats
|
||||
pageInChats: !lastPage.isEmpty && lastPage.allSatisfy {
|
||||
isAmong(shownChatIds, $0.localChatId) || isAmong(nameChatIds, $0.localChatId)
|
||||
yourChats: yourChats,
|
||||
results: results,
|
||||
pageInChats: !lastPage.isEmpty && lastPage.allSatisfy { result in
|
||||
if case let .yourChat(chatId) = result.state { shownChatIds.contains(chatId) } else { false }
|
||||
},
|
||||
footer: footer
|
||||
)
|
||||
}
|
||||
|
||||
// a chat opened from a result exists only once its connection flow ends, so rows without a chat look again then
|
||||
func refreshLocalChatIds() async {
|
||||
// a chat opened from a result exists only once its connection flow ends, so results look again then
|
||||
func refreshStates() async {
|
||||
let gen = generation
|
||||
var nameChatIds: [String: ChatId] = [:]
|
||||
for result in names where result.localChatId == nil {
|
||||
nameChatIds[result.id] = await localChatId(result.target)
|
||||
}
|
||||
var directoryChatIds: [String: ChatId] = [:]
|
||||
for result in directory where result.localChatId == nil {
|
||||
directoryChatIds[result.id] = await localChatId(result.entry.link)
|
||||
var states: [String: DirectoryResultState] = [:]
|
||||
for result in directory {
|
||||
if case .yourChat = result.state { continue }
|
||||
states[result.id] = await resultState(result.entry.link)
|
||||
}
|
||||
guard gen == generation else { return }
|
||||
names = names.map { result in
|
||||
var r = result
|
||||
r.localChatId = r.localChatId ?? nameChatIds[r.id]
|
||||
return r
|
||||
}
|
||||
directory = directory.map { result in
|
||||
var r = result
|
||||
r.localChatId = r.localChatId ?? directoryChatIds[r.id]
|
||||
r.state = states[r.id] ?? r.state
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
private func localChatId(_ link: String) async -> ChatId? {
|
||||
private func resultState(_ link: String) async -> DirectoryResultState {
|
||||
let plan = await apiConnectPlan(connLink: link, resolveMode: .never, inProgress: BoxedValue(false), onFailure: .log)
|
||||
return plan.flatMap { planLocalChatId($0.connectionPlan) }
|
||||
}
|
||||
|
||||
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
|
||||
names = results
|
||||
return directoryResultState(plan?.connectionPlan)
|
||||
}
|
||||
|
||||
private func request(append: Bool) async {
|
||||
@@ -177,7 +140,7 @@ class OnlineSearchModel: ObservableObject {
|
||||
var seen = Set(directory.map { $0.id })
|
||||
for entry in r.entries {
|
||||
guard seen.insert(entry.id).inserted else { continue }
|
||||
page.append(DirectoryResult(entry: entry, localChatId: await localChatId(entry.link)))
|
||||
page.append(DirectoryResult(entry: entry, state: await resultState(entry.link)))
|
||||
}
|
||||
}
|
||||
guard gen == generation else { return }
|
||||
@@ -188,6 +151,7 @@ class OnlineSearchModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
cursor = r.cursor
|
||||
moreCount = r.moreCount
|
||||
directory = append ? directory + page : page
|
||||
lastPageIds = Set(page.map { $0.id })
|
||||
}
|
||||
@@ -233,13 +197,16 @@ struct SearchInDirectoryRow: View {
|
||||
struct DirectorySearchRow: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var entry: DirectorySearchEntry
|
||||
var imageSize: CGFloat
|
||||
var state: DirectoryResultState
|
||||
var showsOpen = true
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProfileImage(
|
||||
imageStr: entry.image,
|
||||
icon: Image(systemName: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill"),
|
||||
size: 42
|
||||
size: imageSize
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
@@ -256,15 +223,17 @@ struct DirectorySearchRow: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Text(membersText)
|
||||
Text(joining ? NSLocalizedString("Already joining", comment: "directory search result") : membersText)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(isChannel ? "Join channel" : "Join group")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
if showsOpen && !joining {
|
||||
Text("Open")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
@@ -274,6 +243,10 @@ struct DirectorySearchRow: View {
|
||||
|
||||
private var isChannel: Bool { entry.entryType.groupType == .channel }
|
||||
|
||||
private var joining: Bool {
|
||||
if case .joining = state { true } else { false }
|
||||
}
|
||||
|
||||
private var membersText: String {
|
||||
let summary = entry.entryType.summary
|
||||
let count = summary.publicMemberCount ?? summary.currentMembers
|
||||
@@ -283,33 +256,6 @@ struct DirectorySearchRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct NameSearchRow: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var result: NameSearchResult
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProfileImage(imageStr: result.image, icon: Image(systemName: 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 DirectoryPageInChatsRow: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -319,7 +265,7 @@ struct DirectoryPageInChatsRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryShowMoreRow: View {
|
||||
struct DirectoryLoadMoreRow: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var onlineSearch: OnlineSearchModel
|
||||
|
||||
@@ -327,11 +273,22 @@ struct DirectoryShowMoreRow: View {
|
||||
Button {
|
||||
Task { await onlineSearch.loadMore() }
|
||||
} label: {
|
||||
Text("Show more")
|
||||
.foregroundColor(onlineSearch.loading ? theme.colors.secondary : theme.colors.primary)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus.magnifyingglass")
|
||||
Text(loadMoreText)
|
||||
}
|
||||
.foregroundColor(onlineSearch.loading ? theme.colors.secondary : theme.colors.primary)
|
||||
}
|
||||
.disabled(onlineSearch.loading)
|
||||
}
|
||||
|
||||
private var loadMoreText: String {
|
||||
if let n = onlineSearch.moreCount, n > 0 {
|
||||
String.localizedStringWithFormat(NSLocalizedString("Load more · %d left", comment: "directory search results"), n)
|
||||
} else {
|
||||
NSLocalizedString("Load more", comment: "directory search results")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryRetryRow: View {
|
||||
@@ -342,14 +299,48 @@ struct DirectoryRetryRow: View {
|
||||
Button {
|
||||
Task { await onlineSearch.retry() }
|
||||
} label: {
|
||||
Text("Search failed, tap to retry")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Text("Search failed, tap to retry")
|
||||
}
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryResultMenu: ViewModifier {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var result: DirectoryResult
|
||||
var imageSize: CGFloat
|
||||
var onOpen: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
content.contextMenu {
|
||||
Button(action: onOpen) {
|
||||
Label("Open", systemImage: "message")
|
||||
}
|
||||
} preview: {
|
||||
DirectorySearchRow(entry: result.entry, imageSize: imageSize, state: result.state, showsOpen: false)
|
||||
.padding()
|
||||
.frame(width: UIScreen.main.bounds.width - 32)
|
||||
.background(theme.colors.background)
|
||||
.environmentObject(theme)
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a pending join keeps the sheet or alert the connection flow shows for it
|
||||
func openDirectoryResult(_ result: DirectoryResult, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?) {
|
||||
let prepareGroup: PrepareGroupMode = if case .joining = result.state { .confirm } else { .open }
|
||||
planAndConnect(result.entry.link, theme: theme, dismiss: dismiss, cleanup: cleanup, prepareGroup: prepareGroup)
|
||||
}
|
||||
|
||||
func confirmDirectorySearch(_ search: @escaping () -> Void) {
|
||||
if UserDefaults.standard.bool(forKey: DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN) {
|
||||
if UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_DIRECTORY_SEARCH) {
|
||||
search()
|
||||
} else {
|
||||
showDirectorySearchAlert(onSearch: search)
|
||||
@@ -358,20 +349,15 @@ 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. 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"),
|
||||
NSLocalizedString("Enable Directory search?", comment: "alert title"),
|
||||
message: NSLocalizedString("Search text is sent to SimpleX Directory, each search separately and without your profile, so it can't build your search history.", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert action"), style: .cancel),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Search", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in onSearch() }
|
||||
),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Search and don't show again", comment: "alert action"),
|
||||
title: NSLocalizedString("Enable", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
UserDefaults.standard.set(true, forKey: DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN)
|
||||
UserDefaults.standard.set(true, forKey: DEFAULT_PRIVACY_DIRECTORY_SEARCH)
|
||||
onSearch()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -93,10 +93,10 @@ struct NewChatSheet: View {
|
||||
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
|
||||
connectNameCandidate: $connectNameCandidate,
|
||||
onSearch: runOnlineSearch
|
||||
onSearch: runDirectorySearch
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
if let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
|
||||
if let candidate = connectNameCandidate {
|
||||
ConnectByNameRow(
|
||||
name: candidate,
|
||||
searchText: $searchText,
|
||||
@@ -105,7 +105,7 @@ struct NewChatSheet: View {
|
||||
dismiss: true
|
||||
)
|
||||
}
|
||||
if !searchTrimmed.isEmpty, !searchShowingSimplexLink {
|
||||
if !searchTrimmed.isEmpty, !searchShowingSimplexLink, !onlineSearch.showResults {
|
||||
SearchInDirectoryRow(searchFocussed: $searchFocussed, onSearch: runDirectorySearch)
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ struct NewChatSheet: View {
|
||||
showDeletedChatIcon: true,
|
||||
showsEmptyState: !onlineSearch.searching && rows.isEmpty
|
||||
)
|
||||
onlineSearchSections(rows)
|
||||
onlineSearchSection(rows)
|
||||
}
|
||||
.onChange(of: searchText) { _ in
|
||||
onlineSearch.reset()
|
||||
@@ -192,49 +192,30 @@ struct NewChatSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func onlineSearchSections(_ rows: OnlineSearchRows) -> some View {
|
||||
if !rows.names.isEmpty {
|
||||
@ViewBuilder private func onlineSearchSection(_ rows: OnlineSearchRows) -> some View {
|
||||
if !rows.isEmpty {
|
||||
Section {
|
||||
ForEach(rows.names) { result in
|
||||
NameSearchRow(result: result)
|
||||
.onTapGesture {
|
||||
searchFocussed = false
|
||||
planAndConnect(result.target, theme: theme, dismiss: true, cleanup: nil)
|
||||
}
|
||||
ForEach(rows.yourChats.compactMap { chatModel.getChat($0) }, id: \.viewId) { chat in
|
||||
ContactListNavLink(chat: chat, showDeletedChatIcon: true)
|
||||
}
|
||||
} header: {
|
||||
onlineSearchHeader("Names")
|
||||
}
|
||||
}
|
||||
if !rows.directory.isEmpty || rows.pageInChats || rows.footer != nil {
|
||||
Section {
|
||||
ForEach(rows.directory) { result in
|
||||
if let chat = result.localChatId.flatMap({ chatModel.getChat($0) }) {
|
||||
ContactListNavLink(chat: chat, showDeletedChatIcon: true)
|
||||
} else {
|
||||
DirectorySearchRow(entry: result.entry)
|
||||
.onTapGesture {
|
||||
searchFocussed = false
|
||||
planAndConnect(result.entry.link, theme: theme, dismiss: true, cleanup: nil)
|
||||
}
|
||||
}
|
||||
ForEach(rows.results) { result in
|
||||
DirectorySearchRow(entry: result.entry, imageSize: 30, state: result.state)
|
||||
.modifier(DirectoryResultMenu(result: result, imageSize: 30, onOpen: { openResult(result) }))
|
||||
.onTapGesture { openResult(result) }
|
||||
}
|
||||
if rows.pageInChats { DirectoryPageInChatsRow() }
|
||||
switch rows.footer {
|
||||
case .retry: DirectoryRetryRow(onlineSearch: onlineSearch)
|
||||
case .showMore: DirectoryShowMoreRow(onlineSearch: onlineSearch)
|
||||
case .loadMore: DirectoryLoadMoreRow(onlineSearch: onlineSearch)
|
||||
case nil: EmptyView()
|
||||
}
|
||||
} header: {
|
||||
onlineSearchHeader("Directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func onlineSearchHeader(_ title: LocalizedStringKey) -> some View {
|
||||
Text(title)
|
||||
.textCase(.uppercase)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
private func openResult(_ result: DirectoryResult) {
|
||||
searchFocussed = false
|
||||
openDirectoryResult(result, theme: theme, dismiss: true, cleanup: nil)
|
||||
}
|
||||
|
||||
private var searchTrimmed: String {
|
||||
@@ -247,13 +228,6 @@ struct NewChatSheet: View {
|
||||
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) } }
|
||||
}
|
||||
|
||||
private func contactListChatPredicate(_ chat: Chat, _ withSearch: Bool) -> Bool {
|
||||
switch chat.chatInfo {
|
||||
case .contactRequest: true
|
||||
|
||||
@@ -1334,6 +1334,10 @@ private func showOpenKnownGroupAlert(
|
||||
)
|
||||
}
|
||||
|
||||
enum PrepareGroupMode {
|
||||
case confirm, open
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#planAndConnect
|
||||
func planAndConnect(
|
||||
_ shortOrFullLink: String,
|
||||
@@ -1342,7 +1346,8 @@ func planAndConnect(
|
||||
dismiss: Bool,
|
||||
cleanup: (() -> Void)? = nil,
|
||||
filterKnownContact: ((Contact) -> Void)? = nil,
|
||||
filterKnownGroup: ((GroupInfo) -> Void)? = nil
|
||||
filterKnownGroup: ((GroupInfo) -> Void)? = nil,
|
||||
prepareGroup: PrepareGroupMode = .confirm
|
||||
) {
|
||||
switch strConnectTarget(shortOrFullLink) {
|
||||
case let .link(_, linkType, _):
|
||||
@@ -1543,18 +1548,30 @@ func planAndConnect(
|
||||
if let groupSLinkData = groupSLinkData_ {
|
||||
logger.debug("planAndConnect, .groupLink, .ok, short link data present")
|
||||
await MainActor.run {
|
||||
showPrepareGroupAlert(
|
||||
connectionLink: connectionLink,
|
||||
groupShortLinkInfo: groupShortLinkInfo_,
|
||||
groupShortLinkData: groupSLinkData,
|
||||
ownerVerification: ownerVerification,
|
||||
verifiedDomain: planSimplexName?.nameDomain,
|
||||
connectOtherButton: connectOtherButton,
|
||||
connectOtherLink: connectOtherLink,
|
||||
theme: theme,
|
||||
dismiss: dismiss,
|
||||
cleanup: cleanup
|
||||
)
|
||||
switch prepareGroup {
|
||||
case .confirm:
|
||||
showPrepareGroupAlert(
|
||||
connectionLink: connectionLink,
|
||||
groupShortLinkInfo: groupShortLinkInfo_,
|
||||
groupShortLinkData: groupSLinkData,
|
||||
ownerVerification: ownerVerification,
|
||||
verifiedDomain: planSimplexName?.nameDomain,
|
||||
connectOtherButton: connectOtherButton,
|
||||
connectOtherLink: connectOtherLink,
|
||||
theme: theme,
|
||||
dismiss: dismiss,
|
||||
cleanup: cleanup
|
||||
)
|
||||
case .open:
|
||||
prepareAndOpenGroup(
|
||||
connectionLink: connectionLink,
|
||||
groupShortLinkInfo: groupShortLinkInfo_,
|
||||
groupShortLinkData: groupSLinkData,
|
||||
verifiedDomain: planSimplexName?.nameDomain,
|
||||
dismiss: dismiss,
|
||||
cleanup: cleanup
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("planAndConnect, .groupLink, .ok, no short link data")
|
||||
|
||||
@@ -17,6 +17,7 @@ struct PrivacySettings: View {
|
||||
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
|
||||
@AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) private var verifySimplexNames = false
|
||||
@AppStorage(DEFAULT_PRIVACY_DIRECTORY_SEARCH) private var directorySearch = false
|
||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true
|
||||
@@ -83,6 +84,9 @@ struct PrivacySettings: View {
|
||||
privacyLinkPreviewsShowAlertGroupDefault.set(false)
|
||||
}
|
||||
}
|
||||
settingsRow("magnifyingglass", color: theme.colors.secondary) {
|
||||
Toggle("Directory search", isOn: $directorySearch)
|
||||
}
|
||||
settingsRow("link", color: theme.colors.secondary) {
|
||||
Toggle("Remove link tracking", isOn: $privacySanitizeLinks)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
|
||||
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
|
||||
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
|
||||
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
|
||||
let DEFAULT_PRIVACY_DIRECTORY_SEARCH = "privacyDirectorySearch"
|
||||
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
|
||||
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
|
||||
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
|
||||
@@ -59,7 +60,6 @@ let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
|
||||
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
|
||||
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
|
||||
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
|
||||
let DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN = "directorySearchAlertShown"
|
||||
let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown"
|
||||
let DEFAULT_SUPPORTER_BANNER_TAPPED = "supporterBannerTapped"
|
||||
let DEFAULT_GET_STAKE_BANNER_TAPPED = "getStakeBannerTapped"
|
||||
@@ -117,6 +117,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
|
||||
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
|
||||
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
|
||||
DEFAULT_PRIVACY_DIRECTORY_SEARCH: false,
|
||||
DEFAULT_EXPERIMENTAL_CALLS: false,
|
||||
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
|
||||
DEFAULT_DEVELOPER_TOOLS: false,
|
||||
@@ -126,7 +127,6 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_CHAT_ITEM_TAIL: true,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
|
||||
DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN: false,
|
||||
DEFAULT_SUPPORTER_BANNER_SHOWN: false,
|
||||
DEFAULT_SUPPORTER_BANNER_TAPPED: false,
|
||||
DEFAULT_GET_STAKE_BANNER_TAPPED: false,
|
||||
@@ -163,7 +163,6 @@ let hintDefaults = [
|
||||
DEFAULT_LA_NOTICE_SHOWN,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
|
||||
DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN,
|
||||
DEFAULT_SUPPORTER_BANNER_SHOWN,
|
||||
DEFAULT_SUPPORTER_BANNER_TAPPED,
|
||||
DEFAULT_GET_STAKE_BANNER_TAPPED,
|
||||
|
||||
Reference in new issue
Block a user