mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-27 22:34:51 +00:00
wip
This commit is contained in:
@@ -133,6 +133,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
|
||||
case apiChangeConnectionUser(connId: Int64, userId: Int64)
|
||||
case apiConnectPlan(userId: Int64, connLink: String, resolveMode: PlanResolveMode, linkOwnerSig: LinkOwnerSig?)
|
||||
case apiSendServiceRequest(userId: Int64, target: String, timeoutSec: Double?, requestJSON: String)
|
||||
case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain?)
|
||||
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain?)
|
||||
case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
|
||||
@@ -355,6 +356,9 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
let resolveStr = resolveMode == .unknown ? "" : " resolve=\(resolveMode.rawValue)"
|
||||
let sigStr = if let linkOwnerSig { " sig=\(encodeJSON(linkOwnerSig))" } else { "" }
|
||||
return "/_connect plan \(userId) \(connLink)\(resolveStr)\(sigStr)"
|
||||
case let .apiSendServiceRequest(userId, target, timeoutSec, requestJSON):
|
||||
let timeoutStr = if let timeoutSec { " timeout=\(timeoutSec)" } else { "" }
|
||||
return "/_service_request \(userId) \(target)\(timeoutStr) \(requestJSON)"
|
||||
case let .apiPrepareContact(userId, connLink, contactShortLinkData, verifiedDomain): return "/_prepare contact \(userId) \(connLink.cmdString)\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(contactShortLinkData))"
|
||||
case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData, verifiedDomain): return "/_prepare group \(userId) \(connLink.cmdString) direct=\(onOff(directLink))\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(groupShortLinkData))"
|
||||
case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
|
||||
@@ -543,6 +547,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
|
||||
case .apiChangeConnectionUser: return "apiChangeConnectionUser"
|
||||
case .apiConnectPlan: return "apiConnectPlan"
|
||||
case .apiSendServiceRequest: return "apiSendServiceRequest"
|
||||
case .apiPrepareContact: return "apiPrepareContact"
|
||||
case .apiPrepareGroup: return "apiPrepareGroup"
|
||||
case .apiChangePreparedContactUser: return "apiChangePreparedContactUser"
|
||||
@@ -821,6 +826,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
|
||||
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
|
||||
case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
|
||||
case connectionPlan(user: UserRef, connLink: CreatedConnLink, planSimplexName: SimplexNameInfo?, otherSimplexName: SimplexNameInfo?, connectionPlan: ConnectionPlan)
|
||||
case serviceResponse(user: UserRef, responseData: JSONValue)
|
||||
case newPreparedChat(user: UserRef, chat: ChatData)
|
||||
case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact)
|
||||
case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo)
|
||||
@@ -865,6 +871,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
|
||||
case .connectionIncognitoUpdated: "connectionIncognitoUpdated"
|
||||
case .connectionUserChanged: "connectionUserChanged"
|
||||
case .connectionPlan: "connectionPlan"
|
||||
case .serviceResponse: "serviceResponse"
|
||||
case .newPreparedChat: "newPreparedChat"
|
||||
case .contactUserChanged: "contactUserChanged"
|
||||
case .groupUserChanged: "groupUserChanged"
|
||||
|
||||
@@ -298,30 +298,47 @@ class ChatItemDummyModel: ObservableObject {
|
||||
func sendUpdate() { objectWillChange.send() }
|
||||
}
|
||||
|
||||
// A directory search and a connection can overlap - tapping a result starts a connection while
|
||||
// the search is still running - so the single progress slot records its owner.
|
||||
enum ConnectProgressOwner {
|
||||
case connect
|
||||
case directorySearch
|
||||
}
|
||||
|
||||
class ConnectProgressManager: ObservableObject {
|
||||
@Published private var connectInProgress: String? = nil
|
||||
@Published private var connectProgressByTimeout: Bool = false
|
||||
private var onCancel: (() -> Void)?
|
||||
private var owner: ConnectProgressOwner?
|
||||
|
||||
static let shared = ConnectProgressManager()
|
||||
|
||||
func startConnectProgress(_ text: String, onCancel: (() -> Void)? = nil) {
|
||||
func startConnectProgress(_ text: String, owner: ConnectProgressOwner = .connect, onCancel: (() -> Void)? = nil) {
|
||||
connectInProgress = text
|
||||
self.owner = owner
|
||||
self.onCancel = onCancel
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
self.connectProgressByTimeout = self.connectInProgress != nil
|
||||
}
|
||||
}
|
||||
|
||||
func stopConnectProgress() {
|
||||
// a late directory search result must not clear the spinner that now belongs to a connection
|
||||
func stopConnectProgress(_ owner: ConnectProgressOwner = .connect) {
|
||||
if let current = self.owner, current != owner { return }
|
||||
connectInProgress = nil
|
||||
self.owner = nil
|
||||
onCancel = nil
|
||||
connectProgressByTimeout = false
|
||||
}
|
||||
|
||||
// a user-initiated cancel, and the takeover in planAndConnect, cancel whatever is running
|
||||
func cancelConnectProgress() {
|
||||
onCancel?()
|
||||
stopConnectProgress()
|
||||
let cancel = onCancel
|
||||
owner = nil
|
||||
onCancel = nil
|
||||
connectInProgress = nil
|
||||
connectProgressByTimeout = false
|
||||
cancel?()
|
||||
}
|
||||
|
||||
var showConnectProgress: String? {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// DirectorySearch.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by spaced4ndy on 12.08.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
|
||||
// The directory's contact address, as published in docs/DIRECTORY.md. It must be the short
|
||||
// link: only that form carries the address DR keys that service requests require, so the full
|
||||
// links on the What's New cards cannot be substituted here.
|
||||
let DIRECTORY_SERVICE_LINK = "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok"
|
||||
|
||||
// A service request is a full DR handshake, so it is slower than a local API call; the user
|
||||
// gets a cancellable spinner while it runs and a retry row if it times out.
|
||||
let DIRECTORY_SEARCH_TIMEOUT_SEC: Double = 10
|
||||
|
||||
struct DirectoryPublicLink: Decodable, Hashable {
|
||||
var connFullLink: String? = nil
|
||||
var connShortLink: String? = nil
|
||||
}
|
||||
|
||||
struct DirectoryEntryType: Decodable, Hashable {
|
||||
var groupType: GroupType? = nil
|
||||
var summary: GroupSummary
|
||||
}
|
||||
|
||||
struct DirectorySearchEntry: Decodable, Hashable, Identifiable {
|
||||
var entryType: DirectoryEntryType
|
||||
var displayName: String
|
||||
var simplexName: String? = nil
|
||||
var groupLink: DirectoryPublicLink
|
||||
var shortDescr: String? = nil
|
||||
var image: String? = nil
|
||||
var activeAt: Date? = nil
|
||||
var createdAt: Date? = nil
|
||||
|
||||
// the directory drops entries with no link, but the response is untrusted input
|
||||
var connectLink: String? { groupLink.connShortLink ?? groupLink.connFullLink }
|
||||
|
||||
// the link is stable and unique per entry, so it also de-duplicates across pages
|
||||
var id: String { connectLink ?? displayName }
|
||||
}
|
||||
|
||||
private struct DirectorySearchResponse: Decodable {
|
||||
var type: String
|
||||
var entries: [DirectorySearchEntry]?
|
||||
var searchCursor: JSONValue?
|
||||
}
|
||||
|
||||
struct DirectorySearchResults {
|
||||
var entries: [DirectorySearchEntry]
|
||||
// opaque: stored and echoed back on the next request, never inspected
|
||||
var cursor: JSONValue?
|
||||
}
|
||||
|
||||
func directorySearchRequestJSON(_ text: String, _ cursor: JSONValue?) -> String {
|
||||
var req: [String: JSONValue] = ["type": .string("search"), "searchText": .string(text)]
|
||||
if let cursor { req["searchCursor"] = cursor }
|
||||
return encodeJSON(JSONValue.object(req))
|
||||
}
|
||||
|
||||
// The response is a tagged object: searchResults or error. Anything else is a failure rather
|
||||
// than something to parse leniently - it comes from outside the app.
|
||||
func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults? {
|
||||
guard let r: DirectorySearchResponse = decodeJSONValue(resp), r.type == "searchResults" else {
|
||||
return nil
|
||||
}
|
||||
return DirectorySearchResults(entries: r.entries ?? [], cursor: r.searchCursor)
|
||||
}
|
||||
@@ -1032,6 +1032,23 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi
|
||||
if let r { throw r.unexpected } else { return nil }
|
||||
}
|
||||
|
||||
// Blocks until the directory replies or the timeout elapses.
|
||||
func apiSearchDirectory(_ text: String, cursor: JSONValue?) async -> DirectorySearchResults? {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiSearchDirectory: no current user")
|
||||
return nil
|
||||
}
|
||||
let req = directorySearchRequestJSON(text, cursor)
|
||||
let r: APIResult<ChatResponse1> = await chatApiSendCmd(
|
||||
.apiSendServiceRequest(userId: userId, target: DIRECTORY_SERVICE_LINK, timeoutSec: DIRECTORY_SEARCH_TIMEOUT_SEC, requestJSON: req)
|
||||
)
|
||||
if case let .result(.serviceResponse(_, responseData)) = r {
|
||||
return parseDirectorySearchResponse(responseData)
|
||||
}
|
||||
logger.error("apiSearchDirectory error: \(String(describing: r))")
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ConnectionPlanResult? {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiConnectPlan: no current user")
|
||||
|
||||
@@ -716,6 +716,50 @@ private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
|
||||
encodeJSON(value).cString(using: .utf8)!
|
||||
}
|
||||
|
||||
// Type-erased JSON, so a service response can cross the API layer without it knowing which
|
||||
// service produced the payload. Callers re-decode it into their own type with decodeJSONValue.
|
||||
public enum JSONValue: Codable, Hashable {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case number(Double)
|
||||
case string(String)
|
||||
case array([JSONValue])
|
||||
case object([String: JSONValue])
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if c.decodeNil() { self = .null }
|
||||
else if let v = try? c.decode(Bool.self) { self = .bool(v) }
|
||||
else if let v = try? c.decode(Double.self) { self = .number(v) }
|
||||
else if let v = try? c.decode(String.self) { self = .string(v) }
|
||||
else if let v = try? c.decode([JSONValue].self) { self = .array(v) }
|
||||
else if let v = try? c.decode([String: JSONValue].self) { self = .object(v) }
|
||||
else {
|
||||
throw DecodingError.dataCorruptedError(in: c, debugDescription: "invalid JSON value")
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .null: try c.encodeNil()
|
||||
case let .bool(v): try c.encode(v)
|
||||
case let .number(v): try c.encode(v)
|
||||
case let .string(v): try c.encode(v)
|
||||
case let .array(v): try c.encode(v)
|
||||
case let .object(v): try c.encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
public var stringValue: String? {
|
||||
if case let .string(v) = self { return v } else { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
public func decodeJSONValue<T: Decodable>(_ value: JSONValue) -> T? {
|
||||
decodeJSON(encodeJSON(value))
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatError
|
||||
public enum ChatError: Decodable, Hashable, Error {
|
||||
case error(errorType: ChatErrorType)
|
||||
|
||||
+18
-4
@@ -45,15 +45,22 @@ import kotlin.collections.ArrayList
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.*
|
||||
|
||||
// A directory search and a connection can overlap - tapping a result starts a connection while
|
||||
// the search is still running - so the single progress slot records its owner: a late search
|
||||
// result must not clear the spinner that now belongs to the connection.
|
||||
enum class ConnectProgressOwner { Connect, DirectorySearch }
|
||||
|
||||
object ConnectProgressManager {
|
||||
private val connectInProgress = mutableStateOf<String?>(null)
|
||||
private val connectProgressByTimeout = mutableStateOf(false)
|
||||
private var onCancel: (() -> Unit)? = null
|
||||
private var owner: ConnectProgressOwner? = null
|
||||
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
fun startConnectProgress(text: String, onCancel: (() -> Unit)? = null) {
|
||||
fun startConnectProgress(text: String, owner: ConnectProgressOwner = ConnectProgressOwner.Connect, onCancel: (() -> Unit)? = null) {
|
||||
connectInProgress.value = text
|
||||
this.owner = owner
|
||||
this.onCancel = onCancel
|
||||
coroutineScope.launch {
|
||||
delay(1000)
|
||||
@@ -61,15 +68,22 @@ object ConnectProgressManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun stopConnectProgress() {
|
||||
fun stopConnectProgress(owner: ConnectProgressOwner = ConnectProgressOwner.Connect) {
|
||||
if (this.owner != null && this.owner != owner) return
|
||||
connectInProgress.value = null
|
||||
this.owner = null
|
||||
onCancel = null
|
||||
connectProgressByTimeout.value = false
|
||||
}
|
||||
|
||||
// a user-initiated cancel, and the takeover in planAndConnect, cancel whatever is running
|
||||
fun cancelConnectProgress() {
|
||||
onCancel?.invoke()
|
||||
stopConnectProgress()
|
||||
val cancel = onCancel
|
||||
owner = null
|
||||
onCancel = null
|
||||
connectInProgress.value = null
|
||||
connectProgressByTimeout.value = false
|
||||
cancel?.invoke()
|
||||
}
|
||||
|
||||
val showConnectProgress: String? get() =
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package chat.simplex.common.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
// The directory's contact address, as published in docs/DIRECTORY.md. It must be the short
|
||||
// link: only that form carries the address DR keys that service requests require, so the full
|
||||
// links on the What's New cards cannot be substituted here.
|
||||
const val DIRECTORY_SERVICE_LINK = "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok"
|
||||
|
||||
// A service request is a full DR handshake, so it is slower than a local API call; the user
|
||||
// gets a cancellable spinner while it runs and a retry row if it times out.
|
||||
const val DIRECTORY_SEARCH_TIMEOUT_SEC = 10.0
|
||||
|
||||
@Serializable
|
||||
data class DirectoryPublicLink(
|
||||
val connFullLink: String? = null,
|
||||
val connShortLink: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DirectoryEntryType(
|
||||
val groupType: GroupType? = null,
|
||||
val summary: GroupSummary,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DirectorySearchEntry(
|
||||
val entryType: DirectoryEntryType,
|
||||
val displayName: String,
|
||||
val simplexName: String? = null,
|
||||
val groupLink: DirectoryPublicLink,
|
||||
val shortDescr: String? = null,
|
||||
val image: String? = null,
|
||||
val activeAt: Instant? = null,
|
||||
val createdAt: Instant? = null,
|
||||
) {
|
||||
// the directory drops entries with no link, but the response is untrusted input
|
||||
val connectLink: String? get() = groupLink.connShortLink ?: groupLink.connFullLink
|
||||
}
|
||||
|
||||
data class DirectorySearchResults(
|
||||
val entries: List<DirectorySearchEntry>,
|
||||
// opaque: stored and echoed back on the next request, never inspected
|
||||
val cursor: JsonObject?,
|
||||
)
|
||||
|
||||
fun directorySearchRequest(text: String, cursor: JsonObject?): JsonObject = buildJsonObject {
|
||||
put("type", JsonPrimitive("search"))
|
||||
put("searchText", JsonPrimitive(text))
|
||||
if (cursor != null) put("searchCursor", cursor)
|
||||
}
|
||||
|
||||
// The response is a tagged object: searchResults or error. Anything else is treated as a failure
|
||||
// rather than parsed leniently - it comes from outside the app.
|
||||
fun parseDirectorySearchResponse(resp: JsonObject): DirectorySearchResults? =
|
||||
when ((resp["type"] as? JsonPrimitive)?.contentOrNull) {
|
||||
"searchResults" -> {
|
||||
val entries = (resp["entries"] as? JsonArray)?.mapNotNull {
|
||||
runCatching { json.decodeFromJsonElement<DirectorySearchEntry>(it) }.getOrNull()
|
||||
} ?: emptyList()
|
||||
DirectorySearchResults(entries, resp["searchCursor"] as? JsonObject)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
+18
@@ -1531,6 +1531,17 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
// Blocks until the directory replies or the timeout elapses, so callers must use
|
||||
// withLongRunningApi, not the single-threaded withBGApi.
|
||||
suspend fun apiSearchDirectory(rh: Long?, text: String, cursor: JsonObject?): DirectorySearchResults? {
|
||||
val userId = kotlin.runCatching { currentUserId("apiSearchDirectory") }.getOrElse { return null }
|
||||
val req = directorySearchRequest(text, cursor)
|
||||
val r = sendCmdWithRetry(rh, CC.APISendServiceRequest(userId, DIRECTORY_SERVICE_LINK, DIRECTORY_SEARCH_TIMEOUT_SEC, req))
|
||||
if (r is API.Result && r.res is CR.CRServiceResponse) return parseDirectorySearchResponse(r.res.responseData)
|
||||
Log.e(TAG, "apiSearchDirectory error: $r")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): ConnectionPlanResult? {
|
||||
val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null }
|
||||
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, resolveMode, linkOwnerSig), inProgress = inProgress)
|
||||
@@ -3879,6 +3890,7 @@ sealed class CC {
|
||||
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
|
||||
class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
|
||||
class APIConnectPlan(val userId: Long, val connLink: String, val resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, val linkOwnerSig: LinkOwnerSig? = null): CC()
|
||||
class APISendServiceRequest(val userId: Long, val target: String, val timeoutSec: Double?, val request: JsonObject): CC()
|
||||
class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
|
||||
class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
|
||||
class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC()
|
||||
@@ -4092,6 +4104,10 @@ sealed class CC {
|
||||
val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else ""
|
||||
"/_connect plan $userId $connLink$resolveStr$sigStr"
|
||||
}
|
||||
is APISendServiceRequest -> {
|
||||
val timeoutStr = if (timeoutSec != null) " timeout=$timeoutSec" else ""
|
||||
"/_service_request $userId $target$timeoutStr ${json.encodeToString(request)}"
|
||||
}
|
||||
is APIPrepareContact -> "/_prepare contact $userId ${connLink.cmdString}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(contactShortLinkData)}"
|
||||
is APIPrepareGroup -> "/_prepare group $userId ${connLink.cmdString} direct=${onOff(directLink)}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(groupShortLinkData)}"
|
||||
is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId"
|
||||
@@ -4278,6 +4294,7 @@ sealed class CC {
|
||||
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
|
||||
is ApiChangeConnectionUser -> "apiChangeConnectionUser"
|
||||
is APIConnectPlan -> "apiConnectPlan"
|
||||
is APISendServiceRequest -> "apiSendServiceRequest"
|
||||
is APIPrepareContact -> "apiPrepareContact"
|
||||
is APIPrepareGroup -> "apiPrepareGroup"
|
||||
is APIChangePreparedContactUser -> "apiChangePreparedContactUser"
|
||||
@@ -6564,6 +6581,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
|
||||
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val planSimplexName: SimplexNameInfo? = null, val otherSimplexName: SimplexNameInfo? = null, val connectionPlan: ConnectionPlan): CR()
|
||||
@Serializable @SerialName("serviceResponse") class CRServiceResponse(val user: UserRef, val responseData: JsonObject): CR()
|
||||
@Serializable @SerialName("newPreparedChat") class NewPreparedChat(val user: UserRef, val chat: Chat): CR()
|
||||
@Serializable @SerialName("contactUserChanged") class ContactUserChanged(val user: UserRef, val fromContact: Contact, val newUser: UserRef, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("groupUserChanged") class GroupUserChanged(val user: UserRef, val fromGroup: GroupInfo, val newUser: UserRef, val toGroup: GroupInfo): CR()
|
||||
|
||||
@@ -25,6 +25,7 @@ import Control.Applicative (optional, (<|>))
|
||||
import Data.Attoparsec.Text (Parser)
|
||||
import qualified Data.Attoparsec.Text as A
|
||||
import Data.Char (isSpace)
|
||||
import qualified Data.Aeson as J
|
||||
import Data.Either (fromRight)
|
||||
import Data.Functor (($>))
|
||||
import Data.Maybe (fromMaybe, isNothing)
|
||||
@@ -66,6 +67,7 @@ data DirectoryEvent
|
||||
| DEItemEditIgnored Contact
|
||||
| DEItemDeleteIgnored Contact
|
||||
| DEContactCommand Contact ChatItemId ADirectoryCmd
|
||||
| DEServiceRequest AgentInvId J.Object
|
||||
| DELogChatResponse Text
|
||||
deriving (Show)
|
||||
|
||||
@@ -110,6 +112,7 @@ crDirectoryEvent_ = \case
|
||||
where
|
||||
ciId = chatItemId' ci
|
||||
err = ADC SDRUser DCUnknownCommand
|
||||
CEvtServiceRequest {requestId, requestData} -> Just $ DEServiceRequest requestId requestData
|
||||
CEvtMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage
|
||||
CEvtChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors)
|
||||
_ -> Nothing
|
||||
|
||||
@@ -123,13 +123,8 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma
|
||||
}
|
||||
imgData = imgFileData groupLink =<< image
|
||||
in (de, imgData)
|
||||
in case publicGroup of
|
||||
Just PublicGroupProfile {groupLink = sLnk} ->
|
||||
Just $ entry $ PublicLink Nothing (Just sLnk)
|
||||
Nothing ->
|
||||
entry . toPublicLink . connLinkContact <$> gLink_
|
||||
in entry <$> groupPublicLink g gLink_
|
||||
where
|
||||
toPublicLink (CCLink fullLink shortLink) = PublicLink (Just fullLink) shortLink
|
||||
imgFileData :: PublicLink -> ImageData -> Maybe (FilePath, ByteString)
|
||||
imgFileData PublicLink {connFullLink, connShortLink} (ImageData img) =
|
||||
let (img', imgExt) =
|
||||
@@ -145,6 +140,14 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma
|
||||
Right img'' -> Just (imgFile, img'')
|
||||
Left _ -> Nothing
|
||||
|
||||
-- a public group publishes its own link in the profile; other groups only have the link the service created
|
||||
groupPublicLink :: GroupInfo -> Maybe GroupLink -> Maybe PublicLink
|
||||
groupPublicLink GroupInfo {groupProfile = GroupProfile {publicGroup}} gLink_ = case publicGroup of
|
||||
Just PublicGroupProfile {groupLink = sLnk} -> Just $ PublicLink Nothing (Just sLnk)
|
||||
Nothing -> toPublicLink . connLinkContact <$> gLink_
|
||||
where
|
||||
toPublicLink (CCLink fullLink shortLink) = PublicLink (Just fullLink) shortLink
|
||||
|
||||
generateListing :: FilePath -> [(GroupInfo, GroupReg, Maybe GroupLink)] -> IO ()
|
||||
generateListing dir gs = do
|
||||
createDirectoryIfMissing True dir
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
||||
|
||||
module Directory.Rpc where
|
||||
|
||||
import qualified Data.Aeson.TH as JQ
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Directory.Listing
|
||||
import Directory.Search
|
||||
import Directory.Store
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.SimplexName (SimplexNameInfo (..), SimplexNameType (..), shortNameInfoStr)
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON)
|
||||
|
||||
data DirectoryRequest = DRSearch
|
||||
{ searchText :: Text,
|
||||
-- opaque to the client: it is stored and echoed back, never inspected
|
||||
searchCursor :: Maybe SearchCursor
|
||||
}
|
||||
|
||||
data DirectorySearchEntry = DirectorySearchEntry
|
||||
{ entryType :: DirectoryEntryType,
|
||||
displayName :: Text,
|
||||
simplexName :: Maybe Text,
|
||||
groupLink :: PublicLink,
|
||||
-- stored text, not DirectoryEntry's MarkdownList: the apps parse markdown locally
|
||||
shortDescr :: Maybe Text,
|
||||
image :: Maybe ImageData,
|
||||
activeAt :: Maybe UTCTime,
|
||||
createdAt :: Maybe UTCTime
|
||||
}
|
||||
|
||||
data DirectoryResponse
|
||||
= DRSearchResults
|
||||
{ entries :: [DirectorySearchEntry],
|
||||
searchCursor :: Maybe SearchCursor -- Nothing when there are no more results
|
||||
}
|
||||
| DRError {errorMessage :: Text}
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''DirectorySearchEntry)
|
||||
|
||||
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DR") ''DirectoryRequest)
|
||||
|
||||
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DR") ''DirectoryResponse)
|
||||
|
||||
-- Entries without any link are dropped: there would be nothing to connect to.
|
||||
searchEntry :: UTCTime -> GroupInfo -> Maybe GroupLink -> Maybe DirectorySearchEntry
|
||||
searchEntry now g@GroupInfo {groupProfile, chatTs, createdAt = groupCreatedAt, groupSummary} gLink_ =
|
||||
entry <$> groupPublicLink g gLink_
|
||||
where
|
||||
GroupProfile {displayName, shortDescr, image, memberAdmission, publicGroup} = groupProfile
|
||||
entry groupLink =
|
||||
DirectorySearchEntry
|
||||
{ entryType = DETGroup ((\PublicGroupProfile {groupType} -> groupType) <$> publicGroup) memberAdmission groupSummary,
|
||||
displayName,
|
||||
simplexName = shortNameInfoStr . SimplexNameInfo NTPublicGroup <$> verifiedGroupDomain g,
|
||||
groupLink,
|
||||
shortDescr,
|
||||
image,
|
||||
activeAt = recentRoundedTime 900 now $ fromMaybe groupCreatedAt chatTs,
|
||||
createdAt = recentRoundedTime 86400 now groupCreatedAt
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Directory.Search where
|
||||
|
||||
import qualified Data.Aeson.TH as JQ
|
||||
import Data.Int (Int64)
|
||||
import Data.Text (Text)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Parsers (defaultJSON)
|
||||
|
||||
data SearchRequest = SearchRequest
|
||||
{ searchType :: SearchType,
|
||||
@@ -20,3 +24,5 @@ data SearchCursor = SearchCursor
|
||||
}
|
||||
|
||||
data SearchType = STAll | STRecent | STSearch Text
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''SearchCursor)
|
||||
|
||||
@@ -20,17 +20,22 @@ where
|
||||
|
||||
import Control.Concurrent (forkIO, threadDelay)
|
||||
import Control.Concurrent.STM
|
||||
import Control.Exception (SomeException, try)
|
||||
import Control.Exception (SomeException, finally, try)
|
||||
import Control.Logger.Simple
|
||||
import Control.Monad
|
||||
import Control.Monad.Except
|
||||
import Control.Monad.IO.Class
|
||||
import qualified Data.Attoparsec.Text as A
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Aeson.Types as JT
|
||||
import Data.Bifunctor (first)
|
||||
import Data.Either (fromRight)
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Either (fromRight, isRight)
|
||||
import Data.Foldable (foldl')
|
||||
import Data.List (find, intercalate)
|
||||
import Data.List.NonEmpty (NonEmpty (..))
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Functor (($>))
|
||||
import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList)
|
||||
import qualified Data.Set as S
|
||||
import Data.Text (Text)
|
||||
@@ -43,6 +48,7 @@ import Directory.Captcha
|
||||
import Directory.Events
|
||||
import Directory.Listing
|
||||
import Directory.Options
|
||||
import Directory.Rpc
|
||||
import Directory.Search
|
||||
import Directory.Store
|
||||
import Directory.Store.Migrate
|
||||
@@ -54,7 +60,7 @@ import Simplex.Chat.Core
|
||||
import Simplex.Chat.Markdown (Format (..), FormattedText (..), SimplexLinkType (..), parseMaybeMarkdownList, viewName)
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.Protocol (GroupShortLinkData (..), LinkOwnerSig (..), MsgChatLink (..), MsgContent (..), memberSupportVoiceVersion)
|
||||
import Simplex.Chat.Protocol (GroupShortLinkData (..), compressServiceBody, LinkOwnerSig (..), MsgChatLink (..), MsgContent (..), memberSupportVoiceVersion)
|
||||
import Simplex.Chat.Store.Direct (getContact)
|
||||
import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, getGroupMemberByMemberId, setGroupCustomData) -- TODO remove setGroupCustomData
|
||||
import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo)
|
||||
@@ -103,7 +109,9 @@ data ServiceState = ServiceState
|
||||
pendingCaptchas :: TMap GroupMemberId PendingCaptcha,
|
||||
serviceCC :: TMVar ChatController,
|
||||
eventQ :: TQueue DirectoryEvent,
|
||||
updateListingsJob :: TMVar ()
|
||||
updateListingsJob :: TMVar (),
|
||||
-- service requests carry no caller identity, so the only possible bound is global
|
||||
serviceRequestsInFlight :: TVar Int
|
||||
}
|
||||
|
||||
data CaptchaMode = CMText | CMAudio
|
||||
@@ -132,7 +140,14 @@ newServiceState opts = do
|
||||
serviceCC <- newEmptyTMVarIO
|
||||
eventQ <- newTQueueIO
|
||||
updateListingsJob <- newEmptyTMVarIO
|
||||
pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, serviceCC, eventQ, updateListingsJob}
|
||||
serviceRequestsInFlight <- newTVarIO 0
|
||||
pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, serviceCC, eventQ, updateListingsJob, serviceRequestsInFlight}
|
||||
|
||||
-- Requests are answered off the event loop, which is shared with registrations and captchas,
|
||||
-- so the bound has to be here rather than in the loop. Over the bound requests are refused
|
||||
-- immediately: a caller gets an error rather than waiting out its timeout.
|
||||
maxServiceRequestsInFlight :: Int
|
||||
maxServiceRequestsInFlight = 8
|
||||
|
||||
welcomeGetOpts :: IO DirectoryOpts
|
||||
welcomeGetOpts = do
|
||||
@@ -318,7 +333,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na
|
||||
pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling}
|
||||
|
||||
directoryServiceEvent :: DirectoryOpts -> ServiceState -> User -> ChatController -> DirectoryEvent -> IO ()
|
||||
directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha} env@ServiceState {searchRequests} user@User {userId} cc = \case
|
||||
directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha} env@ServiceState {searchRequests, serviceRequestsInFlight} user@User {userId} cc = \case
|
||||
DEContactConnected ct -> deContactConnected ct
|
||||
DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole
|
||||
DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner
|
||||
@@ -344,8 +359,64 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o
|
||||
SDRUser -> deUserCommand ct ciId cmd
|
||||
SDRAdmin -> deAdminCommand ct ciId cmd
|
||||
SDRSuperUser -> deSuperUserCommand ct ciId cmd
|
||||
DEServiceRequest reqId req -> deServiceRequest reqId req
|
||||
DELogChatResponse r -> logInfo r
|
||||
where
|
||||
deServiceRequest :: AgentInvId -> J.Object -> IO ()
|
||||
deServiceRequest reqId req = do
|
||||
accepted <- atomically $ stateTVar serviceRequestsInFlight $ \n ->
|
||||
if n < maxServiceRequestsInFlight then (True, n + 1) else (False, n)
|
||||
if accepted
|
||||
then void $ forkIO $ (respond =<< requestResponse) `finally` releaseSlot
|
||||
else reject "service is busy"
|
||||
where
|
||||
releaseSlot = atomically $ modifyTVar' serviceRequestsInFlight (subtract 1)
|
||||
-- rejecting is cheaper than answering and fails the caller immediately
|
||||
reject reason =
|
||||
sendChatCmd cc (APIRejectServiceRequest userId reqId $ Just reason) >>= \case
|
||||
Right _ -> pure ()
|
||||
Left e -> logError $ "service reject error: " <> tshow e
|
||||
requestResponse = case JT.parseMaybe JT.parseJSON (J.Object req) of
|
||||
Nothing -> pure $ DRError "unsupported request"
|
||||
Just DRSearch {searchText, searchCursor} -> directorySearch searchText searchCursor
|
||||
respond resp = case J.toJSON resp of
|
||||
J.Object o ->
|
||||
sendChatCmd cc (APISendServiceResponse userId reqId o) >>= \case
|
||||
Right _ -> pure ()
|
||||
Left e -> logError $ "service response error: " <> tshow e
|
||||
_ -> logError "service response is not an object"
|
||||
directorySearch :: Text -> Maybe SearchCursor -> IO DirectoryResponse
|
||||
directorySearch searchText cursor_ =
|
||||
searchListedGroups cc user (STSearch searchText) cursor_ searchResults >>= \case
|
||||
Left e -> logError ("searchListedGroups error: " <> T.pack e) $> DRError "search failed"
|
||||
Right (gs, _) ->
|
||||
getGroupLinks cc user (map fst gs) >>= \case
|
||||
Left e -> logError ("getGroupLinks error: " <> T.pack e) $> DRError "search failed"
|
||||
Right links -> do
|
||||
now <- getCurrentTime
|
||||
-- rows without a link are dropped, so entries and their cursors must stay paired
|
||||
let rows = [(gr, e) | (gr, Just e) <- zipWith (\gr@(g, _) l -> (gr, searchEntry now g l)) gs links]
|
||||
pure $ searchResults_ $ fitRows rows
|
||||
where
|
||||
-- send as many entries as the padded envelope allows; a lone entry that does not fit
|
||||
-- is sent without its image, so paging always makes progress
|
||||
fitRows [] = []
|
||||
fitRows rows
|
||||
| fits rows = rows
|
||||
| [(gr, e)] <- rows = [(gr, dropImage e)]
|
||||
| otherwise = fitRows $ init rows
|
||||
dropImage :: DirectorySearchEntry -> DirectorySearchEntry
|
||||
dropImage e = e {image = Nothing}
|
||||
fits = isRight . compressServiceBody . LB.toStrict . J.encode . searchResults_
|
||||
searchResults_ rows =
|
||||
DRSearchResults
|
||||
{ entries = map snd rows,
|
||||
-- the cursor is the last row actually sent, so truncated rows are not skipped
|
||||
searchCursor = rowCursor . fst <$> lastMaybe rows
|
||||
}
|
||||
lastMaybe = foldl' (\_ x -> Just x) Nothing
|
||||
rowCursor (GroupInfo {groupId, groupSummary = GroupSummary {currentMembers}}, GroupReg {createdAt}) =
|
||||
SearchCursor {lastMembers = currentMembers, lastCreatedAt = createdAt, lastGroupId = groupId}
|
||||
groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt $ simplexChatContact cReq) strEncodeTxt sLnk_
|
||||
withAdminUsers action = void . forkIO $ do
|
||||
forM_ superUsers $ \KnownContact {contactId} -> action contactId
|
||||
|
||||
@@ -38,6 +38,7 @@ module Directory.Store
|
||||
listPendingGroups,
|
||||
getAllListedGroups,
|
||||
getAllListedGroups_,
|
||||
getGroupLinks,
|
||||
searchListedGroups,
|
||||
verifiedGroupDomain,
|
||||
groupRegStatusText,
|
||||
@@ -340,6 +341,12 @@ getAllListedGroups_ db cxt user@User {userId, userContactId} = do
|
||||
where
|
||||
withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g)
|
||||
|
||||
-- only the RPC search needs links, so they are read for the returned page rather than in the search query
|
||||
getGroupLinks :: ChatController -> User -> [GroupInfo] -> IO (Either String [Maybe GroupLink])
|
||||
getGroupLinks cc user gs =
|
||||
withDB' "getGroupLinks" cc $ \db ->
|
||||
mapM (\g -> eitherToMaybe <$> runExceptT (getGroupLink db user g)) gs
|
||||
|
||||
searchListedGroups :: ChatController -> User -> SearchType -> Maybe SearchCursor -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int))
|
||||
searchListedGroups cc user@User {userId, userContactId} searchType cursor_ pageSize =
|
||||
withDB' "searchListedGroups" cc $ \db -> do
|
||||
|
||||
Reference in New Issue
Block a user