mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-16 23:24:58 +00:00
android, desktop, ios: connect via SimpleX name (#7068)
* android, desktop, ios: connect via SimpleX name
* android, desktop, ios: open known contact on name lookup; surface prepared contact
Name search opens the contact (not list-filter); resolved/prepared contacts and groups are added to the chat list so they're visible and openable. Kotlin compile-verified; iOS edits pattern-matched, pending Xcode build.
* feat(names): UI names role + agent NAME error
Parity with the core names rework (#7045):
- Add `names` to ServerRoles (Android + iOS) and a per-operator
"To resolve names" toggle under the SMP section (xftp has no names
role; the shared ServerRoles field stays false there).
- Mirror the new agent error: NameErrorType + a NAME case on both
AgentErrorType and ProtocolErrorType (the SMP ErrorType mirror), so
the new SMP/agent NAME errors decode instead of crashing the decoder.
- Remove ChatErrorType.SimplexNameResolverUnavailable (deleted in core)
and repoint its "name resolution unavailable" alert to the agent
NAME NO_SERVERS error, reusing the existing strings.
Android (multiplatform) compiles clean; iOS mirrors the same changes
(builds in Xcode).
* feat(names): UI warning when no server resolves names
Mirror core USWNoNamesServers: add the NoNamesServers variant to
UserServersWarning (Kotlin sealed class + Swift enum) and its
globalWarning / globalServersWarning branch, rendered by the existing
ServersWarningFooter / ServersWarningView. Matches the noChatRelays
warning exactly.
* fix(servers): show all validation errors and warnings, not just the first
globalServersError/Warning returned only the first entry, so a second
warning (e.g. no names servers behind no chat relays) or a second error
(e.g. no XFTP servers behind no SMP servers) was never displayed. Make
them return all entries (globalServersErrors/Warnings) and render one
footer row each, across the three combined-footer views. Per-protocol
SMP/XFTP footers are unchanged.
* docs(names): add SimpleX name UI plan
* feat(names): add name model fields + SimplexName helpers
* feat(names): verify + set-name API & responses
* docs(names): bump core sync to 5008b4e62
* feat(names): show name + verification on chat info
* feat(names): add Verify SimpleX names privacy toggle
* feat(names): add set-name screens (user + channel)
* update ui
* fix kotlin
* fix codable
* fix ios
* fix errors
* api in UI
* send name as string in protocol
* update simplexmq, capitalize
* verify that name is in profile for own and known contacts and channels as condition of name resolution
* update simplexmq
---------
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
co-authored by
Evgeny Poberezkin
Evgeny @ SimpleX Chat
parent
ec8fe669c7
commit
9bd38c4aec
+44
-4
@@ -2068,7 +2068,9 @@ data class LocalProfile(
|
||||
val contactLink: String? = null,
|
||||
val preferences: ChatPreferences? = null,
|
||||
val peerType: ChatPeerType? = null,
|
||||
val localBadge: LocalBadge? = null
|
||||
val localBadge: LocalBadge? = null,
|
||||
val simplexName: SimplexNameClaim? = null,
|
||||
val contactDomainVerification: Boolean? = null
|
||||
): NamedChat {
|
||||
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
||||
|
||||
@@ -2198,6 +2200,7 @@ data class GroupInfo (
|
||||
val chatTags: List<Long>,
|
||||
val chatItemTTL: Long?,
|
||||
override val localAlias: String,
|
||||
val groupDomainVerification: Boolean? = null,
|
||||
): SomeChat, NamedChat {
|
||||
override val chatType get() = ChatType.Group
|
||||
override val id get() = "#$groupId"
|
||||
@@ -2319,10 +2322,18 @@ object GroupTypeSerializer : KSerializer<GroupType> {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SimplexNameClaim(
|
||||
val name: String,
|
||||
val proof: NameClaimProof? = null
|
||||
) {
|
||||
val shortName: String get() = name.removePrefix("simplex:/name")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PublicGroupAccess(
|
||||
val groupWebPage: String? = null,
|
||||
val groupDomain: String? = null,
|
||||
val simplexName: SimplexNameClaim? = null,
|
||||
val domainWebPage: Boolean = false,
|
||||
val allowEmbedding: Boolean = false
|
||||
)
|
||||
@@ -4875,14 +4886,35 @@ enum class SimplexLinkType(val linkType: String) {
|
||||
data class SimplexNameInfo(
|
||||
val nameType: SimplexNameType,
|
||||
val nameDomain: SimplexNameDomain
|
||||
)
|
||||
) {
|
||||
// prefix-less domain for prefilling the set-name field (shortName without the @/# prefix)
|
||||
val editDomain: String
|
||||
get() = if (nameType == SimplexNameType.publicGroup && nameDomain.nameTLD == SimplexTLD.simplex && nameDomain.subDomain.isEmpty())
|
||||
nameDomain.domain
|
||||
else nameDomain.fullDomainName
|
||||
|
||||
// user-facing display string, mirrors backend shortNameInfoStr
|
||||
val shortName: String
|
||||
get() = (if (nameType == SimplexNameType.publicGroup) "#" else "@") + editDomain
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SimplexNameDomain(
|
||||
val nameTLD: SimplexTLD,
|
||||
val domain: String,
|
||||
val subDomain: List<String>
|
||||
)
|
||||
) {
|
||||
// mirrors backend fullDomainName: reverse(subDomain) + [domain] + tld
|
||||
val fullDomainName: String
|
||||
get() {
|
||||
val tld = when (nameTLD) {
|
||||
SimplexTLD.simplex -> listOf("simplex")
|
||||
SimplexTLD.testing -> listOf("testing")
|
||||
SimplexTLD.web -> emptyList()
|
||||
}
|
||||
return (subDomain.reversed() + domain + tld).joinToString(".")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SimplexTLD {
|
||||
@@ -4897,6 +4929,14 @@ enum class SimplexNameType {
|
||||
@SerialName("contact") contact
|
||||
}
|
||||
|
||||
// peer's signed name claim; UI only checks presence
|
||||
@Serializable
|
||||
data class NameClaimProof(
|
||||
val presHeader: String,
|
||||
val signature: String,
|
||||
val linkOwnerId: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class FormatColor(val color: String) {
|
||||
red("red"),
|
||||
|
||||
+127
-8
@@ -122,6 +122,7 @@ class AppPreferences {
|
||||
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
|
||||
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
|
||||
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
|
||||
val privacyVerifySimplexNames = mkBoolPreference(SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES, true)
|
||||
val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true)
|
||||
val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, false)
|
||||
// TODO remove
|
||||
@@ -397,6 +398,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
|
||||
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
|
||||
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
|
||||
private const val SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES = "PrivacyVerifySimplexNames"
|
||||
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "PrivacyLinkPreviewsShowAlert"
|
||||
private const val SHARED_PREFS_PRIVACY_SANITIZE_LINKS = "PrivacySanitizeLinks"
|
||||
private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" // TODO remove
|
||||
@@ -1555,6 +1557,27 @@ object ChatController {
|
||||
generalGetString(MR.strings.link_requires_newer_app_version_please_upgrade)
|
||||
)
|
||||
}
|
||||
r is API.Error && r.err is ChatError.ChatErrorChat
|
||||
&& r.err.errorType is ChatErrorType.SimplexName -> {
|
||||
if (r.err.errorType.simplexNameError is SimplexNameError.NoValidLink) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.cannot_reconnect_via_simplex_name),
|
||||
generalGetString(MR.strings.simplex_name_unprepared_desc)
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.simplex_name_not_found),
|
||||
generalGetString(MR.strings.simplex_name_not_found_desc)
|
||||
)
|
||||
}
|
||||
}
|
||||
r is API.Error && r.err is ChatError.ChatErrorAgent
|
||||
&& r.err.agentError is AgentErrorType.NO_NAME_SERVERS -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.simplex_name_resolution_unavailable),
|
||||
generalGetString(MR.strings.simplex_name_resolver_unavailable_desc)
|
||||
)
|
||||
}
|
||||
r is API.Error && r.err is ChatError.ChatErrorAgent
|
||||
&& r.err.agentError is AgentErrorType.SMP
|
||||
&& r.err.agentError.smpErr is SMPErrorType.AUTH -> {
|
||||
@@ -1592,6 +1615,12 @@ object ChatController {
|
||||
generalGetString(MR.strings.invalid_connection_link)
|
||||
e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.UnsupportedConnReq ->
|
||||
generalGetString(MR.strings.unsupported_connection_link)
|
||||
e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.SimplexName ->
|
||||
if (e.errorType.simplexNameError is SimplexNameError.NoValidLink)
|
||||
generalGetString(MR.strings.cannot_reconnect_via_simplex_name)
|
||||
else generalGetString(MR.strings.simplex_name_not_found)
|
||||
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.NO_NAME_SERVERS ->
|
||||
generalGetString(MR.strings.simplex_name_resolution_unavailable)
|
||||
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH ->
|
||||
generalGetString(MR.strings.connection_error_auth)
|
||||
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.BLOCKED ->
|
||||
@@ -1762,6 +1791,31 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
// name is the encoded SimplexName (e.g. "@alice.simplex"); null clears it. Throws on rejection.
|
||||
suspend fun apiSetUserName(rh: Long?, name: String?): User {
|
||||
val userId = currentUserId("apiSetUserName")
|
||||
val r = sendCmd(rh, CC.ApiSetUserName(userId, name))
|
||||
return when {
|
||||
r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh)
|
||||
r is API.Result && r.res is CR.UserProfileNoChange -> r.res.user.updateRemoteHostId(rh)
|
||||
else -> throw Exception("failed to set SimpleX name: ${r.responseType} ${r.details}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiVerifyContactName(rh: Long?, contactId: Long): Pair<Contact, String?>? {
|
||||
val r = sendCmd(rh, CC.ApiVerifyContactName(contactId))
|
||||
if (r is API.Result && r.res is CR.ContactNameVerified) return r.res.contact to r.res.verificationFailure
|
||||
Log.e(TAG, "apiVerifyContactName bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiVerifyPublicGroupName(rh: Long?, groupId: Long): Pair<GroupInfo, String?>? {
|
||||
val r = sendCmd(rh, CC.ApiVerifyPublicGroupName(groupId))
|
||||
if (r is API.Result && r.res is CR.GroupNameVerified) return r.res.groupInfo to r.res.verificationFailure
|
||||
Log.e(TAG, "apiVerifyPublicGroupName bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? {
|
||||
val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs))
|
||||
if (r is API.Result && r.res is CR.ContactPrefsUpdated) return r.res.toContact
|
||||
@@ -2289,7 +2343,7 @@ object ChatController {
|
||||
return when {
|
||||
r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup
|
||||
r is API.Error -> {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), r.err.string)
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
@@ -2303,6 +2357,21 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiSetPublicGroupAccess(rh: Long?, groupId: Long, access: PublicGroupAccess): GroupInfo? {
|
||||
val r = sendCmd(rh, CC.ApiSetPublicGroupAccess(groupId, access))
|
||||
return when {
|
||||
r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup
|
||||
r is API.Error -> {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), r.err.string)
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "apiSetPublicGroupAccess bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? {
|
||||
val r = sendCmdWithRetry(rh, CC.APICreateGroupLink(groupId, memberRole))
|
||||
if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.groupLink
|
||||
@@ -3705,6 +3774,7 @@ sealed class CC {
|
||||
class ApiLeaveGroup(val groupId: Long): CC()
|
||||
class ApiListMembers(val groupId: Long): CC()
|
||||
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
|
||||
class ApiSetPublicGroupAccess(val groupId: Long, val access: PublicGroupAccess): CC()
|
||||
class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC()
|
||||
class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC()
|
||||
class APIDeleteGroupLink(val groupId: Long): CC()
|
||||
@@ -3775,6 +3845,9 @@ sealed class CC {
|
||||
class ApiShowMyAddress(val userId: Long): CC()
|
||||
class ApiAddMyAddressShortLink(val userId: Long): CC()
|
||||
class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC()
|
||||
class ApiSetUserName(val userId: Long, val name: String?): CC()
|
||||
class ApiVerifyContactName(val contactId: Long): CC()
|
||||
class ApiVerifyPublicGroupName(val groupId: Long): CC()
|
||||
class ApiSetAddressSettings(val userId: Long, val addressSettings: AddressSettings): CC()
|
||||
class ApiGetCallInvitations: CC()
|
||||
class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC()
|
||||
@@ -3983,6 +4056,10 @@ sealed class CC {
|
||||
is ApiShowMyAddress -> "/_show_address $userId"
|
||||
is ApiAddMyAddressShortLink -> "/_short_link_address $userId"
|
||||
is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}"
|
||||
is ApiSetUserName -> "/_set_name $userId" + (if (name != null) " $name" else "")
|
||||
is ApiSetPublicGroupAccess -> "/_public group access #$groupId ${json.encodeToString(access)}"
|
||||
is ApiVerifyContactName -> "/_verify name @$contactId"
|
||||
is ApiVerifyPublicGroupName -> "/_verify name #$groupId"
|
||||
is ApiSetAddressSettings -> "/_address_settings $userId ${json.encodeToString(addressSettings)}"
|
||||
is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId"
|
||||
is ApiRejectContact -> "/_reject $contactReqId"
|
||||
@@ -4164,6 +4241,10 @@ sealed class CC {
|
||||
is ApiShowMyAddress -> "apiShowMyAddress"
|
||||
is ApiAddMyAddressShortLink -> "apiAddMyAddressShortLink"
|
||||
is ApiSetProfileAddress -> "apiSetProfileAddress"
|
||||
is ApiSetUserName -> "apiSetUserName"
|
||||
is ApiSetPublicGroupAccess -> "apiSetPublicGroupAccess"
|
||||
is ApiVerifyContactName -> "apiVerifyContactName"
|
||||
is ApiVerifyPublicGroupName -> "apiVerifyPublicGroupName"
|
||||
is ApiSetAddressSettings -> "apiSetAddressSettings"
|
||||
is ApiAcceptContact -> "apiAcceptContact"
|
||||
is ApiRejectContact -> "apiRejectContact"
|
||||
@@ -4443,8 +4524,8 @@ data class ServerOperator(
|
||||
serverDomains = listOf("simplex.im"),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false),
|
||||
enabled = true,
|
||||
smpRoles = ServerRoles(storage = true, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true)
|
||||
smpRoles = ServerRoles(storage = true, proxy = true, names = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true, names = false)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4504,7 +4585,8 @@ data class ServerOperator(
|
||||
@Serializable
|
||||
data class ServerRoles(
|
||||
val storage: Boolean,
|
||||
val proxy: Boolean
|
||||
val proxy: Boolean,
|
||||
val names: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -4526,8 +4608,8 @@ data class UserOperatorServers(
|
||||
serverDomains = emptyList(),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false),
|
||||
enabled = false,
|
||||
smpRoles = ServerRoles(storage = true, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true)
|
||||
smpRoles = ServerRoles(storage = true, proxy = true, names = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true, names = false)
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -4613,6 +4695,7 @@ sealed class UserServersError {
|
||||
@Serializable
|
||||
sealed class UserServersWarning {
|
||||
@Serializable @SerialName("noChatRelays") data class NoChatRelays(val user: UserRef? = null): UserServersWarning()
|
||||
@Serializable @SerialName("noNamesServers") data class NoNamesServers(val user: UserRef? = null): UserServersWarning()
|
||||
|
||||
val globalWarning: String?
|
||||
get() = when (this) {
|
||||
@@ -4622,6 +4705,12 @@ sealed class UserServersWarning {
|
||||
String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text
|
||||
} else text
|
||||
}
|
||||
is NoNamesServers -> {
|
||||
val text = generalGetString(MR.strings.no_names_servers_enabled)
|
||||
if (user != null) {
|
||||
String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text
|
||||
} else text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6462,6 +6551,8 @@ sealed class CR {
|
||||
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR()
|
||||
@Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR()
|
||||
@Serializable @SerialName("contactNameVerified") class ContactNameVerified(val user: UserRef, val contact: Contact, val verificationFailure: String? = null): CR()
|
||||
@Serializable @SerialName("groupNameVerified") class GroupNameVerified(val user: UserRef, val groupInfo: GroupInfo, val verificationFailure: String? = null): CR()
|
||||
@Serializable @SerialName("groupLinkDataUpdated") class GroupLinkDataUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>, val relaysChanged: Boolean): CR()
|
||||
@Serializable @SerialName("groupRelayUpdated") class GroupRelayUpdated(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val groupRelay: GroupRelay): CR()
|
||||
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR()
|
||||
@@ -6653,6 +6744,8 @@ sealed class CR {
|
||||
is JoinedGroupMember -> "joinedGroupMember"
|
||||
is ConnectedToGroupMember -> "connectedToGroupMember"
|
||||
is GroupUpdated -> "groupUpdated"
|
||||
is ContactNameVerified -> "contactNameVerified"
|
||||
is GroupNameVerified -> "groupNameVerified"
|
||||
is GroupLinkDataUpdated -> "groupLinkDataUpdated"
|
||||
is GroupRelayUpdated -> "groupRelayUpdated"
|
||||
is GroupLinkCreated -> "groupLinkCreated"
|
||||
@@ -6837,6 +6930,8 @@ sealed class CR {
|
||||
is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
|
||||
is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact")
|
||||
is GroupUpdated -> withUser(user, json.encodeToString(toGroup))
|
||||
is ContactNameVerified -> withUser(user, "contact: ${json.encodeToString(contact)}\nverificationFailure: $verificationFailure")
|
||||
is GroupNameVerified -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nverificationFailure: $verificationFailure")
|
||||
is GroupLinkDataUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays\nrelaysChanged: $relaysChanged")
|
||||
is GroupRelayUpdated -> withUser(user, "groupInfo: $groupInfo\nmember: $member\ngroupRelay: $groupRelay")
|
||||
is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink")
|
||||
@@ -6960,6 +7055,12 @@ sealed class OwnerVerification {
|
||||
@Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class SimplexNameError {
|
||||
@Serializable @SerialName("noValidLink") object NoValidLink : SimplexNameError()
|
||||
@Serializable @SerialName("unknownName") object UnknownName : SimplexNameError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ConnectionPlan {
|
||||
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
|
||||
@@ -6978,7 +7079,7 @@ sealed class InvitationLinkPlan {
|
||||
|
||||
@Serializable
|
||||
sealed class ContactAddressPlan {
|
||||
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): ContactAddressPlan()
|
||||
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedName: SimplexNameInfo? = null): ContactAddressPlan()
|
||||
@Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan()
|
||||
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan()
|
||||
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan()
|
||||
@@ -6988,7 +7089,7 @@ sealed class ContactAddressPlan {
|
||||
|
||||
@Serializable
|
||||
sealed class GroupLinkPlan {
|
||||
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null): GroupLinkPlan()
|
||||
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedName: SimplexNameInfo? = null): GroupLinkPlan()
|
||||
@Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan()
|
||||
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan()
|
||||
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan()
|
||||
@@ -7297,6 +7398,7 @@ sealed class ChatErrorType {
|
||||
is ChatStoreChanged -> "chatStoreChanged"
|
||||
is ConnectionPlanChatError -> "connectionPlan"
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is SimplexName -> "simplexName"
|
||||
is UnsupportedConnReq -> "unsupportedConnReq"
|
||||
is InvalidChatMessage -> "invalidChatMessage"
|
||||
is ConnReqMessageProhibited -> "connReqMessageProhibited"
|
||||
@@ -7379,6 +7481,7 @@ sealed class ChatErrorType {
|
||||
@Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType()
|
||||
@Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType()
|
||||
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("simplexName") class SimplexName(val simplexName: SimplexNameInfo, val simplexNameError: SimplexNameError): ChatErrorType()
|
||||
@Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("connReqMessageProhibited") object ConnReqMessageProhibited: ChatErrorType()
|
||||
@@ -7647,6 +7750,7 @@ sealed class AgentErrorType {
|
||||
is INTERNAL -> "INTERNAL $internalErr"
|
||||
is CRITICAL -> "CRITICAL $offerRestart $criticalErr"
|
||||
is INACTIVE -> "INACTIVE"
|
||||
is NO_NAME_SERVERS -> "NO_NAME_SERVERS"
|
||||
}
|
||||
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType, val errContext: String): AgentErrorType()
|
||||
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType, val errContext: String): AgentErrorType()
|
||||
@@ -7661,6 +7765,19 @@ sealed class AgentErrorType {
|
||||
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
|
||||
@Serializable @SerialName("CRITICAL") data class CRITICAL(val offerRestart: Boolean, val criticalErr: String): AgentErrorType()
|
||||
@Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType()
|
||||
@Serializable @SerialName("NO_NAME_SERVERS") object NO_NAME_SERVERS: AgentErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class NameErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is NO_RESOLVER -> "NO_RESOLVER"
|
||||
is NOT_FOUND -> "NOT_FOUND"
|
||||
is RESOLVER -> "RESOLVER $resolverErr"
|
||||
}
|
||||
@Serializable @SerialName("NO_RESOLVER") object NO_RESOLVER: NameErrorType()
|
||||
@Serializable @SerialName("NOT_FOUND") object NOT_FOUND: NameErrorType()
|
||||
@Serializable @SerialName("RESOLVER") class RESOLVER(val resolverErr: String): NameErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -7730,6 +7847,7 @@ sealed class SMPErrorType {
|
||||
is LARGE_MSG -> "LARGE_MSG"
|
||||
is EXPIRED -> "EXPIRED"
|
||||
is INTERNAL -> "INTERNAL"
|
||||
is NAME -> "NAME ${nameErr.string}"
|
||||
}
|
||||
@Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType()
|
||||
@Serializable @SerialName("SESSION") class SESSION: SMPErrorType()
|
||||
@@ -7744,6 +7862,7 @@ sealed class SMPErrorType {
|
||||
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
|
||||
@Serializable @SerialName("EXPIRED") class EXPIRED: SMPErrorType()
|
||||
@Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType()
|
||||
@Serializable @SerialName("NAME") class NAME(val nameErr: NameErrorType): SMPErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
+15
@@ -757,6 +757,21 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName)
|
||||
)
|
||||
ChatInfoDescription(cInfo, displayName, copyNameToClipboard)
|
||||
val contactDomain = contact.profile.simplexName?.shortName
|
||||
if (contactDomain != null && contact.profile.simplexName?.proof != null) {
|
||||
SimplexNameView(
|
||||
name = contactDomain,
|
||||
verification = contact.profile.contactDomainVerification,
|
||||
autoVerify = chatModel.controller.appPrefs.privacyVerifySimplexNames.get(),
|
||||
verify = {
|
||||
val rhId = chatModel.remoteHostId()
|
||||
chatModel.controller.apiVerifyContactName(rhId, contact.contactId)?.let { (ct, reason) ->
|
||||
chatModel.chatsContext.updateContact(rhId, ct)
|
||||
ct.profile.contactDomainVerification to reason
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.SimplexNameInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
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.
|
||||
// `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(
|
||||
name: String,
|
||||
verification: Boolean?,
|
||||
autoVerify: Boolean,
|
||||
verify: suspend () -> Pair<Boolean?, String?>?
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val inFlight = remember { mutableStateOf(false) }
|
||||
val showSpinner = remember { mutableStateOf(false) }
|
||||
|
||||
fun runVerify(manual: Boolean) {
|
||||
if (inFlight.value) return
|
||||
inFlight.value = true
|
||||
scope.launch {
|
||||
// delay the spinner so a fast result on open doesn't flash it
|
||||
val spinner = launch { delay(300); if (inFlight.value) showSpinner.value = true }
|
||||
val res = try {
|
||||
verify()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "verify SimplexName: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
spinner.cancel()
|
||||
inFlight.value = false
|
||||
showSpinner.value = false
|
||||
if (res != null) {
|
||||
val (newV, reason) = res
|
||||
// show the reason on a manual run, or on an inconclusive auto run (state stayed null)
|
||||
if (reason != null && (manual || newV == null)) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.simplex_name_not_verified), reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (autoVerify && verification == null) runVerify(manual = false)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.body2.copy(
|
||||
color = if (verification == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
fontFamily = if (verification == true) FontFamily.Default else FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
when {
|
||||
showSpinner.value ->
|
||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary)
|
||||
verification == true ->
|
||||
Icon(painterResource(MR.images.ic_check_filled), null, Modifier.size(18.dp), tint = MaterialTheme.colors.onBackground)
|
||||
verification == false ->
|
||||
Icon(
|
||||
painterResource(MR.images.ic_close), null, tint = Color.Red,
|
||||
modifier = Modifier.size(18.dp).clickable { runVerify(manual = true) }
|
||||
)
|
||||
else ->
|
||||
Text(
|
||||
stringResource(MR.strings.verify_simplex_name_action),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable { runVerify(manual = true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -49,7 +49,7 @@ fun ChannelWebPageView(
|
||||
val trimmedPage = webPage.value.trim()
|
||||
val newAccess = PublicGroupAccess(
|
||||
groupWebPage = trimmedPage.ifEmpty { null },
|
||||
groupDomain = access?.groupDomain,
|
||||
simplexName = access?.simplexName,
|
||||
domainWebPage = access?.domainWebPage ?: false,
|
||||
allowEmbedding = allowEmbedding.value
|
||||
)
|
||||
|
||||
+46
@@ -178,6 +178,26 @@ fun ModalData.GroupChatInfoView(
|
||||
manageWebPage = {
|
||||
ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) }
|
||||
},
|
||||
setSimplexName = {
|
||||
ModalManager.end.showCustomModal { close ->
|
||||
SetSimplexNameView(
|
||||
title = generalGetString(MR.strings.set_simplex_name),
|
||||
footer = generalGetString(MR.strings.set_channel_simplex_name_footer),
|
||||
prefix = "#",
|
||||
initial = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.simplexName?.shortName ?: "",
|
||||
save = { name ->
|
||||
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?: PublicGroupAccess()
|
||||
val newAccess = access.copy(simplexName = name?.let { SimplexNameClaim(it) })
|
||||
val gInfo = chatModel.controller.apiSetPublicGroupAccess(rhId, groupInfo.groupId, newAccess)
|
||||
if (gInfo != null) {
|
||||
withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, gInfo) }
|
||||
true
|
||||
} else false
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
},
|
||||
onSearchClicked = onSearchClicked,
|
||||
deletingItems = deletingItems
|
||||
)
|
||||
@@ -510,6 +530,7 @@ fun ModalData.GroupChatInfoLayout(
|
||||
leaveGroup: () -> Unit,
|
||||
manageGroupLink: () -> Unit,
|
||||
manageWebPage: () -> Unit,
|
||||
setSimplexName: () -> Unit,
|
||||
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
|
||||
onSearchClicked: () -> Unit,
|
||||
deletingItems: State<Boolean>
|
||||
@@ -804,6 +825,14 @@ fun ModalData.GroupChatInfoLayout(
|
||||
SectionDividerSpaced()
|
||||
SectionView(title = stringResource(MR.strings.advanced_options)) {
|
||||
ChannelWebPageButton(groupInfo, manageWebPage)
|
||||
if (groupInfo.groupProfile.publicGroup?.publicGroupAccess != null) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_verified_user),
|
||||
stringResource(MR.strings.set_simplex_name),
|
||||
setSimplexName,
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,6 +974,22 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) {
|
||||
modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName)
|
||||
)
|
||||
ChatInfoDescription(cInfo, displayName, copyNameToClipboard)
|
||||
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess
|
||||
val groupName = access?.simplexName?.shortName
|
||||
if (groupName != null && access.simplexName?.proof != null) {
|
||||
SimplexNameView(
|
||||
name = groupName,
|
||||
verification = groupInfo.groupDomainVerification,
|
||||
autoVerify = chatModel.controller.appPrefs.privacyVerifySimplexNames.get(),
|
||||
verify = {
|
||||
val rhId = chatModel.remoteHostId()
|
||||
chatModel.controller.apiVerifyPublicGroupName(rhId, groupInfo.groupId)?.let { (gInfo, reason) ->
|
||||
chatModel.chatsContext.updateGroup(rhId, gInfo)
|
||||
gInfo.groupDomainVerification to reason
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage
|
||||
if (webPage != null) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -1436,6 +1481,7 @@ fun PreviewGroupChatInfoLayout() {
|
||||
manageGroupLink = {},
|
||||
manageWebPage = {},
|
||||
onSearchClicked = {},
|
||||
setSimplexName = {},
|
||||
deletingItems = remember { mutableStateOf(true) }
|
||||
)
|
||||
}
|
||||
|
||||
+4
-7
@@ -338,13 +338,10 @@ fun MarkdownText (
|
||||
withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) }
|
||||
withAnnotation("SIMPLEX_NAME") { a ->
|
||||
val idx = a.item.toIntOrNull()
|
||||
val nameInfo = (idx?.let { formattedText.getOrNull(it) }?.format as? Format.SimplexName)?.nameInfo
|
||||
val (title, msg) = if (nameInfo?.nameType == SimplexNameType.contact) {
|
||||
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
|
||||
} else {
|
||||
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
|
||||
}
|
||||
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
|
||||
val nameText = idx?.let { formattedText.getOrNull(it) }?.text
|
||||
// The name string is routed through the same connect path as a
|
||||
// link; planAndConnect resolves it on the core (name target).
|
||||
if (nameText != null) uriHandler.openVerifiedSimplexUri(nameText)
|
||||
}
|
||||
}
|
||||
if (hasSecrets) {
|
||||
|
||||
+14
-1
@@ -800,7 +800,20 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
|
||||
}
|
||||
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
|
||||
is ConnectTarget.Name -> {
|
||||
// A name lookup means "take me to this contact": open the chat if
|
||||
// it's already known (visible prompt), unlike a pasted link which
|
||||
// filters the list. So no filterKnownContact here.
|
||||
hideKeyboard(view)
|
||||
withBGApi {
|
||||
planAndConnect(
|
||||
chatModel.remoteHostId(),
|
||||
target.text,
|
||||
close = null,
|
||||
cleanup = { searchText.value = TextFieldValue() },
|
||||
)
|
||||
}
|
||||
}
|
||||
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
|
||||
if (it.isNotEmpty()) {
|
||||
focusRequester.requestFocus()
|
||||
|
||||
+14
-6
@@ -31,11 +31,6 @@ suspend fun planAndConnect(
|
||||
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
|
||||
): CompletableDeferred<Boolean> {
|
||||
when (val target = strConnectTarget(shortOrFullLink.trim())) {
|
||||
is ConnectTarget.Name -> {
|
||||
showUnsupportedNameAlert(target.nameInfo)
|
||||
cleanup?.invoke()
|
||||
return CompletableDeferred(false)
|
||||
}
|
||||
is ConnectTarget.Link -> {
|
||||
if (target.linkType == SimplexLinkType.relay) {
|
||||
AlertManager.privacySensitive.showAlertMsg(
|
||||
@@ -46,7 +41,9 @@ suspend fun planAndConnect(
|
||||
return CompletableDeferred(false)
|
||||
}
|
||||
}
|
||||
null -> {}
|
||||
// A SimplexName falls through to apiConnectPlan, which resolves it on the
|
||||
// core (the /_connect plan command accepts a name target, not only a link).
|
||||
is ConnectTarget.Name, null -> {}
|
||||
}
|
||||
connectProgressManager.cancelConnectProgress()
|
||||
val inProgress = mutableStateOf(true)
|
||||
@@ -204,6 +201,12 @@ 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 {
|
||||
@@ -288,6 +291,11 @@ 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 {
|
||||
|
||||
+14
-1
@@ -536,7 +536,20 @@ private fun ContactsSearchBar(
|
||||
cleanup = { searchText.value = TextFieldValue() }
|
||||
)
|
||||
}
|
||||
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
|
||||
is ConnectTarget.Name -> {
|
||||
// A name lookup means "take me to this contact": open the chat if
|
||||
// it's already known (visible prompt), unlike a pasted link which
|
||||
// filters the list. So no filterKnownContact here.
|
||||
hideKeyboard(view)
|
||||
withBGApi {
|
||||
planAndConnect(
|
||||
chatModel.remoteHostId(),
|
||||
target.text,
|
||||
close = close,
|
||||
cleanup = { searchText.value = TextFieldValue() },
|
||||
)
|
||||
}
|
||||
}
|
||||
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
|
||||
if (it.isNotEmpty()) {
|
||||
focusRequester.requestFocus()
|
||||
|
||||
+9
-13
@@ -679,7 +679,11 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState<String>, showQRC
|
||||
showQRCodeScanner.value = false
|
||||
withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } }
|
||||
}
|
||||
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
|
||||
is ConnectTarget.Name -> {
|
||||
pastedLink.value = target.text
|
||||
showQRCodeScanner.value = false
|
||||
withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } }
|
||||
}
|
||||
null -> AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.invalid_contact_link),
|
||||
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
|
||||
@@ -824,7 +828,7 @@ fun strIsSimplexLink(str: String): Boolean {
|
||||
|
||||
sealed class ConnectTarget {
|
||||
class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget()
|
||||
class Name(val nameInfo: SimplexNameInfo) : ConnectTarget()
|
||||
class Name(val text: String, val nameInfo: SimplexNameInfo) : ConnectTarget()
|
||||
}
|
||||
|
||||
fun strConnectTarget(str: String): ConnectTarget? {
|
||||
@@ -835,21 +839,13 @@ fun strConnectTarget(str: String): ConnectTarget? {
|
||||
return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText)
|
||||
}
|
||||
if (links.isEmpty()) {
|
||||
val nameInfo = parsedMd.firstNotNullOfOrNull { (it.format as? Format.SimplexName)?.nameInfo }
|
||||
if (nameInfo != null) return ConnectTarget.Name(nameInfo)
|
||||
val nameFt = parsedMd.firstOrNull { it.format is Format.SimplexName }
|
||||
val nameInfo = (nameFt?.format as? Format.SimplexName)?.nameInfo
|
||||
if (nameFt != null && nameInfo != null) return ConnectTarget.Name(nameFt.text, nameInfo)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun showUnsupportedNameAlert(nameInfo: SimplexNameInfo) {
|
||||
val (title, msg) = if (nameInfo.nameType == SimplexNameType.contact) {
|
||||
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
|
||||
} else {
|
||||
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
|
||||
}
|
||||
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncognitoToggle(
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
|
||||
+5
@@ -134,6 +134,11 @@ fun MorePrivacyView(chatModel: ChatModel) {
|
||||
chatModel.draftChatId.value = null
|
||||
}
|
||||
})
|
||||
SettingsPreferenceItem(
|
||||
painterResource(MR.images.ic_verified_user),
|
||||
stringResource(MR.strings.verify_simplex_names),
|
||||
chatModel.controller.appPrefs.privacyVerifySimplexNames
|
||||
)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
// Set the user's own (prefix "@") or a channel's (prefix "#") SimpleX name.
|
||||
// The field is prefilled with the full prefixed name; `save` receives the encoded name (or null to
|
||||
// clear) and returns true on success (it shows its own error alert otherwise).
|
||||
@Composable
|
||||
fun SetSimplexNameView(
|
||||
title: String,
|
||||
footer: String,
|
||||
prefix: String,
|
||||
initial: String,
|
||||
save: suspend (String?) -> Boolean,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val name = rememberSaveable { mutableStateOf(initial) }
|
||||
val saving = remember { mutableStateOf(false) }
|
||||
val unchanged = name.value.trim() == initial.trim()
|
||||
|
||||
fun normalized(): String? {
|
||||
val s = name.value.trim()
|
||||
return when {
|
||||
s.isEmpty() -> null
|
||||
s.startsWith("@") || s.startsWith("#") -> prefix + s.substring(1)
|
||||
else -> prefix + s
|
||||
}
|
||||
}
|
||||
|
||||
val doSave: () -> Unit = {
|
||||
withBGApi {
|
||||
saving.value = true
|
||||
val ok = try { save(normalized()) } catch (e: Exception) {
|
||||
Log.e(TAG, "SetSimplexNameView save: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), e.message ?: "")
|
||||
false
|
||||
}
|
||||
saving.value = false
|
||||
if (ok) withContext(Dispatchers.Main) { close() }
|
||||
}
|
||||
}
|
||||
|
||||
ModalView(close = close) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(title)
|
||||
SectionView {
|
||||
PlainTextEditor(name, placeholder = prefix + stringResource(MR.strings.simplex_name_placeholder))
|
||||
}
|
||||
SectionTextFooter(footer)
|
||||
SectionDividerSpaced()
|
||||
SectionView {
|
||||
SectionItemView(doSave, disabled = unchanged || saving.value) {
|
||||
Text(
|
||||
stringResource(MR.strings.save_verb),
|
||||
color = if (unchanged || saving.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -34,6 +34,8 @@ import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.common.BuildConfigCommon
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun UserAddressView(
|
||||
@@ -355,6 +357,32 @@ private fun UserAddressLayout(
|
||||
// ShareViaEmailButton { sendEmail(userAddress) }
|
||||
BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) }
|
||||
AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_verified_user),
|
||||
stringResource(MR.strings.set_simplex_name),
|
||||
click = {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
SetSimplexNameView(
|
||||
title = generalGetString(MR.strings.set_simplex_name),
|
||||
footer = generalGetString(MR.strings.set_user_simplex_name_footer),
|
||||
prefix = "@",
|
||||
initial = user?.profile?.simplexName?.shortName ?: "",
|
||||
save = { name ->
|
||||
try {
|
||||
val u = chatModel.controller.apiSetUserName(user?.remoteHostId, name)
|
||||
withContext(Dispatchers.Main) { chatModel.updateUser(u) }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), e.message ?: "")
|
||||
false
|
||||
}
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
},
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
if (addressSettingsState.value.businessAddress) {
|
||||
SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations))
|
||||
|
||||
+12
-23
@@ -276,20 +276,21 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) {
|
||||
) {
|
||||
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
}
|
||||
val serversErr = globalServersError(serverErrors.value)
|
||||
if (serversErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(serversErr)
|
||||
val serversErrs = globalServersErrors(serverErrors.value)
|
||||
if (serversErrs.isNotEmpty()) {
|
||||
serversErrs.forEach { err ->
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(err)
|
||||
}
|
||||
}
|
||||
} else if (serverErrors.value.isNotEmpty()) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration))
|
||||
}
|
||||
}
|
||||
val serversWarn = globalServersWarning(serverWarnings.value)
|
||||
if (serversWarn != null) {
|
||||
globalServersWarnings(serverWarnings.value).forEach { warn ->
|
||||
SectionCustomFooter {
|
||||
ServersWarningFooter(serversWarn)
|
||||
ServersWarningFooter(warn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,23 +952,11 @@ fun serversCanBeSaved(
|
||||
return userServers != currUserServers && serverErrors.isEmpty()
|
||||
}
|
||||
|
||||
fun globalServersError(serverErrors: List<UserServersError>): String? {
|
||||
for (err in serverErrors) {
|
||||
if (err.globalError != null) {
|
||||
return err.globalError
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
fun globalServersErrors(serverErrors: List<UserServersError>): List<String> =
|
||||
serverErrors.mapNotNull { it.globalError }
|
||||
|
||||
fun globalServersWarning(serverWarnings: List<UserServersWarning>): String? {
|
||||
for (warn in serverWarnings) {
|
||||
if (warn.globalWarning != null) {
|
||||
return warn.globalWarning
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
fun globalServersWarnings(serverWarnings: List<UserServersWarning>): List<String> =
|
||||
serverWarnings.mapNotNull { it.globalWarning }
|
||||
|
||||
fun globalSMPServersError(serverErrors: List<UserServersError>): String? {
|
||||
for (err in serverErrors) {
|
||||
|
||||
+35
-11
@@ -211,15 +211,19 @@ fun OperatorViewLayout(
|
||||
rhId = rhId
|
||||
)
|
||||
}
|
||||
val serversErr = globalServersError(serverErrors.value)
|
||||
val serversWarn = globalServersWarning(serverWarnings.value)
|
||||
if (serversErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(serversErr)
|
||||
val serversErrs = globalServersErrors(serverErrors.value)
|
||||
val serversWarns = globalServersWarnings(serverWarnings.value)
|
||||
if (serversErrs.isNotEmpty()) {
|
||||
serversErrs.forEach { err ->
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(err)
|
||||
}
|
||||
}
|
||||
} else if (serversWarn != null) {
|
||||
SectionCustomFooter {
|
||||
ServersWarningFooter(serversWarn)
|
||||
} else if (serversWarns.isNotEmpty()) {
|
||||
serversWarns.forEach { warn ->
|
||||
SectionCustomFooter {
|
||||
ServersWarningFooter(warn)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val footerText = when (val c = operator.conditionsAcceptance) {
|
||||
@@ -267,7 +271,7 @@ fun OperatorViewLayout(
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
operator = this[operatorIndex].operator?.copy(
|
||||
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
|
||||
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false, names = false)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -287,7 +291,27 @@ fun OperatorViewLayout(
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
operator = this[operatorIndex].operator?.copy(
|
||||
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled)
|
||||
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled, names = false)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Text(
|
||||
stringResource(MR.strings.operator_use_for_names),
|
||||
Modifier.padding(end = 24.dp),
|
||||
color = Color.Unspecified
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
DefaultSwitch(
|
||||
checked = userServers.value[operatorIndex].operator_.smpRoles.names,
|
||||
onCheckedChange = { enabled ->
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
operator = this[operatorIndex].operator?.copy(
|
||||
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(names = enabled) ?: ServerRoles(storage = false, proxy = false, names = enabled)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -371,7 +395,7 @@ fun OperatorViewLayout(
|
||||
userServers.value = userServers.value.toMutableList().apply {
|
||||
this[operatorIndex] = this[operatorIndex].copy(
|
||||
operator = this[operatorIndex].operator?.copy(
|
||||
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
|
||||
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false, names = false)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+4
-6
@@ -185,16 +185,14 @@ fun YourServersViewLayout(
|
||||
iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
val serversErr = globalServersError(serverErrors.value)
|
||||
if (serversErr != null) {
|
||||
globalServersErrors(serverErrors.value).forEach { err ->
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(serversErr)
|
||||
ServersErrorFooter(err)
|
||||
}
|
||||
}
|
||||
val serversWarn = globalServersWarning(serverWarnings.value)
|
||||
if (serversWarn != null) {
|
||||
globalServersWarnings(serverWarnings.value).forEach { warn ->
|
||||
SectionCustomFooter {
|
||||
ServersWarningFooter(serversWarn)
|
||||
ServersWarningFooter(warn)
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
<string name="for_chat_profile">For chat profile %s:</string>
|
||||
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
|
||||
<string name="no_chat_relays_enabled">No chat relays enabled.</string>
|
||||
<string name="no_names_servers_enabled">No servers to resolve names.</string>
|
||||
<string name="server_warning">Server warning</string>
|
||||
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
|
||||
<string name="blocking_reason_spam">Spam</string>
|
||||
@@ -199,6 +200,12 @@
|
||||
<string name="channel_name_requires_newer_app_version">Connecting via channel name requires a newer app version.</string>
|
||||
<string name="contact_name_requires_newer_app_version">Connecting via contact name requires a newer app version.</string>
|
||||
<string name="please_upgrade_the_app">Please upgrade the app.</string>
|
||||
<string name="simplex_name_not_found">SimpleX name not found</string>
|
||||
<string name="simplex_name_not_found_desc">There is no contact or group registered with this SimpleX name.</string>
|
||||
<string name="cannot_reconnect_via_simplex_name">Cannot reconnect via name</string>
|
||||
<string name="simplex_name_unprepared_desc">This SimpleX name is known but has no saved link to reconnect via.</string>
|
||||
<string name="simplex_name_resolution_unavailable">Name resolution unavailable</string>
|
||||
<string name="simplex_name_resolver_unavailable_desc">None of your SMP servers support resolving SimpleX names. Add a server that does, or use a connection link.</string>
|
||||
<string name="channel_temporarily_unavailable">Channel temporarily unavailable</string>
|
||||
<string name="channel_no_active_relays_try_later">Channel has no active relays. Please try to join later.</string>
|
||||
<string name="app_update_required">App update required</string>
|
||||
@@ -929,7 +936,15 @@
|
||||
<string name="paste_link">Paste link</string>
|
||||
<string name="one_time_link">One-time invitation link</string>
|
||||
<string name="one_time_link_short">1-time link</string>
|
||||
<string name="simplex_address">SimpleX address</string>
|
||||
<string name="simplex_address">SimpleX address and name</string>
|
||||
<string name="verify_simplex_name_action">Verify name</string>
|
||||
<string name="verify_simplex_names">Verify SimpleX names</string>
|
||||
<string name="simplex_name_not_verified">SimpleX name not verified</string>
|
||||
<string name="set_simplex_name">Set SimpleX name</string>
|
||||
<string name="simplex_name_placeholder">name.simplex</string>
|
||||
<string name="error_saving_simplex_name">Error saving SimpleX name</string>
|
||||
<string name="set_user_simplex_name_footer">Set a SimpleX name so people can connect to you using @yourname instead of a link. The name must already be registered to your address.</string>
|
||||
<string name="set_channel_simplex_name_footer">Set a SimpleX name so people can find this channel as #name. The name must be registered to this channel\'s address.</string>
|
||||
<string name="or_show_this_qr_code">Or show this code</string>
|
||||
<string name="full_link_button_text">Full link</string>
|
||||
<string name="short_link_button_text">Short link</string>
|
||||
@@ -2153,6 +2168,7 @@
|
||||
<string name="operator_use_for_messages">Use for messages</string>
|
||||
<string name="operator_use_for_messages_receiving">To receive</string>
|
||||
<string name="operator_use_for_messages_private_routing">For private routing</string>
|
||||
<string name="operator_use_for_names">To resolve names</string>
|
||||
<string name="operator_added_message_servers">Added message servers</string>
|
||||
<string name="operator_use_for_files">Use for files</string>
|
||||
<string name="operator_use_for_sending">To send</string>
|
||||
|
||||
Reference in New Issue
Block a user