mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 10:47:32 +00:00
core: alerts with information about registering names (#7653)
* minimal api change * embed status. reduce * drop fake owner change * no-valid-link fix * add UX flow for name lookups * diagram edits * corrections * update name lookup API * manual renaming * implement core + cli * implement UI * fix * build iOS app blindly * shrink diff * fix review comments * review iterations * opuse 5.5 /goal iterations * fixes EP review * fixes * fixes * short goal iterations * refactor core-UI API to NameWarning with logic in core * diff cosmetics * simplify and normalize diagram * add excalidraw normalizer script and test git hook * cosmetics * commit wireframe collab doc to repo as README * review round on UX coverage * iterate on /goal * iterate on /goal * add tests * iterate on /goal * fix freshness bug with local chats: When core marks a chat verified for a name, it now first un-verifies the user's other chats of the same kind: - a contact name covers contacts and business chats; - a channel name covers channels. * align app divergences * docs fixes * iterations on /goal * align excalidraw * align and simplify core/UI/UXcanvas * 27 rounds of /goal * review * core: simplify connect plan with name lookup * simplify * cache names in a separate table * cache names resolution * wip * all * fix * remove cache * update plans * simplify * bot types * change types * simplify * update bot types * remove review * update view, tests * simplify "unverify" * update query plan * changes * simplify * test helpers * simpler * update query plans * fix * query plans * query plans * fixes * query plans --------- Co-authored-by: Alain Brenzikofer <alain@encointer.org> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
39 files changed
+2751
-385
No files matched your search
+11
-4
@@ -507,7 +507,7 @@ object ChatModel {
|
||||
|
||||
suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null))
|
||||
|
||||
private suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) {
|
||||
suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) {
|
||||
if (hasChat(rhId, cInfo.id)) {
|
||||
updateChatInfo(rhId, cInfo)
|
||||
} else if (addMissing) {
|
||||
@@ -2140,7 +2140,7 @@ data class LocalProfile(
|
||||
val peerType: ChatPeerType? = null,
|
||||
val localBadge: LocalBadge? = null,
|
||||
val contactDomain: SimplexDomainClaim? = null,
|
||||
val contactDomainVerified: Boolean? = null
|
||||
val contactDomainVerified: DomainVerification? = null
|
||||
): NamedChat {
|
||||
override val profileDescription: String? get() = description
|
||||
|
||||
@@ -2450,7 +2450,7 @@ enum class BadgeAlertKind {
|
||||
@SerialName("issueFailed") IssueFailed
|
||||
}
|
||||
|
||||
private fun badgeDateText(date: Instant): String {
|
||||
fun badgeDateText(date: Instant): String {
|
||||
val ts = date.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
return ts.toJavaLocalDateTime().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG))
|
||||
}
|
||||
@@ -2506,7 +2506,7 @@ data class GroupInfo (
|
||||
val chatTags: List<Long>,
|
||||
val chatItemTTL: Long?,
|
||||
override val localAlias: String,
|
||||
val groupDomainVerified: Boolean? = null,
|
||||
val groupDomainVerified: DomainVerification? = null,
|
||||
): SomeChat, NamedChat {
|
||||
override val chatType get() = ChatType.Group
|
||||
override val id get() = "#$groupId"
|
||||
@@ -2638,6 +2638,13 @@ data class SimplexDomainClaim(
|
||||
val shortName: String get() = domain.removeSuffix(".simplex")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class DomainVerification {
|
||||
@SerialName("failed") Failed,
|
||||
@SerialName("verified") Verified,
|
||||
@SerialName("moved") Moved,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PublicGroupAccess(
|
||||
val groupWebPage: String? = null,
|
||||
|
||||
+68
-2
@@ -34,6 +34,7 @@ import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
|
||||
import chat.simplex.common.views.chatlist.openGroupChat
|
||||
import chat.simplex.common.views.database.deleteDatabaseBackups
|
||||
import chat.simplex.common.views.migration.MigrationFileLinkData
|
||||
import chat.simplex.common.views.newchat.showNameWarningAlert
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.defaultConditionsLink
|
||||
@@ -1689,6 +1690,8 @@ object ChatController {
|
||||
generalGetString(MR.strings.simplex_name_no_valid_link),
|
||||
generalGetString(MR.strings.simplex_name_no_valid_link_desc).format(domain)
|
||||
)
|
||||
} else if (r.err.errorType.simplexDomainError is SimplexDomainError.NameWarning) {
|
||||
showNameWarningAlert(r.rhId, r.err.errorType.simplexDomain, r.err.errorType.simplexDomainError.nameWarning, own = false, openExistingChat = null, cleanup = null)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.simplex_name_unconfirmed),
|
||||
@@ -2987,6 +2990,25 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
}
|
||||
is CR.NameMoved -> {
|
||||
if (active(r.user)) {
|
||||
withContext(Dispatchers.Main) {
|
||||
fun chatInfo(id: String) = chatModel.chatsContext.chats.value.firstOrNull { it.remoteHostId == rhId && it.id == id }?.chatInfo
|
||||
r.contactIds.forEach { contactId ->
|
||||
val cInfo = chatInfo("@$contactId")
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
chatModel.chatsContext.updateChatInfo(rhId, ChatInfo.Direct(cInfo.contact.copy(profile = cInfo.contact.profile.copy(contactDomainVerified = DomainVerification.Moved))))
|
||||
}
|
||||
}
|
||||
r.groupIds.forEach { groupId ->
|
||||
val cInfo = chatInfo("#$groupId")
|
||||
if (cInfo is ChatInfo.Group) {
|
||||
chatModel.chatsContext.updateChatInfo(rhId, ChatInfo.Group(cInfo.groupInfo.copy(groupDomainVerified = DomainVerification.Moved), groupChatScope = null))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is CR.GroupMemberUpdated -> {
|
||||
if (active(r.user)) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -6796,6 +6818,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef, val contactRequest: UserContactRequest, val contact_: Contact?): CR()
|
||||
@Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("nameMoved") class NameMoved(val user: UserRef, val contactIds: List<Long>, val groupIds: List<Long>): CR()
|
||||
@Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR()
|
||||
@Serializable @SerialName("subscriptionStatus") class SubscriptionStatusEvt(val subscriptionStatus: SubscriptionStatus, val connections: List<String>): CR()
|
||||
@Serializable @SerialName("chatInfoUpdated") class ChatInfoUpdated(val user: UserRef, val chatInfo: ChatInfo): CR()
|
||||
@@ -6998,6 +7021,7 @@ sealed class CR {
|
||||
is AcceptingContactRequest -> "acceptingContactRequest"
|
||||
is ContactRequestRejected -> "contactRequestRejected"
|
||||
is ContactUpdated -> "contactUpdated"
|
||||
is NameMoved -> "nameMoved"
|
||||
is GroupMemberUpdated -> "groupMemberUpdated"
|
||||
is SubscriptionStatusEvt -> "subscriptionStatus"
|
||||
is ChatInfoUpdated -> "chatInfoUpdated"
|
||||
@@ -7190,6 +7214,7 @@ sealed class CR {
|
||||
is AcceptingContactRequest -> withUser(user, json.encodeToString(contact))
|
||||
is ContactRequestRejected -> withUser(user, "contactRequest: ${json.encodeToString(contactRequest)}\ncontact_: ${json.encodeToString(contact_)}")
|
||||
is ContactUpdated -> withUser(user, json.encodeToString(toContact))
|
||||
is NameMoved -> withUser(user, "contactIds: $contactIds\ngroupIds: $groupIds")
|
||||
is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember")
|
||||
is SubscriptionStatusEvt -> "subscriptionStatus $subscriptionStatus\nconnections: $connections"
|
||||
is ChatInfoUpdated -> withUser(user, json.encodeToString(chatInfo))
|
||||
@@ -7372,6 +7397,7 @@ sealed class OwnerVerification {
|
||||
sealed class SimplexDomainError {
|
||||
@Serializable @SerialName("noValidLink") object NoValidLink : SimplexDomainError()
|
||||
@Serializable @SerialName("unknownDomain") object UnknownDomain : SimplexDomainError()
|
||||
@Serializable @SerialName("nameWarning") class NameWarning(val nameWarning: chat.simplex.common.model.NameWarning) : SimplexDomainError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -7495,12 +7521,52 @@ enum class PlanResolveMode {
|
||||
|
||||
@Serializable
|
||||
sealed class ConnectionPlan {
|
||||
open val nameChange: NameChange? get() = null
|
||||
val localChats: List<ChatInfo> get() = listOfNotNull(planChat, knownChat)
|
||||
val planChat: ChatInfo? get() = when (this) {
|
||||
is ContactAddress -> when (val p = contactAddressPlan) {
|
||||
is ContactAddressPlan.ConnectingProhibit -> ChatInfo.Direct(p.contact)
|
||||
is ContactAddressPlan.Known -> ChatInfo.Direct(p.contact)
|
||||
is ContactAddressPlan.ContactViaAddress -> ChatInfo.Direct(p.contact)
|
||||
else -> null
|
||||
}
|
||||
is GroupLink -> when (val p = groupLinkPlan) {
|
||||
is GroupLinkPlan.OwnLink -> p.groupInfo
|
||||
is GroupLinkPlan.Known -> p.groupInfo
|
||||
is GroupLinkPlan.ConnectingProhibit -> p.groupInfo_
|
||||
else -> null
|
||||
}?.let { ChatInfo.Group(it, groupChatScope = null) }
|
||||
else -> null
|
||||
}
|
||||
val knownChat: ChatInfo? get() = (nameChange as? NameChange.Moved)?.knownChat
|
||||
val isOwnLink: Boolean get() = when (this) {
|
||||
is ContactAddress -> contactAddressPlan is ContactAddressPlan.OwnLink
|
||||
is GroupLink -> groupLinkPlan is GroupLinkPlan.OwnLink
|
||||
else -> false
|
||||
}
|
||||
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
|
||||
@Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan()
|
||||
@Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan()
|
||||
@Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan, override val nameChange: NameChange? = null): ConnectionPlan()
|
||||
@Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan, override val nameChange: NameChange? = null): ConnectionPlan()
|
||||
@Serializable @SerialName("error") class Error(val chatError: ChatError): ConnectionPlan()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class NameChange {
|
||||
@Serializable @SerialName("lapsed") class Lapsed(val nameWarning: NameWarning): NameChange()
|
||||
@Serializable @SerialName("moved") class Moved(val knownChat: ChatInfo): NameChange()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class NameWarning {
|
||||
@Serializable @SerialName("expired") class Expired(val expiredAt: Instant, val graceUntil: Instant? = null): NameWarning()
|
||||
@Serializable @SerialName("available") class Available(val price: NamePrice): NameWarning()
|
||||
@Serializable @SerialName("reservedForCommunity") object ReservedForCommunity: NameWarning()
|
||||
@Serializable @SerialName("notRegistered") object NotRegistered: NameWarning()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NamePrice(val amount: Long, val years: Int)
|
||||
|
||||
@Serializable
|
||||
sealed class InvitationLinkPlan {
|
||||
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): InvitationLinkPlan()
|
||||
|
||||
+8
-8
@@ -20,16 +20,16 @@ import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
// Renders a contact's / channel's SimpleX name with its 3-state verification indicator.
|
||||
// `verification`: null = not attempted, false = failed, true = verified.
|
||||
// Renders a contact's / channel's SimpleX name with its verification indicator.
|
||||
// `verified`: null = not attempted, Failed, Verified, or Moved to another chat.
|
||||
// `verify` runs the verify API, updates the model and returns (newVerification, failureReason);
|
||||
// null on network error. With `autoVerify`, it runs once on open when state is null.
|
||||
@Composable
|
||||
fun SimplexNameView(
|
||||
simplexName: String,
|
||||
verified: Boolean?,
|
||||
verified: DomainVerification?,
|
||||
verifiable: Boolean = true,
|
||||
verify: suspend () -> Pair<Boolean?, String?>?
|
||||
verify: suspend () -> Pair<DomainVerification?, String?>?
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val inFlight = remember { mutableStateOf(false) }
|
||||
@@ -66,7 +66,7 @@ fun SimplexNameView(
|
||||
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val nameStyle = MaterialTheme.typography.body2.copy(
|
||||
color = if (verified == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
color = if (verified == DomainVerification.Verified) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -78,14 +78,14 @@ fun SimplexNameView(
|
||||
Text(simplexName, style = nameStyle)
|
||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
verified == true ->
|
||||
verified == DomainVerification.Verified ->
|
||||
SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_check_filled, MaterialTheme.colors.primary) {
|
||||
clipboard.setText(AnnotatedString(simplexName))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}
|
||||
!verifiable -> Text(simplexName, style = nameStyle)
|
||||
verified == false ->
|
||||
SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_close, Color.Red) { runVerify(manual = true) }
|
||||
verified == DomainVerification.Failed || verified == DomainVerification.Moved ->
|
||||
SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_close, if (verified == DomainVerification.Failed) Color.Red else MaterialTheme.colors.secondary) { runVerify(manual = true) }
|
||||
else -> {
|
||||
Text(simplexName, style = nameStyle)
|
||||
Text(
|
||||
|
||||
+16
-37
@@ -851,16 +851,16 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
|
||||
if (candidate != null) {
|
||||
// resolve the name locally on each keystroke, debounced; collectLatest cancels the in-flight
|
||||
// search when the next keystroke arrives. A bare name can be a contact or a channel, so search
|
||||
// both and filter every known chat found; drop the row only when both types are already known.
|
||||
// both and filter every known chat found.
|
||||
delay(NAME_SEARCH_DEBOUNCE_MS)
|
||||
val rhId = chatModel.remoteHostId()
|
||||
val inProgress = mutableStateOf(false) // background search: no spinner, no error alerts
|
||||
val targets = if (candidate.startsWith("@") || candidate.startsWith("#")) listOf(candidate) else listOf("@$candidate", "#$candidate")
|
||||
val ids = targets.mapNotNull { name ->
|
||||
knownChatId(rhId, chatModel.controller.apiConnectPlan(rhId, name, PlanResolveMode.PRMNever, inProgress = inProgress))
|
||||
val localChats = targets.flatMap { name ->
|
||||
chatModel.controller.apiConnectPlan(rhId, name, PlanResolveMode.PRMNever, inProgress = inProgress)?.connectionPlan?.localChats ?: emptyList()
|
||||
}
|
||||
searchChatFilteredBySimplexLink.value = ids.toSet()
|
||||
if (ids.size == targets.size) connectNameCandidate.value = null
|
||||
localChats.forEach { chatModel.chatsContext.updateChat(rhId, it) }
|
||||
searchChatFilteredBySimplexLink.value = localChats.map { it.id }.toSet()
|
||||
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
|
||||
if (it.isNotEmpty()) {
|
||||
focusRequester.requestFocus()
|
||||
@@ -985,6 +985,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
val searchShowingSimplexLink = remember { mutableStateOf(false) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
|
||||
val showLocalChats: (List<ChatInfo>) -> Unit = { localChats -> searchChatFilteredBySimplexLink.value += localChats.map { it.id } }
|
||||
val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value)
|
||||
val topPaddingToContent = topPaddingToContent(false)
|
||||
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
|
||||
@@ -1018,10 +1019,10 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
|
||||
Divider()
|
||||
// bottom toolbar: search bar below, so on desktop the connect row goes below the tags
|
||||
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
|
||||
TagsOrConnectByName(searchText, connectNameCandidate, showLocalChats) { candidate ->
|
||||
TagsView(searchText)
|
||||
Divider()
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null, showLocalChats = showLocalChats)
|
||||
}
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
|
||||
@@ -1029,8 +1030,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
} else {
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
|
||||
// top toolbar: search bar above, so on desktop the connect row goes above the tags
|
||||
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
|
||||
TagsOrConnectByName(searchText, connectNameCandidate, showLocalChats) { candidate ->
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null, showLocalChats = showLocalChats)
|
||||
Divider()
|
||||
TagsView(searchText)
|
||||
}
|
||||
@@ -1162,30 +1163,6 @@ internal fun nameSearchCandidate(str: String): String? {
|
||||
}
|
||||
}
|
||||
|
||||
// The chat id a local (PRMNever) search resolved to — a contact, a business, or a channel — or null on a miss.
|
||||
// The core returns the correct type for @ vs # (getContactToConnect / type-filtered getGroupToConnect), so no
|
||||
// client-side type check is needed.
|
||||
internal suspend fun knownChatId(rhId: Long?, result: ConnectionPlanResult?): String? = when (val plan = result?.connectionPlan) {
|
||||
is ConnectionPlan.ContactAddress -> (plan.contactAddressPlan as? ContactAddressPlan.Known)?.contact?.let { contact ->
|
||||
// a name-resolved chat may be prepared in the store but not yet listed, so add it (as the tap path does)
|
||||
if (chatModel.getContactChat(contact.contactId) == null) {
|
||||
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
|
||||
}
|
||||
contact.id
|
||||
}
|
||||
is ConnectionPlan.GroupLink -> (when (val g = plan.groupLinkPlan) {
|
||||
is GroupLinkPlan.Known -> g.groupInfo
|
||||
is GroupLinkPlan.OwnLink -> g.groupInfo
|
||||
else -> null
|
||||
})?.let { gInfo ->
|
||||
if (chatModel.getGroupChat(gInfo.groupId) == null) {
|
||||
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(gInfo, groupChatScope = null), chatItems = emptyList()))
|
||||
}
|
||||
gInfo.id
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
// The list tags and the connect-by-name row share one slot. When there is no name, the tags show; on
|
||||
// mobile the row replaces the tags while shown. On desktop both show, arranged by the caller (which
|
||||
// knows whether the search bar is above or below), passed as desktopView.
|
||||
@@ -1193,18 +1170,19 @@ internal suspend fun knownChatId(rhId: Long?, result: ConnectionPlanResult?): St
|
||||
private fun TagsOrConnectByName(
|
||||
searchText: MutableState<TextFieldValue>,
|
||||
connectNameCandidate: MutableState<String?>,
|
||||
showLocalChats: (List<ChatInfo>) -> Unit,
|
||||
desktopView: @Composable (candidate: String) -> Unit,
|
||||
) {
|
||||
val candidate = connectNameCandidate.value
|
||||
when {
|
||||
candidate == null -> TagsView(searchText)
|
||||
!appPlatform.isDesktop -> ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
|
||||
!appPlatform.isDesktop -> ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null, showLocalChats = showLocalChats)
|
||||
else -> desktopView(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ConnectByNameRow(name: String, searchText: MutableState<TextFieldValue>, connectNameCandidate: MutableState<String?>, close: (() -> Unit)?) {
|
||||
internal fun ConnectByNameRow(name: String, searchText: MutableState<TextFieldValue>, connectNameCandidate: MutableState<String?>, close: (() -> Unit)?, showLocalChats: ((List<ChatInfo>) -> Unit)? = null) {
|
||||
val view = LocalMultiplatformView()
|
||||
Row(
|
||||
Modifier
|
||||
@@ -1215,11 +1193,12 @@ internal fun ConnectByNameRow(name: String, searchText: MutableState<TextFieldVa
|
||||
planAndConnect(
|
||||
chatModel.remoteHostId(),
|
||||
name,
|
||||
close = close,
|
||||
cleanup = {
|
||||
close = {
|
||||
close?.invoke()
|
||||
searchText.value = TextFieldValue()
|
||||
connectNameCandidate.value = null
|
||||
},
|
||||
showLocalChats = showLocalChats,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+98
-16
@@ -6,14 +6,18 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.item.openBrowserAlert
|
||||
import chat.simplex.common.views.chat.subscriberCountStr
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.simplexTeamUri
|
||||
import chat.simplex.res.*
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@@ -29,6 +33,7 @@ suspend fun planAndConnect(
|
||||
cleanup: (() -> Unit)? = null,
|
||||
filterKnownContact: ((Contact) -> Unit)? = null,
|
||||
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
|
||||
showLocalChats: ((List<ChatInfo>) -> Unit)? = null,
|
||||
): CompletableDeferred<Boolean> {
|
||||
when (val target = strConnectTarget(shortOrFullLink.trim())) {
|
||||
is ConnectTarget.Link -> {
|
||||
@@ -51,7 +56,82 @@ suspend fun planAndConnect(
|
||||
inProgress.value = false
|
||||
cleanup?.invoke()
|
||||
}
|
||||
return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, inProgress)
|
||||
return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, showLocalChats, inProgress)
|
||||
}
|
||||
|
||||
private fun namePrice(price: NamePrice): String {
|
||||
val dollars = "$" + (price.amount / 100).toString() + (if (price.amount % 100 == 0L) "" else ".%02d".format(price.amount % 100))
|
||||
return String.format(generalGetString(MR.strings.simplex_name_price_for_years), dollars, price.years)
|
||||
}
|
||||
|
||||
private fun openNameHowTo(uriHandler: UriHandler) = openBrowserAlert("https://simplex.domains/#testing", uriHandler)
|
||||
|
||||
fun showNameWarningAlert(
|
||||
rhId: Long?,
|
||||
domain: SimplexDomain,
|
||||
warning: NameWarning,
|
||||
own: Boolean,
|
||||
openExistingChat: (() -> Unit)?,
|
||||
cleanup: (() -> Unit)?
|
||||
) {
|
||||
val nameStr = domain.fullDomainName
|
||||
fun dismiss() {
|
||||
AlertManager.privacySensitive.hideAlert()
|
||||
cleanup?.invoke()
|
||||
}
|
||||
fun alert(title: String, text: String, action: Pair<String, (UriHandler) -> Unit>? = null) {
|
||||
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
|
||||
title = title,
|
||||
text = text,
|
||||
buttons = {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Column {
|
||||
if (action != null) {
|
||||
SectionItemView({ dismiss(); action.second(uriHandler) }) {
|
||||
Text(action.first, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
if (openExistingChat != null) {
|
||||
SectionItemView({ dismiss(); openExistingChat() }) {
|
||||
Text(generalGetString(MR.strings.connect_plan_open_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionItemView(::dismiss) {
|
||||
Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismissRequest = cleanup,
|
||||
hostDevice = hostDevice(rhId),
|
||||
)
|
||||
}
|
||||
when (warning) {
|
||||
is NameWarning.Expired -> if (!own) alert(
|
||||
generalGetString(MR.strings.simplex_name_expired),
|
||||
if (warning.graceUntil != null) String.format(generalGetString(MR.strings.simplex_name_expired_desc), nameStr, badgeDateText(warning.expiredAt), badgeDateText(warning.graceUntil))
|
||||
else String.format(generalGetString(MR.strings.simplex_name_expired_no_date_desc), nameStr, badgeDateText(warning.expiredAt))
|
||||
) else alert(
|
||||
generalGetString(MR.strings.simplex_name_own_expired),
|
||||
if (warning.graceUntil != null) String.format(generalGetString(MR.strings.simplex_name_own_expired_desc), nameStr, badgeDateText(warning.expiredAt), badgeDateText(warning.graceUntil))
|
||||
else String.format(generalGetString(MR.strings.simplex_name_own_expired_no_date_desc), nameStr, badgeDateText(warning.expiredAt)),
|
||||
generalGetString(MR.strings.simplex_name_renew) to ::openNameHowTo
|
||||
)
|
||||
is NameWarning.Available -> if (!own) alert(
|
||||
generalGetString(if (openExistingChat == null) MR.strings.simplex_name_not_registered else MR.strings.simplex_name_no_longer_registered),
|
||||
String.format(generalGetString(MR.strings.simplex_name_available_desc), nameStr, namePrice(warning.price)),
|
||||
generalGetString(MR.strings.simplex_name_register) to ::openNameHowTo
|
||||
) else alert(
|
||||
generalGetString(MR.strings.simplex_name_own_expired),
|
||||
String.format(generalGetString(MR.strings.simplex_name_own_available_desc), nameStr, namePrice(warning.price)),
|
||||
generalGetString(MR.strings.simplex_name_re_register) to ::openNameHowTo
|
||||
)
|
||||
NameWarning.ReservedForCommunity -> alert(
|
||||
generalGetString(MR.strings.simplex_name_not_registered),
|
||||
String.format(generalGetString(MR.strings.simplex_name_reserved_community_desc), nameStr),
|
||||
generalGetString(MR.strings.simplex_name_connect_simplex_team) to { uh: UriHandler -> uh.openVerifiedSimplexUri(simplexTeamUri) }
|
||||
)
|
||||
NameWarning.NotRegistered -> alert(generalGetString(MR.strings.simplex_name_not_registered), generalGetString(MR.strings.simplex_name_not_found_desc))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun planAndConnectTask(
|
||||
@@ -62,6 +142,7 @@ private suspend fun planAndConnectTask(
|
||||
cleanup: (() -> Unit)? = null,
|
||||
filterKnownContact: ((Contact) -> Unit)? = null,
|
||||
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
|
||||
showLocalChats: ((List<ChatInfo>) -> Unit)? = null,
|
||||
inProgress: MutableState<Boolean>
|
||||
): CompletableDeferred<Boolean> {
|
||||
val completable = CompletableDeferred<Boolean>()
|
||||
@@ -79,6 +160,10 @@ private suspend fun planAndConnectTask(
|
||||
if (!inProgress.value) { return completable }
|
||||
if (result != null) {
|
||||
val (connectionLink, planSimplexName, otherSimplexName, connectionPlan) = result
|
||||
withContext(Dispatchers.Main) {
|
||||
connectionPlan.localChats.forEach { chatModel.chatsContext.updateChat(rhId, it) }
|
||||
showLocalChats?.invoke(connectionPlan.localChats)
|
||||
}
|
||||
val target = strConnectTarget(shortOrFullLink.trim())
|
||||
val linkText = if (target is ConnectTarget.Link) "<br><br><u>${target.linkText}</u>" else ""
|
||||
// the name can also resolve to the other kind; its type picks the verb, its short form the label and target
|
||||
@@ -87,6 +172,13 @@ private suspend fun planAndConnectTask(
|
||||
val label = if (it.nameType == SimplexNameType.publicGroup) MR.strings.connect_plan_join_name else MR.strings.connect_plan_connect_to_name
|
||||
generalGetString(label).format(it.shortStr)
|
||||
}
|
||||
val nameWarning = (connectionPlan.nameChange as? NameChange.Lapsed)?.nameWarning
|
||||
val nameDomain = planSimplexName?.nameDomain
|
||||
if (nameWarning != null && nameDomain != null) {
|
||||
val openExisting: (() -> Unit)? = connectionPlan.planChat?.let { chatInfo -> { withBGApi { close(); openChat(secondaryChatsCtx = null, rhId, chatInfo) } } }
|
||||
showNameWarningAlert(rhId, nameDomain, nameWarning, connectionPlan.isOwnLink, openExisting, cleanup)
|
||||
return completable
|
||||
}
|
||||
when (connectionPlan) {
|
||||
is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) {
|
||||
is InvitationLinkPlan.Ok ->
|
||||
@@ -216,12 +308,6 @@ private suspend fun planAndConnectTask(
|
||||
is ContactAddressPlan.Known -> {
|
||||
Log.d(TAG, "planAndConnect, .ContactAddress, .Known")
|
||||
val contact = connectionPlan.contactAddressPlan.contact
|
||||
// A name-resolved contact is prepared in the store but not yet in the
|
||||
// chat list (link-prepared chats arrive via NewPreparedChat). Surface it
|
||||
// so it's visible and openable; no-op if already present.
|
||||
if (chatModel.getContactChat(contact.contactId) == null) {
|
||||
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
|
||||
}
|
||||
if (filterKnownContact != null) {
|
||||
filterKnownContact(contact)
|
||||
} else {
|
||||
@@ -233,10 +319,7 @@ private suspend fun planAndConnectTask(
|
||||
Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress")
|
||||
val contact = connectionPlan.contactAddressPlan.contact
|
||||
// the contact is already prepared in the store, so open the existing chat instead of sending a new
|
||||
// connection request; surface it in the chat list first if it is not there yet (as for Known above)
|
||||
if (chatModel.getContactChat(contact.contactId) == null) {
|
||||
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
|
||||
}
|
||||
// connection request
|
||||
if (filterKnownContact != null) {
|
||||
filterKnownContact(contact)
|
||||
} else {
|
||||
@@ -322,11 +405,6 @@ private suspend fun planAndConnectTask(
|
||||
is GroupLinkPlan.Known -> {
|
||||
Log.d(TAG, "planAndConnect, .GroupLink, .Known")
|
||||
val groupInfo = connectionPlan.groupLinkPlan.groupInfo
|
||||
// Same as ContactAddress.Known: surface a name-resolved (prepared)
|
||||
// group in the chat list so it's visible and openable.
|
||||
if (chatModel.getGroupChat(groupInfo.groupId) == null) {
|
||||
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(groupInfo, groupChatScope = null), chatItems = emptyList()))
|
||||
}
|
||||
if (filterKnownGroup != null) {
|
||||
filterKnownGroup(groupInfo)
|
||||
} else {
|
||||
@@ -350,6 +428,8 @@ private suspend fun planAndConnectTask(
|
||||
},
|
||||
subtitle = generalGetString(MR.strings.channel_no_active_relays_try_later),
|
||||
confirmText = null,
|
||||
connectOtherButton = connectOtherButton,
|
||||
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } },
|
||||
dismissText = generalGetString(MR.strings.ok),
|
||||
onDismiss = { cleanup() }
|
||||
)
|
||||
@@ -377,6 +457,8 @@ private suspend fun planAndConnectTask(
|
||||
},
|
||||
subtitle = generalGetString(MR.strings.group_link_requires_newer_version),
|
||||
confirmText = null,
|
||||
connectOtherButton = connectOtherButton,
|
||||
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } },
|
||||
dismissText = generalGetString(MR.strings.ok),
|
||||
onDismiss = { cleanup() }
|
||||
)
|
||||
|
||||
@@ -205,6 +205,22 @@
|
||||
<string name="simplex_name_resolver_error_desc">Resolver error: %1$s</string>
|
||||
<string name="simplex_name_no_valid_link">No valid link</string>
|
||||
<string name="simplex_name_no_valid_link_desc">The SimpleX name %1$s is registered, but it has no valid link.</string>
|
||||
<string name="simplex_name_expired">Name expired</string>
|
||||
<string name="simplex_name_expired_desc">%1$s expired on %2$s. Its owner can renew it until %3$s.</string>
|
||||
<string name="simplex_name_expired_no_date_desc">%1$s expired on %2$s.</string>
|
||||
<string name="simplex_name_own_expired">Your name has expired</string>
|
||||
<string name="simplex_name_own_expired_desc">Your name %1$s expired on %2$s. Renew it before %3$s.</string>
|
||||
<string name="simplex_name_own_expired_no_date_desc">Your name %1$s expired on %2$s.</string>
|
||||
<string name="simplex_name_own_available_desc">Your name %1$s is no longer registered. It is available for registration for %2$s.</string>
|
||||
<string name="simplex_name_not_registered">Name not registered</string>
|
||||
<string name="simplex_name_no_longer_registered">Name no longer registered</string>
|
||||
<string name="simplex_name_available_desc">%1$s is available for registration for %2$s.</string>
|
||||
<string name="simplex_name_price_for_years">%1$s for %2$d years</string>
|
||||
<string name="simplex_name_reserved_community_desc">%1$s is reserved for community. Please contact SimpleX Chat if you are interested in using it.</string>
|
||||
<string name="simplex_name_register">Register</string>
|
||||
<string name="simplex_name_re_register">Re-register</string>
|
||||
<string name="simplex_name_renew">Renew</string>
|
||||
<string name="simplex_name_connect_simplex_team">Connect to SimpleX team</string>
|
||||
<string name="simplex_name_unconfirmed">Unconfirmed name</string>
|
||||
<string name="simplex_name_unconfirmed_desc">The SimpleX name %1$s is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner.</string>
|
||||
<string name="channel_temporarily_unavailable">Channel temporarily unavailable</string>
|
||||
|
||||
@@ -58,13 +58,23 @@ suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState
|
||||
|
||||
```kotlin
|
||||
sealed class ConnectionPlan {
|
||||
val localChats: List<ChatInfo>
|
||||
val planChat: ChatInfo?
|
||||
val knownChat: ChatInfo?
|
||||
class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
|
||||
class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan()
|
||||
class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan()
|
||||
class ContactAddress(val contactAddressPlan: ContactAddressPlan, val nameChange: NameChange? = null): ConnectionPlan()
|
||||
class GroupLink(val groupLinkPlan: GroupLinkPlan, val nameChange: NameChange? = null): ConnectionPlan()
|
||||
class Error(val chatError: ChatError): ConnectionPlan()
|
||||
}
|
||||
|
||||
sealed class NameChange {
|
||||
class Lapsed(val nameWarning: NameWarning): NameChange()
|
||||
class Moved(val knownChat: ChatInfo): NameChange()
|
||||
}
|
||||
```
|
||||
|
||||
`planChat` is the plan's contact or group, `knownChat` is the chat of `Moved`, and `localChats` lists both.
|
||||
|
||||
4. For `InvitationLinkPlan`:
|
||||
- `Ok`: Fresh invitation, safe to connect.
|
||||
- `OwnLink`: User's own link, alert shown.
|
||||
@@ -98,17 +108,20 @@ suspend fun planAndConnect(
|
||||
cleanup: (() -> Unit)? = null,
|
||||
filterKnownContact: ((Contact) -> Unit)? = null,
|
||||
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
|
||||
showLocalChats: ((List<ChatInfo>) -> Unit)? = null,
|
||||
): CompletableDeferred<Boolean>
|
||||
```
|
||||
|
||||
1. A progress indicator is shown.
|
||||
2. `apiConnectPlan` is called to analyze the link.
|
||||
3. Based on the plan type, the appropriate UI is shown:
|
||||
3. `localChats` are added to or updated in the chat list, and passed to `showLocalChats`.
|
||||
4. Based on the plan type, the appropriate UI is shown:
|
||||
- For a name warning (`NameChange.Lapsed`): the name warning alert, with Open chat for `planChat`. With nothing local, the warning is the error `SimplexDomainError.NameWarning`, and the same alert is shown by the connect error handler.
|
||||
- For `Ok` plans: proceed to `apiConnect`.
|
||||
- For `Known`: navigate to the existing contact/group.
|
||||
- For `OwnLink`: show alert.
|
||||
- For `Connecting`: show reconnect confirmation or prohibit.
|
||||
4. Returns a `CompletableDeferred<Boolean>` indicating success.
|
||||
5. Returns a `CompletableDeferred<Boolean>` indicating success.
|
||||
|
||||
### 2.3 Execute Connection
|
||||
|
||||
|
||||
Reference in new issue
Block a user