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:
authored and GitHub committed 2026-10-13 17:05:15 +01:00
1 parent 2f4a443dda
commit 7306f3aeef
39 files changed
+2751 -385

No files matched your search

@@ -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,
@@ -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()
@@ -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(
@@ -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,
)
}
}
@@ -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>
+17 -4
View File
@@ -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