mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-17 10:52:09 +00:00
Merge branch 'master' into master-android
This commit is contained in:
+50
-12
@@ -1660,9 +1660,6 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
if (groupInfo.membership.memberActive) {
|
||||
when (groupChatScope) {
|
||||
null -> {
|
||||
if (allRelaysBroken && groupInfo.useRelays) {
|
||||
return generalGetString(MR.strings.cant_broadcast_message) to null
|
||||
}
|
||||
if (groupInfo.membership.memberPending) {
|
||||
return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc)
|
||||
}
|
||||
@@ -1673,6 +1670,9 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc)
|
||||
}
|
||||
}
|
||||
if (allRelaysBroken && groupInfo.useRelays) {
|
||||
return generalGetString(MR.strings.cant_broadcast_message) to null
|
||||
}
|
||||
return null
|
||||
}
|
||||
is GroupChatScopeInfo.MemberSupport ->
|
||||
@@ -2039,14 +2039,15 @@ data class Profile(
|
||||
val peerType: ChatPeerType? = null,
|
||||
// the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge),
|
||||
// but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof
|
||||
val badge: BadgeProof? = null
|
||||
val badge: BadgeProof? = null,
|
||||
val contactDomain: SimplexDomainClaim? = null
|
||||
): NamedChat {
|
||||
val profileViewName: String
|
||||
get() {
|
||||
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
|
||||
}
|
||||
|
||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType)
|
||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain)
|
||||
|
||||
companion object {
|
||||
val sampleData = Profile(
|
||||
@@ -2068,11 +2069,13 @@ data class LocalProfile(
|
||||
val contactLink: String? = null,
|
||||
val preferences: ChatPreferences? = null,
|
||||
val peerType: ChatPeerType? = null,
|
||||
val localBadge: LocalBadge? = null
|
||||
val localBadge: LocalBadge? = null,
|
||||
val contactDomain: SimplexDomainClaim? = null,
|
||||
val contactDomainVerified: Boolean? = null
|
||||
): NamedChat {
|
||||
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
||||
|
||||
fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType)
|
||||
fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain)
|
||||
|
||||
companion object {
|
||||
val sampleData = LocalProfile(
|
||||
@@ -2198,6 +2201,7 @@ data class GroupInfo (
|
||||
val chatTags: List<Long>,
|
||||
val chatItemTTL: Long?,
|
||||
override val localAlias: String,
|
||||
val groupDomainVerified: Boolean? = null,
|
||||
): SomeChat, NamedChat {
|
||||
override val chatType get() = ChatType.Group
|
||||
override val id get() = "#$groupId"
|
||||
@@ -2319,10 +2323,18 @@ object GroupTypeSerializer : KSerializer<GroupType> {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SimplexDomainClaim(
|
||||
val domain: String,
|
||||
val proof: SimplexDomainProof? = null
|
||||
) {
|
||||
val shortName: String get() = domain.removeSuffix(".simplex")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PublicGroupAccess(
|
||||
val groupWebPage: String? = null,
|
||||
val groupDomain: String? = null,
|
||||
val groupDomainClaim: SimplexDomainClaim? = null,
|
||||
val domainWebPage: Boolean = false,
|
||||
val allowEmbedding: Boolean = false
|
||||
)
|
||||
@@ -4874,15 +4886,33 @@ enum class SimplexLinkType(val linkType: String) {
|
||||
@Serializable
|
||||
data class SimplexNameInfo(
|
||||
val nameType: SimplexNameType,
|
||||
val nameDomain: SimplexNameDomain
|
||||
)
|
||||
val nameDomain: SimplexDomain
|
||||
) {
|
||||
// mirrors backend shortNameInfoStr: "#name" for a simplex public group, else prefix + full domain
|
||||
val shortStr: String get() = when {
|
||||
nameType == SimplexNameType.publicGroup && nameDomain.nameTLD == SimplexTLD.simplex && nameDomain.subDomain.isEmpty() -> "#" + nameDomain.domain
|
||||
else -> (if (nameType == SimplexNameType.publicGroup) "#" else "@") + nameDomain.fullDomainName
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SimplexNameDomain(
|
||||
data class SimplexDomain(
|
||||
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(".")
|
||||
}
|
||||
|
||||
val cmdString: String get() = "domain=$fullDomainName"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SimplexTLD {
|
||||
@@ -4897,6 +4927,14 @@ enum class SimplexNameType {
|
||||
@SerialName("contact") contact
|
||||
}
|
||||
|
||||
// peer's signed name claim; UI only checks presence
|
||||
@Serializable
|
||||
data class SimplexDomainProof(
|
||||
val linkOwnerId: String? = null,
|
||||
val presHeader: String,
|
||||
val signature: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class FormatColor(val color: String) {
|
||||
red("red"),
|
||||
|
||||
+209
-26
@@ -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, false)
|
||||
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
|
||||
@@ -1515,10 +1517,12 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiConnectPlan(rh: Long?, connLink: String, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): Pair<CreatedConnLink, ConnectionPlan>? {
|
||||
suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): ConnectionPlanResult? {
|
||||
val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null }
|
||||
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, linkOwnerSig), inProgress = inProgress)
|
||||
if (r is API.Result && r.res is CR.CRConnectionPlan) return r.res.connLink to r.res.connectionPlan
|
||||
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, resolveMode, linkOwnerSig), inProgress = inProgress)
|
||||
if (r is API.Result && r.res is CR.CRConnectionPlan) return ConnectionPlanResult(r.res.connLink, r.res.planSimplexName, r.res.otherSimplexName, r.res.connectionPlan)
|
||||
// a PRMNever (typing) search that matches nothing locally is not an error to surface
|
||||
if (r is API.Error && r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.NotResolvedLocally) return null
|
||||
if (inProgress.value && r != null) apiConnectResponseAlert(r)
|
||||
return null
|
||||
}
|
||||
@@ -1555,6 +1559,46 @@ 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.SimplexDomainNotReady -> {
|
||||
val domain = r.err.errorType.simplexDomain.fullDomainName
|
||||
if (r.err.errorType.simplexDomainError is SimplexDomainError.NoValidLink) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.simplex_name_no_valid_link),
|
||||
generalGetString(MR.strings.simplex_name_no_valid_link_desc).format(domain)
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.simplex_name_unconfirmed),
|
||||
generalGetString(MR.strings.simplex_name_unconfirmed_desc).format(domain)
|
||||
)
|
||||
}
|
||||
}
|
||||
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_error),
|
||||
generalGetString(MR.strings.simplex_name_no_servers_desc)
|
||||
)
|
||||
}
|
||||
r is API.Error && r.err is ChatError.ChatErrorAgent
|
||||
&& r.err.agentError is AgentErrorType.SMP
|
||||
&& r.err.agentError.smpErr is SMPErrorType.NAME -> {
|
||||
when (val nameErr = r.err.agentError.smpErr.nameErr) {
|
||||
is NameErrorType.NOT_FOUND -> AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.simplex_name_not_found),
|
||||
generalGetString(MR.strings.simplex_name_not_found_desc)
|
||||
)
|
||||
is NameErrorType.NO_RESOLVER -> AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.simplex_name_error),
|
||||
generalGetString(MR.strings.simplex_name_server_no_resolver_desc).format(r.err.agentError.serverAddress)
|
||||
)
|
||||
is NameErrorType.RESOLVER -> AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.simplex_name_error),
|
||||
generalGetString(MR.strings.simplex_name_resolver_error_desc).format(nameErr.resolverErr)
|
||||
)
|
||||
}
|
||||
}
|
||||
r is API.Error && r.err is ChatError.ChatErrorAgent
|
||||
&& r.err.agentError is AgentErrorType.SMP
|
||||
&& r.err.agentError.smpErr is SMPErrorType.AUTH -> {
|
||||
@@ -1587,11 +1631,29 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
// owner-specific wording for setting one's own/channel name; null for other errors (handled by apiConnectResponseAlert)
|
||||
fun simplexNameOwnerError(err: ChatError, isChannel: Boolean): String? =
|
||||
if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.SimplexDomainNotReady && err.errorType.simplexDomainError is SimplexDomainError.NoValidLink) {
|
||||
val domain = err.errorType.simplexDomain.fullDomainName
|
||||
if (isChannel) generalGetString(MR.strings.simplex_name_owner_no_channel_link).format(domain)
|
||||
else generalGetString(MR.strings.simplex_name_owner_no_address).format(domain)
|
||||
} else null
|
||||
|
||||
fun connErrorText(e: ChatError): String = when {
|
||||
e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.InvalidConnReq ->
|
||||
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.SimplexDomainNotReady ->
|
||||
if (e.errorType.simplexDomainError is SimplexDomainError.NoValidLink)
|
||||
generalGetString(MR.strings.simplex_name_no_valid_link)
|
||||
else generalGetString(MR.strings.simplex_name_unconfirmed)
|
||||
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.NO_NAME_SERVERS ->
|
||||
generalGetString(MR.strings.simplex_name_error)
|
||||
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.NAME ->
|
||||
if (e.agentError.smpErr.nameErr is NameErrorType.NOT_FOUND)
|
||||
generalGetString(MR.strings.simplex_name_not_found)
|
||||
else generalGetString(MR.strings.simplex_name_error)
|
||||
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 ->
|
||||
@@ -1604,19 +1666,19 @@ object ChatController {
|
||||
"${generalGetString(MR.strings.error_prefix)}: ${e.string}"
|
||||
}
|
||||
|
||||
suspend fun apiPrepareContact(rh: Long?, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData): Chat? {
|
||||
suspend fun apiPrepareContact(rh: Long?, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain? = null): Chat? {
|
||||
val userId = try { currentUserId("apiPrepareContact") } catch (e: Exception) { return null }
|
||||
val r = sendCmd(rh, CC.APIPrepareContact(userId, connLink, contactShortLinkData))
|
||||
if (r is API.Result && r.res is CR.NewPreparedChat) return r.res.chat
|
||||
val r = sendCmd(rh, CC.APIPrepareContact(userId, connLink, contactShortLinkData, verifiedDomain))
|
||||
if (r is API.Result && r.res is CR.NewPreparedChat) return if (rh == null) r.res.chat else r.res.chat.copy(remoteHostId = rh)
|
||||
Log.e(TAG, "apiPrepareContact bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_contact), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, directLink: Boolean, groupShortLinkData: GroupShortLinkData): Chat? {
|
||||
suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, directLink: Boolean, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain? = null): Chat? {
|
||||
val userId = try { currentUserId("apiPrepareGroup") } catch (e: Exception) { return null }
|
||||
val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, directLink, groupShortLinkData))
|
||||
if (r is API.Result && r.res is CR.NewPreparedChat) return r.res.chat
|
||||
val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, directLink, groupShortLinkData, verifiedDomain))
|
||||
if (r is API.Result && r.res is CR.NewPreparedChat) return if (rh == null) r.res.chat else r.res.chat.copy(remoteHostId = rh)
|
||||
Log.e(TAG, "apiPrepareGroup bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_group), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
@@ -1762,6 +1824,38 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
// name is the encoded SimplexName (e.g. "@alice.simplex"); null clears it. Throws on rejection.
|
||||
suspend fun apiSetUserDomain(rh: Long?, simplexDomain: String?): User {
|
||||
val userId = currentUserId("apiSetUserDomain")
|
||||
val r = sendCmd(rh, CC.ApiSetUserDomain(userId, simplexDomain))
|
||||
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 -> {
|
||||
if (r is API.Error) {
|
||||
val ownerMsg = simplexNameOwnerError(r.err, isChannel = false)
|
||||
if (ownerMsg != null) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), ownerMsg)
|
||||
else apiConnectResponseAlert(r)
|
||||
}
|
||||
throw Exception("failed to set SimpleX name: ${r.responseType} ${r.details}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiVerifyContactDomain(rh: Long?, contactId: Long): Pair<Contact, String?>? {
|
||||
val r = sendCmd(rh, CC.ApiVerifyContactDomain(contactId))
|
||||
if (r is API.Result && r.res is CR.ContactDomainVerified) return r.res.contact to r.res.verificationFailure
|
||||
Log.e(TAG, "apiVerifyContactDomain bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiVerifyGroupDomain(rh: Long?, groupId: Long): Pair<GroupInfo, String?>? {
|
||||
val r = sendCmd(rh, CC.ApiVerifyGroupDomain(groupId))
|
||||
if (r is API.Result && r.res is CR.GroupDomainVerified) return r.res.groupInfo to r.res.verificationFailure
|
||||
Log.e(TAG, "apiVerifyGroupDomain 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
|
||||
@@ -2172,8 +2266,8 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiGetGroupRelays(groupId: Long): List<GroupRelay> {
|
||||
val r = sendCmd(null, CC.ApiGetGroupRelays(groupId))
|
||||
suspend fun apiGetGroupRelays(rh: Long?, groupId: Long): List<GroupRelay> {
|
||||
val r = sendCmd(rh, CC.ApiGetGroupRelays(groupId))
|
||||
if (r is API.Result && r.res is CR.GroupRelays) return r.res.groupRelays
|
||||
return emptyList()
|
||||
}
|
||||
@@ -2183,8 +2277,8 @@ object ChatController {
|
||||
data class AddFailed(val addRelayResults: List<AddRelayResult>): AddGroupRelaysResult()
|
||||
}
|
||||
|
||||
suspend fun apiAddGroupRelays(groupId: Long, relayIds: List<Long>): AddGroupRelaysResult? {
|
||||
val r = sendCmdWithRetry(null, CC.ApiAddGroupRelays(groupId, relayIds))
|
||||
suspend fun apiAddGroupRelays(rh: Long?, groupId: Long, relayIds: List<Long>): AddGroupRelaysResult? {
|
||||
val r = sendCmdWithRetry(rh, CC.ApiAddGroupRelays(groupId, relayIds))
|
||||
if (r is API.Result && r.res is CR.GroupRelaysAdded) return AddGroupRelaysResult.Added(r.res.groupInfo, r.res.groupLink, r.res.groupRelays)
|
||||
if (r is API.Result && r.res is CR.GroupRelaysAddFailed) return AddGroupRelaysResult.AddFailed(r.res.addRelayResults)
|
||||
if (r != null) throw Exception("${r.responseType}: ${r.details}")
|
||||
@@ -2289,7 +2383,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 +2397,23 @@ 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 -> {
|
||||
val ownerMsg = simplexNameOwnerError(r.err, isChannel = true)
|
||||
if (ownerMsg != null) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), ownerMsg)
|
||||
else apiConnectResponseAlert(r)
|
||||
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 +3816,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()
|
||||
@@ -3751,9 +3863,9 @@ sealed class CC {
|
||||
class APIAddContact(val userId: Long, val incognito: Boolean): CC()
|
||||
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
|
||||
class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
|
||||
class APIConnectPlan(val userId: Long, val connLink: String, val linkOwnerSig: LinkOwnerSig? = null): CC()
|
||||
class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData): CC()
|
||||
class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData): CC()
|
||||
class APIConnectPlan(val userId: Long, val connLink: String, val resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, val linkOwnerSig: LinkOwnerSig? = null): CC()
|
||||
class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
|
||||
class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
|
||||
class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC()
|
||||
class APIChangePreparedGroupUser(val groupId: Long, val newUserId: Long): CC()
|
||||
class APIConnectPreparedContact(val contactId: Long, val incognito: Boolean, val msg: MsgContent?): CC()
|
||||
@@ -3775,6 +3887,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 ApiSetUserDomain(val userId: Long, val simplexDomain: String?): CC()
|
||||
class ApiVerifyContactDomain(val contactId: Long): CC()
|
||||
class ApiVerifyGroupDomain(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()
|
||||
@@ -3957,11 +4072,12 @@ sealed class CC {
|
||||
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
|
||||
is ApiChangeConnectionUser -> "/_set conn user :$connId $userId"
|
||||
is APIConnectPlan -> {
|
||||
val resolveStr = if (resolveMode != PlanResolveMode.PRMUnknown) " resolve=${resolveMode.cmdString}" else ""
|
||||
val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else ""
|
||||
"/_connect plan $userId $connLink$sigStr"
|
||||
"/_connect plan $userId $connLink$resolveStr$sigStr"
|
||||
}
|
||||
is APIPrepareContact -> "/_prepare contact $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(contactShortLinkData)}"
|
||||
is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} direct=${onOff(directLink)} ${json.encodeToString(groupShortLinkData)}"
|
||||
is APIPrepareContact -> "/_prepare contact $userId ${connLink.cmdString}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(contactShortLinkData)}"
|
||||
is APIPrepareGroup -> "/_prepare group $userId ${connLink.cmdString} direct=${onOff(directLink)}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(groupShortLinkData)}"
|
||||
is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId"
|
||||
is APIChangePreparedGroupUser -> "/_set group user #$groupId $newUserId"
|
||||
is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)}${maybeContent(msg)}"
|
||||
@@ -3983,6 +4099,10 @@ sealed class CC {
|
||||
is ApiShowMyAddress -> "/_show_address $userId"
|
||||
is ApiAddMyAddressShortLink -> "/_short_link_address $userId"
|
||||
is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}"
|
||||
is ApiSetUserDomain -> "/_set domain $userId" + (if (simplexDomain != null) " $simplexDomain" else "")
|
||||
is ApiSetPublicGroupAccess -> "/_public group access #$groupId ${json.encodeToString(access)}"
|
||||
is ApiVerifyContactDomain -> "/_verify domain @$contactId"
|
||||
is ApiVerifyGroupDomain -> "/_verify domain #$groupId"
|
||||
is ApiSetAddressSettings -> "/_address_settings $userId ${json.encodeToString(addressSettings)}"
|
||||
is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId"
|
||||
is ApiRejectContact -> "/_reject $contactReqId"
|
||||
@@ -4164,6 +4284,10 @@ sealed class CC {
|
||||
is ApiShowMyAddress -> "apiShowMyAddress"
|
||||
is ApiAddMyAddressShortLink -> "apiAddMyAddressShortLink"
|
||||
is ApiSetProfileAddress -> "apiSetProfileAddress"
|
||||
is ApiSetUserDomain -> "apiSetUserDomain"
|
||||
is ApiSetPublicGroupAccess -> "apiSetPublicGroupAccess"
|
||||
is ApiVerifyContactDomain -> "apiVerifyContactDomain"
|
||||
is ApiVerifyGroupDomain -> "apiVerifyGroupDomain"
|
||||
is ApiSetAddressSettings -> "apiSetAddressSettings"
|
||||
is ApiAcceptContact -> "apiAcceptContact"
|
||||
is ApiRejectContact -> "apiRejectContact"
|
||||
@@ -4443,8 +4567,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 +4628,8 @@ data class ServerOperator(
|
||||
@Serializable
|
||||
data class ServerRoles(
|
||||
val storage: Boolean,
|
||||
val proxy: Boolean
|
||||
val proxy: Boolean,
|
||||
val names: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -4526,8 +4651,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 +4738,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 +4748,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6384,7 +6516,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connLinkInvitation: CreatedConnLink, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
|
||||
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val connectionPlan: ConnectionPlan): CR()
|
||||
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val planSimplexName: SimplexNameInfo? = null, val otherSimplexName: SimplexNameInfo? = null, val connectionPlan: ConnectionPlan): CR()
|
||||
@Serializable @SerialName("newPreparedChat") class NewPreparedChat(val user: UserRef, val chat: Chat): CR()
|
||||
@Serializable @SerialName("contactUserChanged") class ContactUserChanged(val user: UserRef, val fromContact: Contact, val newUser: UserRef, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("groupUserChanged") class GroupUserChanged(val user: UserRef, val fromGroup: GroupInfo, val newUser: UserRef, val toGroup: GroupInfo): CR()
|
||||
@@ -6462,6 +6594,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("contactDomainVerified") class ContactDomainVerified(val user: UserRef, val contact: Contact, val verificationFailure: String? = null): CR()
|
||||
@Serializable @SerialName("groupDomainVerified") class GroupDomainVerified(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 +6787,8 @@ sealed class CR {
|
||||
is JoinedGroupMember -> "joinedGroupMember"
|
||||
is ConnectedToGroupMember -> "connectedToGroupMember"
|
||||
is GroupUpdated -> "groupUpdated"
|
||||
is ContactDomainVerified -> "contactDomainVerified"
|
||||
is GroupDomainVerified -> "groupDomainVerified"
|
||||
is GroupLinkDataUpdated -> "groupLinkDataUpdated"
|
||||
is GroupRelayUpdated -> "groupRelayUpdated"
|
||||
is GroupLinkCreated -> "groupLinkCreated"
|
||||
@@ -6837,6 +6973,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 ContactDomainVerified -> withUser(user, "contact: ${json.encodeToString(contact)}\nverificationFailure: $verificationFailure")
|
||||
is GroupDomainVerified -> 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")
|
||||
@@ -6948,6 +7086,8 @@ data class CreatedConnLink(val connFullLink: String, val connShortLink: String?)
|
||||
fun simplexChatUri(short: Boolean): String =
|
||||
if (short) connShortLink ?: simplexChatLink(connFullLink)
|
||||
else simplexChatLink(connFullLink)
|
||||
|
||||
val cmdString: String get() = connFullLink + (if (connShortLink == null) "" else " $connShortLink")
|
||||
}
|
||||
|
||||
fun simplexChatLink(uri: String): String =
|
||||
@@ -6960,6 +7100,29 @@ sealed class OwnerVerification {
|
||||
@Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class SimplexDomainError {
|
||||
@Serializable @SerialName("noValidLink") object NoValidLink : SimplexDomainError()
|
||||
@Serializable @SerialName("unknownDomain") object UnknownDomain : SimplexDomainError()
|
||||
}
|
||||
|
||||
data class ConnectionPlanResult(
|
||||
val connLink: CreatedConnLink,
|
||||
val planSimplexName: SimplexNameInfo?,
|
||||
val otherSimplexName: SimplexNameInfo?,
|
||||
val connectionPlan: ConnectionPlan,
|
||||
)
|
||||
|
||||
// APIConnectPlan resolution scope; PRMNever is local-store-only (no network), used for per-keystroke name search
|
||||
enum class PlanResolveMode {
|
||||
PRMAllGroups, PRMUnknown, PRMNever;
|
||||
val cmdString: String get() = when (this) {
|
||||
PRMAllGroups -> "allGroups"
|
||||
PRMUnknown -> "unknown"
|
||||
PRMNever -> "never"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ConnectionPlan {
|
||||
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
|
||||
@@ -7297,6 +7460,8 @@ sealed class ChatErrorType {
|
||||
is ChatStoreChanged -> "chatStoreChanged"
|
||||
is ConnectionPlanChatError -> "connectionPlan"
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is SimplexDomainNotReady -> "simplexDomainNotReady"
|
||||
is NotResolvedLocally -> "notResolvedLocally"
|
||||
is UnsupportedConnReq -> "unsupportedConnReq"
|
||||
is InvalidChatMessage -> "invalidChatMessage"
|
||||
is ConnReqMessageProhibited -> "connReqMessageProhibited"
|
||||
@@ -7379,6 +7544,8 @@ 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("simplexDomainNotReady") class SimplexDomainNotReady(val simplexDomain: SimplexDomain, val simplexDomainError: SimplexDomainError): ChatErrorType()
|
||||
@Serializable @SerialName("notResolvedLocally") object NotResolvedLocally: 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 +7814,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 +7829,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 +7911,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 +7926,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
|
||||
|
||||
+14
@@ -757,6 +757,20 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName)
|
||||
)
|
||||
ChatInfoDescription(cInfo, displayName, copyNameToClipboard)
|
||||
val domain = contact.profile.contactDomain
|
||||
if (domain != null && (contact.profile.contactDomainVerified != null || domain.proof != null)) {
|
||||
SimplexNameView(
|
||||
simplexName = "@${domain.shortName}",
|
||||
verified = contact.profile.contactDomainVerified,
|
||||
verify = {
|
||||
val rhId = chatModel.remoteHostId()
|
||||
chatModel.controller.apiVerifyContactDomain(rhId, contact.contactId)?.let { (ct, reason) ->
|
||||
chatModel.chatsContext.updateContact(rhId, ct)
|
||||
ct.profile.contactDomainVerified to reason
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -208,7 +208,7 @@ fun ChatView(
|
||||
withBGApi {
|
||||
setGroupMembers(chatRh, cInfo.groupInfo, chatModel)
|
||||
if (cInfo.groupInfo.membership.memberRole == GroupMemberRole.Owner) {
|
||||
val relays = chatModel.controller.apiGetGroupRelays(cInfo.groupInfo.groupId)
|
||||
val relays = chatModel.controller.apiGetGroupRelays(chatRh, cInfo.groupInfo.groupId)
|
||||
withContext(Dispatchers.Main) {
|
||||
ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays)
|
||||
}
|
||||
@@ -2084,7 +2084,7 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.padding(start = if (chatInfo.isChannel) 12.dp else 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(swipeableOrSelectionModifier)
|
||||
) {
|
||||
@@ -2167,7 +2167,7 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.padding(start = if (chatInfo.isChannel) 12.dp else 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(swipeableOrSelectionModifier)
|
||||
) {
|
||||
|
||||
+35
-13
@@ -1203,8 +1203,9 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
val ownerRelayState = ownerRelayState(chat, chatModel)
|
||||
val subscriberRelayState = subscriberRelayState(chat, chatModel)
|
||||
|
||||
val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason(ownerRelayState?.noActiveRelays == true))
|
||||
val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason((ownerRelayState?.noActiveRelays ?: subscriberRelayState?.noActiveRelays) == true))
|
||||
val sendMsgEnabled = rememberUpdatedState(userCantSendReason.value == null)
|
||||
val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv)
|
||||
|
||||
@@ -1575,18 +1576,12 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted()
|
||||
val relayMembers = chatModel.groupMembers.value
|
||||
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus !in listOf(GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) }
|
||||
.sortedBy { hostFromRelayLink(it.relayLink ?: "") }
|
||||
val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress
|
||||
val removedCount = relayMembers.count { relayMemberRemoved(it.memberStatus) }
|
||||
val connectedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connStatus == ConnStatus.Ready && it.activeConn?.connFailedErr == null }
|
||||
val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null }
|
||||
val resolvedCount = connectedCount + removedCount + failedCount
|
||||
val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size
|
||||
if (total == 0 || removedCount + failedCount > 0 || resolvedCount < total) {
|
||||
SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, showProgress, relayListExpanded)
|
||||
subscriberRelayState?.let { s ->
|
||||
val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress
|
||||
val resolvedCount = s.connectedCount + s.removedCount + s.failedCount
|
||||
if (s.total == 0 || s.removedCount + s.failedCount > 0 || resolvedCount < s.total) {
|
||||
SubscriberChannelRelayBar(s.hostnames, s.relayMembers, s.connectedCount, s.removedCount, s.failedCount, s.total, showProgress, relayListExpanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2052,6 +2047,33 @@ private data class OwnerRelayState(
|
||||
val noActiveRelays: Boolean
|
||||
)
|
||||
|
||||
private fun subscriberRelayState(chat: Chat, chatModel: ChatModel): SubscriberRelayState? {
|
||||
val gInfo = (chat.chatInfo as? ChatInfo.Group)?.groupInfo ?: return null
|
||||
if (!gInfo.useRelays || gInfo.membership.memberRole == GroupMemberRole.Owner ||
|
||||
gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemRejected, GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
|
||||
) return null
|
||||
val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted()
|
||||
val relayMembers = chatModel.groupMembers.value
|
||||
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus !in listOf(GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) }
|
||||
.sortedBy { hostFromRelayLink(it.relayLink ?: "") }
|
||||
val removedCount = relayMembers.count { relayMemberRemoved(it.memberStatus) }
|
||||
val connectedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connStatus == ConnStatus.Ready && it.activeConn?.connFailedErr == null }
|
||||
val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null }
|
||||
val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size
|
||||
val noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total
|
||||
return SubscriberRelayState(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays)
|
||||
}
|
||||
|
||||
private data class SubscriberRelayState(
|
||||
val hostnames: List<String>,
|
||||
val relayMembers: List<GroupMember>,
|
||||
val connectedCount: Int,
|
||||
val removedCount: Int,
|
||||
val failedCount: Int,
|
||||
val total: Int,
|
||||
val noActiveRelays: Boolean
|
||||
)
|
||||
|
||||
private fun relayMemberRemoved(status: GroupMemberStatus?): Boolean =
|
||||
status in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
|
||||
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
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(
|
||||
simplexName: String,
|
||||
verified: 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 (chatModel.controller.appPrefs.privacyVerifySimplexNames.get() && verified == null) runVerify(manual = false)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
Text(
|
||||
simplexName,
|
||||
style = MaterialTheme.typography.body2.copy(
|
||||
color = if (verified == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
fontFamily = if (verified == true) FontFamily.Default else FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
when {
|
||||
showSpinner.value ->
|
||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary)
|
||||
verified == true ->
|
||||
Icon(painterResource(MR.images.ic_check_filled), null, Modifier.size(18.dp), tint = MaterialTheme.colors.onBackground)
|
||||
verified == 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -31,6 +31,7 @@ data class AvailableRelay(
|
||||
|
||||
@Composable
|
||||
fun AddGroupRelayView(
|
||||
rhId: Long?,
|
||||
groupInfo: GroupInfo,
|
||||
existingRelayIds: Set<Long>,
|
||||
onRelayAdded: () -> Unit,
|
||||
@@ -46,7 +47,7 @@ fun AddGroupRelayView(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
val servers = ChatController.getUserServers(null)
|
||||
val servers = ChatController.getUserServers(rhId)
|
||||
if (servers != null) {
|
||||
val relays = mutableListOf<AvailableRelay>()
|
||||
for (op in servers) {
|
||||
@@ -80,7 +81,7 @@ fun AddGroupRelayView(
|
||||
if (relayIds.isEmpty()) return@AddGroupRelayLayout
|
||||
isAdding = true
|
||||
scope.launch {
|
||||
addSelectedRelays(groupInfo, relayIds, selectedRelayIds, availableRelays, onRelayAdded, close) { newSelectedIds, newAvailableRelays ->
|
||||
addSelectedRelays(rhId, groupInfo, relayIds, selectedRelayIds, availableRelays, onRelayAdded, close) { newSelectedIds, newAvailableRelays ->
|
||||
selectedRelayIds = newSelectedIds
|
||||
availableRelays = newAvailableRelays
|
||||
isAdding = false
|
||||
@@ -183,6 +184,7 @@ private fun AddRelaysButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
}
|
||||
|
||||
private suspend fun addSelectedRelays(
|
||||
rhId: Long?,
|
||||
groupInfo: GroupInfo,
|
||||
relayIds: List<Long>,
|
||||
selectedRelayIds: Set<Long>,
|
||||
@@ -192,7 +194,7 @@ private suspend fun addSelectedRelays(
|
||||
updateState: (Set<Long>, List<AvailableRelay>) -> Unit
|
||||
) {
|
||||
try {
|
||||
val result = ChatController.apiAddGroupRelays(groupInfo.groupId, relayIds)
|
||||
val result = ChatController.apiAddGroupRelays(rhId, groupInfo.groupId, relayIds)
|
||||
if (result == null) {
|
||||
updateState(selectedRelayIds, availableRelays)
|
||||
return
|
||||
|
||||
+2
-1
@@ -37,7 +37,7 @@ fun ChannelRelaysView(
|
||||
LaunchedEffect(Unit) {
|
||||
setGroupMembers(rhId, groupInfo, chatModel)
|
||||
if (groupInfo.isOwner) {
|
||||
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
|
||||
val relays = chatModel.controller.apiGetGroupRelays(rhId, groupInfo.groupId)
|
||||
ChannelRelaysModel.set(groupId = groupInfo.groupId, groupRelays = relays)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ private fun ChannelRelaysLayout(
|
||||
val existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet()
|
||||
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
|
||||
AddGroupRelayView(
|
||||
rhId = rhId,
|
||||
groupInfo = groupInfo,
|
||||
existingRelayIds = existingRelayIds,
|
||||
onRelayAdded = { withBGApi { setGroupMembers(rhId, groupInfo, chatModel) } },
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ fun ChannelWebPageView(
|
||||
val trimmedPage = webPage.value.trim()
|
||||
val newAccess = PublicGroupAccess(
|
||||
groupWebPage = trimmedPage.ifEmpty { null },
|
||||
groupDomain = access?.groupDomain,
|
||||
groupDomainClaim = access?.groupDomainClaim,
|
||||
domainWebPage = access?.domainWebPage ?: false,
|
||||
allowEmbedding = allowEmbedding.value
|
||||
)
|
||||
@@ -81,7 +81,7 @@ fun ChannelWebPageView(
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
|
||||
val relays = chatModel.controller.apiGetGroupRelays(rhId, groupInfo.groupId)
|
||||
groupRelays.clear()
|
||||
groupRelays.addAll(relays)
|
||||
}
|
||||
|
||||
+44
@@ -178,6 +178,27 @@ fun ModalData.GroupChatInfoView(
|
||||
manageWebPage = {
|
||||
ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) }
|
||||
},
|
||||
setSimplexName = {
|
||||
ModalManager.end.showCustomModal { close ->
|
||||
val domain = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName
|
||||
SetSimplexDomainView(
|
||||
title = generalGetString(MR.strings.set_simplex_name),
|
||||
footer = generalGetString(MR.strings.set_channel_simplex_name_footer),
|
||||
placeholder = "#channelname.testing",
|
||||
simplexName = if (domain == null) "" else "#$domain",
|
||||
save = { domain ->
|
||||
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?: PublicGroupAccess()
|
||||
val newAccess = access.copy(groupDomainClaim = domain?.let { SimplexDomainClaim(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 +531,7 @@ fun ModalData.GroupChatInfoLayout(
|
||||
leaveGroup: () -> Unit,
|
||||
manageGroupLink: () -> Unit,
|
||||
manageWebPage: () -> Unit,
|
||||
setSimplexName: () -> Unit,
|
||||
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
|
||||
onSearchClicked: () -> Unit,
|
||||
deletingItems: State<Boolean>
|
||||
@@ -616,6 +638,12 @@ fun ModalData.GroupChatInfoLayout(
|
||||
if (groupInfo.isOwner && groupLink != null) {
|
||||
anyTopSectionRowShow = true
|
||||
ChannelLinkButton(manageGroupLink)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_tag),
|
||||
stringResource(MR.strings.simplex_name),
|
||||
setSimplexName,
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
} else if (channelLink != null) {
|
||||
anyTopSectionRowShow = true
|
||||
ChannelLinkQRCodeSection(channelLink)
|
||||
@@ -945,6 +973,21 @@ 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 domain = access?.groupDomainClaim?.shortName
|
||||
if (domain != null && (groupInfo.groupDomainVerified != null || access.groupDomainClaim?.proof != null)) {
|
||||
SimplexNameView(
|
||||
simplexName = "#${domain}",
|
||||
verified = groupInfo.groupDomainVerified,
|
||||
verify = {
|
||||
val rhId = chatModel.remoteHostId()
|
||||
chatModel.controller.apiVerifyGroupDomain(rhId, groupInfo.groupId)?.let { (gInfo, reason) ->
|
||||
chatModel.chatsContext.updateGroup(rhId, gInfo)
|
||||
gInfo.groupDomainVerified to reason
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage
|
||||
if (webPage != null) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -1436,6 +1479,7 @@ fun PreviewGroupChatInfoLayout() {
|
||||
manageGroupLink = {},
|
||||
manageWebPage = {},
|
||||
onSearchClicked = {},
|
||||
setSimplexName = {},
|
||||
deletingItems = remember { mutableStateOf(true) }
|
||||
)
|
||||
}
|
||||
|
||||
+18
-7
@@ -15,7 +15,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -30,7 +32,10 @@ import java.net.URI
|
||||
@Composable
|
||||
fun CIFileView(
|
||||
file: CIFile?,
|
||||
edited: Boolean,
|
||||
meta: CIMeta,
|
||||
chatTTL: Int?,
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
showMenu: MutableState<Boolean>,
|
||||
smallView: Boolean = false,
|
||||
senderProfile: LocalProfile?,
|
||||
@@ -202,10 +207,13 @@ fun CIFileView(
|
||||
) {
|
||||
fileIndicator()
|
||||
if (!smallView) {
|
||||
val metaReserve = if (edited)
|
||||
" "
|
||||
else
|
||||
" "
|
||||
val secondaryColor = MaterialTheme.colors.secondary
|
||||
val encrypted = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null
|
||||
val metaReserve = buildAnnotatedString {
|
||||
withStyle(reserveTimestampStyle) {
|
||||
append(reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor = secondaryColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp))
|
||||
}
|
||||
}
|
||||
if (file != null) {
|
||||
Column {
|
||||
Text(
|
||||
@@ -213,8 +221,11 @@ fun CIFileView(
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
formatBytes(file.fileSize) + metaReserve,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
buildAnnotatedString {
|
||||
append(formatBytes(file.fileSize))
|
||||
append(metaReserve)
|
||||
},
|
||||
color = secondaryColor,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ fun CIImageView(
|
||||
.then(
|
||||
if (!smallView) {
|
||||
val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH
|
||||
Modifier.width(w).aspectRatio((previewBitmap.width.toFloat() / previewBitmap.height.toFloat()).coerceIn(1f / 2.33f, 2.33f))
|
||||
Modifier.width(w).height(w * (previewBitmap.height.toFloat() / previewBitmap.width.toFloat()).coerceAtMost(2.33f))
|
||||
} else Modifier
|
||||
)
|
||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ fun FramedItemView(
|
||||
|
||||
@Composable
|
||||
fun ciFileView(ci: ChatItem, text: String) {
|
||||
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
|
||||
CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
|
||||
if (text != "" || ci.meta.isLive) {
|
||||
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
+156
-25
@@ -49,6 +49,7 @@ import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -745,7 +746,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
|
||||
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
|
||||
Box {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -763,6 +764,8 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
|
||||
searchText = searchText,
|
||||
enabled = !remember { searchShowingSimplexLink }.value,
|
||||
trailingContent = null,
|
||||
// the clear button must line up with the filter icon it replaces, so no reduction here
|
||||
reducedCloseButtonPadding = 0.dp,
|
||||
) {
|
||||
searchText.value = searchText.value.copy(it)
|
||||
}
|
||||
@@ -791,17 +794,35 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { searchText.value.text }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
when (val target = strConnectTarget(it.trim())) {
|
||||
is ConnectTarget.Link -> {
|
||||
hideKeyboard(view)
|
||||
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
|
||||
searchShowingSimplexLink.value = true
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
|
||||
}
|
||||
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
|
||||
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
|
||||
.collectLatest {
|
||||
val target = strConnectTarget(it.trim())
|
||||
if (target is ConnectTarget.Link) {
|
||||
hideKeyboard(view)
|
||||
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
|
||||
searchShowingSimplexLink.value = true
|
||||
searchChatFilteredBySimplexLink.value = emptySet()
|
||||
connectNameCandidate.value = null
|
||||
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
|
||||
} else {
|
||||
val candidate = nameSearchCandidate(it.trim())
|
||||
connectNameCandidate.value = candidate
|
||||
// clear the previous match immediately so the list falls back to text search during the debounce,
|
||||
// instead of showing a stale filtered chat while the new search runs
|
||||
searchChatFilteredBySimplexLink.value = emptySet()
|
||||
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.
|
||||
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))
|
||||
}
|
||||
searchChatFilteredBySimplexLink.value = ids.toSet()
|
||||
if (ids.size == targets.size) connectNameCandidate.value = null
|
||||
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
|
||||
if (it.isNotEmpty()) {
|
||||
focusRequester.requestFocus()
|
||||
} else {
|
||||
@@ -813,7 +834,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
|
||||
}
|
||||
}
|
||||
searchShowingSimplexLink.value = false
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
searchChatFilteredBySimplexLink.value = emptySet()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,13 +845,13 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<String?>, cleanup: (() -> Unit)?) {
|
||||
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<Set<String>>, cleanup: (() -> Unit)?) {
|
||||
withBGApi {
|
||||
planAndConnect(
|
||||
chatModel.remoteHostId(),
|
||||
link,
|
||||
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
|
||||
filterKnownGroup = { searchChatFilteredBySimplexLink.value = it.id },
|
||||
filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
|
||||
filterKnownGroup = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
|
||||
close = null,
|
||||
cleanup = cleanup,
|
||||
)
|
||||
@@ -917,7 +938,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
// which is related to [derivedStateOf]. Using safe alternative instead
|
||||
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
|
||||
val searchShowingSimplexLink = remember { mutableStateOf(false) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
|
||||
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
|
||||
@@ -950,13 +972,23 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
if (oneHandUI.value) {
|
||||
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
|
||||
Divider()
|
||||
TagsView(searchText)
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
// bottom toolbar: search bar below, so on desktop the connect row goes below the tags
|
||||
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
|
||||
TagsView(searchText)
|
||||
Divider()
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
|
||||
}
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
|
||||
}
|
||||
} else {
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
TagsView(searchText)
|
||||
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)
|
||||
Divider()
|
||||
TagsView(searchText)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
@@ -1004,6 +1036,105 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
}
|
||||
}
|
||||
|
||||
// Default top-level part used to complete a bare name typed in the search field (search field only;
|
||||
// the message parser and the wire format are unchanged).
|
||||
private const val DEFAULT_NAME_TLD = "testing"
|
||||
// Shortest name that offers the button, so it is discoverable but does not flash on a single letter.
|
||||
private const val MIN_NAME_LENGTH = 2
|
||||
// Wait this long after the last keystroke before the local name search runs.
|
||||
internal const val NAME_SEARCH_DEBOUNCE_MS = 300L
|
||||
|
||||
private val nameLabelRegex = Regex("[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*")
|
||||
private fun isNameLabel(s: String): Boolean = s.length in 1..63 && nameLabelRegex.matches(s)
|
||||
|
||||
// On-device candidate for connecting by SimpleX name: the string sent to the core to resolve it.
|
||||
// Mirrors the domain grammar (nameLabelP/mkDomain in SimplexName.hs): an optional @/# prefix, then
|
||||
// dot-separated ASCII labels; a dotless word is completed with the default top-level part. Returns
|
||||
// the string to send (keeping @/# so the type is preserved), or null when the text is not a name.
|
||||
internal fun nameSearchCandidate(str: String): String? {
|
||||
val text = str.trim()
|
||||
val prefix = text.firstOrNull()?.takeIf { it == '@' || it == '#' }
|
||||
val core = if (prefix != null) text.substring(1) else text
|
||||
val labels = core.split(".")
|
||||
if (core.isEmpty() || labels.any { !isNameLabel(it) }) return null
|
||||
return when {
|
||||
labels.size > 1 -> text // already has a top-level part
|
||||
core.length >= MIN_NAME_LENGTH -> "${prefix ?: ""}$core.$DEFAULT_NAME_TLD"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@Composable
|
||||
private fun TagsOrConnectByName(
|
||||
searchText: MutableState<TextFieldValue>,
|
||||
connectNameCandidate: MutableState<String?>,
|
||||
desktopView: @Composable (candidate: String) -> Unit,
|
||||
) {
|
||||
val candidate = connectNameCandidate.value
|
||||
when {
|
||||
candidate == null -> TagsView(searchText)
|
||||
!appPlatform.isDesktop -> ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
|
||||
else -> desktopView(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ConnectByNameRow(name: String, searchText: MutableState<TextFieldValue>, connectNameCandidate: MutableState<String?>, close: (() -> Unit)?) {
|
||||
val view = LocalMultiplatformView()
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
hideKeyboard(view)
|
||||
withBGApi {
|
||||
planAndConnect(
|
||||
chatModel.remoteHostId(),
|
||||
name,
|
||||
close = close,
|
||||
cleanup = {
|
||||
searchText.value = TextFieldValue()
|
||||
connectNameCandidate.value = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(vertical = DEFAULT_PADDING_HALF),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// icon and text aligned with the search bar's icon and text (same paddings and icon size)
|
||||
val icon = if (name.startsWith("@")) MR.images.ic_at else MR.images.ic_tag
|
||||
Icon(painterResource(icon), null, Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.primary)
|
||||
Text(String.format(generalGetString(MR.strings.connect_plan_connect_to_name), name), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoChatsView(searchText: MutableState<TextFieldValue>) {
|
||||
val activeFilter = remember { chatModel.activeChatTagFilter }.value
|
||||
@@ -1311,14 +1442,14 @@ fun ItemPresetFilterAction(
|
||||
|
||||
fun filteredChats(
|
||||
searchShowingSimplexLink: State<Boolean>,
|
||||
searchChatFilteredBySimplexLink: State<String?>,
|
||||
searchChatFilteredBySimplexLink: State<Set<String>>,
|
||||
searchText: String,
|
||||
chats: List<Chat>,
|
||||
activeFilter: ActiveFilter? = null,
|
||||
): List<Chat> {
|
||||
val linkChatId = searchChatFilteredBySimplexLink.value
|
||||
return if (linkChatId != null) {
|
||||
chats.filter { it.id == linkChatId }
|
||||
val linkChatIds = searchChatFilteredBySimplexLink.value
|
||||
return if (linkChatIds.isNotEmpty()) {
|
||||
chats.filter { it.id in linkChatIds }
|
||||
} else {
|
||||
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
|
||||
if (s.isEmpty())
|
||||
|
||||
+1
-1
@@ -342,7 +342,7 @@ fun ChatPreviewView(
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> SmallContentPreviewFile {
|
||||
CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
|
||||
CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
|
||||
val user = chatModel.currentUser.value ?: return@CIFileView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ private fun ShareList(
|
||||
val chats by remember(search) {
|
||||
derivedStateOf {
|
||||
val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local }
|
||||
filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted)
|
||||
filteredChats(mutableStateOf(false), mutableStateOf<Set<String>>(emptySet()), search, sorted)
|
||||
}
|
||||
}
|
||||
val topPaddingToContent = topPaddingToContent(false)
|
||||
|
||||
+22
@@ -291,10 +291,13 @@ class AlertManager {
|
||||
profileFullName: String,
|
||||
profileImage: @Composable () -> Unit,
|
||||
profileBadge: LocalBadge? = null,
|
||||
nameCaption: String? = null,
|
||||
subtitle: String? = null,
|
||||
information: String? = null,
|
||||
confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
connectOtherButton: String? = null,
|
||||
onConnectOther: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(MR.strings.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
) {
|
||||
@@ -337,6 +340,17 @@ class AlertManager {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (nameCaption != null) {
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
Text(
|
||||
nameCaption,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
if (profileFullName.isNotEmpty() && profileFullName != profileName) {
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
Text(
|
||||
@@ -388,6 +402,14 @@ class AlertManager {
|
||||
Text(confirmText)
|
||||
}
|
||||
}
|
||||
if (connectOtherButton != null && onConnectOther != null) {
|
||||
TextButton(onClick = {
|
||||
onConnectOther.invoke()
|
||||
hideAlert()
|
||||
}) {
|
||||
Text(connectOtherButton)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ fun SearchTextField(
|
||||
placeholder: String = stringResource(MR.strings.search_verb),
|
||||
enabled: Boolean = true,
|
||||
trailingContent: @Composable (() -> Unit)? = null,
|
||||
reducedCloseButtonPadding: Dp = 0.dp,
|
||||
reducedCloseButtonPadding: Dp = 8.dp,
|
||||
onValueChange: (String) -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -116,7 +116,7 @@ fun SearchTextField(
|
||||
trailingIcon = if (searchText.value.text.isNotEmpty() || trailingContent != null) {{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.offset(x = 8.dp)
|
||||
modifier = Modifier.offset(x = reducedCloseButtonPadding)
|
||||
) {
|
||||
if (searchText.value.text.isNotEmpty()) {
|
||||
IconButton({
|
||||
|
||||
+7
-3
@@ -1,4 +1,5 @@
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -27,7 +28,7 @@ import chat.simplex.common.views.onboarding.SelectableCard
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
|
||||
import chat.simplex.res.MR
|
||||
|
||||
private val SectionCardShape = RoundedCornerShape(16.dp)
|
||||
val SectionCardShape = RoundedCornerShape(16.dp)
|
||||
val CARD_PADDING = 18.dp
|
||||
val ICON_TEXT_SPACING = 8.dp
|
||||
|
||||
@@ -113,15 +114,18 @@ fun SectionView(
|
||||
iconTint: Color = MaterialTheme.colors.secondary,
|
||||
leadingIcon: Boolean = false,
|
||||
padding: PaddingValues = PaddingValues(),
|
||||
onIconClick: (() -> Unit)? = null,
|
||||
content: (@Composable ColumnScope.() -> Unit)
|
||||
) {
|
||||
val card = LocalCardScreen.current
|
||||
Column {
|
||||
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val iconClickable = if (onIconClick != null) Modifier.clickable(interactionSource = interactionSource, indication = ripple(bounded = false, radius = iconSize * 0.75f), onClick = onIconClick) else Modifier
|
||||
Row(Modifier.padding(start = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint)
|
||||
if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize).then(iconClickable), tint = iconTint)
|
||||
Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = if (card) 14.sp else 12.sp, fontWeight = if (card) FontWeight.Medium else FontWeight.Normal)
|
||||
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint)
|
||||
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize).then(iconClickable), tint = iconTint)
|
||||
}
|
||||
CardColumn(padding) { content() }
|
||||
}
|
||||
|
||||
+2
-1
@@ -31,6 +31,7 @@ fun TextEditor(
|
||||
modifier: Modifier,
|
||||
placeholder: String? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
|
||||
shape: Shape = RoundedCornerShape(14.dp),
|
||||
isValid: (String) -> Boolean = { true },
|
||||
focusRequester: FocusRequester? = null,
|
||||
enabled: Boolean = true
|
||||
@@ -53,7 +54,7 @@ fun TextEditor(
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding)
|
||||
.heightIn(min = 52.dp)
|
||||
.border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(14.dp)),
|
||||
.border(border = BorderStroke(1.dp, strokeColor), shape = shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val textFieldModifier = modifier
|
||||
|
||||
+21
-17
@@ -38,7 +38,8 @@ import dev.icerock.moko.resources.compose.painterResource
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit) {
|
||||
fun AddChannelView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, closeAll: () -> Unit) {
|
||||
val rhId = rh?.remoteHostId
|
||||
val view = LocalMultiplatformView()
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -56,7 +57,7 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit
|
||||
|
||||
val gInfo = groupInfo.value
|
||||
if (showLinkStep.value && gInfo != null) {
|
||||
LinkStepView(chatModel, gInfo, groupLink, closeAll)
|
||||
LinkStepView(chatModel, rhId, gInfo, groupLink, closeAll)
|
||||
} else if (gInfo != null) {
|
||||
ProgressStepView(
|
||||
chatModel, gInfo, groupRelays, relayListExpanded,
|
||||
@@ -65,9 +66,9 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit
|
||||
chatModel.creatingChannelId.value = null
|
||||
closeAll()
|
||||
withBGApi {
|
||||
openGroupChat(null, gInfo.groupId)
|
||||
openGroupChat(rhId, gInfo.groupId)
|
||||
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
|
||||
GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, shareGroupInfo = gInfo, close = close)
|
||||
GroupLinkView(chatModel, rhId = rhId, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, shareGroupInfo = gInfo, close = close)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,9 +81,9 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit
|
||||
closeAll()
|
||||
withBGApi {
|
||||
try {
|
||||
chatModel.controller.apiDeleteChat(rh = null, type = ChatType.Group, id = gInfo.apiId)
|
||||
chatModel.controller.apiDeleteChat(rh = rhId, type = ChatType.Group, id = gInfo.apiId)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatsContext.removeChat(null, gInfo.id)
|
||||
chatModel.chatsContext.removeChat(rhId, gInfo.id)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "cancelChannelCreation error: ${e.message}")
|
||||
@@ -93,6 +94,7 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit
|
||||
} else {
|
||||
ProfileStepView(
|
||||
chatModel = chatModel,
|
||||
rhId = rhId,
|
||||
displayName = displayName,
|
||||
profileImage = profileImage,
|
||||
chosenImage = chosenImage,
|
||||
@@ -120,7 +122,7 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit
|
||||
creationInProgress.value = true
|
||||
withBGApi {
|
||||
try {
|
||||
val enabledRelays = chooseRandomRelays()
|
||||
val enabledRelays = chooseRandomRelays(rhId)
|
||||
val relayIds = enabledRelays.mapNotNull { it.chatRelayId }
|
||||
if (relayIds.isEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -130,7 +132,7 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit
|
||||
return@withBGApi
|
||||
}
|
||||
val result = chatModel.controller.apiNewPublicGroup(
|
||||
rh = null,
|
||||
rh = rhId,
|
||||
incognito = false,
|
||||
relayIds = relayIds,
|
||||
groupProfile = profile
|
||||
@@ -138,7 +140,7 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit
|
||||
when (result) {
|
||||
is ChatController.PublicGroupCreationResult.Created -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatsContext.updateGroup(rhId = null, result.groupInfo)
|
||||
chatModel.chatsContext.updateGroup(rhId = rhId, result.groupInfo)
|
||||
chatModel.creatingChannelId.value = result.groupInfo.id
|
||||
groupInfo.value = result.groupInfo
|
||||
groupLink.value = result.groupLink
|
||||
@@ -178,8 +180,8 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit
|
||||
|
||||
private const val maxRelays = 3
|
||||
|
||||
private suspend fun chooseRandomRelays(): List<UserChatRelay> {
|
||||
val servers = getUserServers(rh = null) ?: return emptyList()
|
||||
private suspend fun chooseRandomRelays(rhId: Long?): List<UserChatRelay> {
|
||||
val servers = getUserServers(rh = rhId) ?: return emptyList()
|
||||
// Operator relays are grouped per operator; custom relays (null operator)
|
||||
// are treated independently to maximize trust distribution.
|
||||
val operatorGroups = mutableListOf<List<UserChatRelay>>()
|
||||
@@ -215,8 +217,8 @@ private suspend fun chooseRandomRelays(): List<UserChatRelay> {
|
||||
return selected
|
||||
}
|
||||
|
||||
private suspend fun checkHasRelays(): Boolean {
|
||||
val servers = try { getUserServers(rh = null) } catch (_: Exception) { null } ?: return false
|
||||
private suspend fun checkHasRelays(rhId: Long?): Boolean {
|
||||
val servers = try { getUserServers(rh = rhId) } catch (_: Exception) { null } ?: return false
|
||||
return servers.any { op ->
|
||||
(op.operator?.enabled ?: true) &&
|
||||
op.chatRelays.any { it.enabled && !it.deleted && it.chatRelayId != null }
|
||||
@@ -226,6 +228,7 @@ private suspend fun checkHasRelays(): Boolean {
|
||||
@Composable
|
||||
private fun ProfileStepView(
|
||||
chatModel: ChatModel,
|
||||
rhId: Long?,
|
||||
displayName: MutableState<String>,
|
||||
profileImage: MutableState<String?>,
|
||||
chosenImage: MutableState<URI?>,
|
||||
@@ -239,7 +242,7 @@ private fun ProfileStepView(
|
||||
createChannel: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
hasRelays.value = checkHasRelays()
|
||||
hasRelays.value = checkHasRelays(rhId)
|
||||
}
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
@@ -553,6 +556,7 @@ private fun RelayRow(relay: GroupRelay, connFailed: Boolean) {
|
||||
@Composable
|
||||
private fun LinkStepView(
|
||||
chatModel: ChatModel,
|
||||
rhId: Long?,
|
||||
gInfo: GroupInfo,
|
||||
groupLink: MutableState<GroupLink?>,
|
||||
closeAll: () -> Unit
|
||||
@@ -563,14 +567,14 @@ private fun LinkStepView(
|
||||
delay(500)
|
||||
withContext(Dispatchers.Main) {
|
||||
ModalManager.start.closeModals()
|
||||
openGroupChat(null, gInfo.groupId)
|
||||
openGroupChat(rhId, gInfo.groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
ModalView(close = close, showClose = false, cardScreen = true) {
|
||||
GroupLinkView(
|
||||
chatModel = chatModel,
|
||||
rhId = null,
|
||||
rhId = rhId,
|
||||
groupInfo = gInfo,
|
||||
groupLink = groupLink.value,
|
||||
onGroupLinkUpdated = { groupLink.value = it },
|
||||
@@ -660,6 +664,6 @@ fun RelayProgressIndicator(active: Int, total: Int) {
|
||||
@Composable
|
||||
fun PreviewAddChannelView() {
|
||||
SimpleXTheme {
|
||||
AddChannelView(chatModel = ChatModel, close = {}, closeAll = {})
|
||||
AddChannelView(chatModel = ChatModel, rh = null, close = {}, closeAll = {})
|
||||
}
|
||||
}
|
||||
|
||||
+103
-21
@@ -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)
|
||||
@@ -77,13 +74,19 @@ private suspend fun planAndConnectTask(
|
||||
cleanup?.invoke()
|
||||
completable.complete(!completable.isActive)
|
||||
}
|
||||
val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, linkOwnerSig, inProgress = inProgress)
|
||||
val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, linkOwnerSig = linkOwnerSig, inProgress = inProgress)
|
||||
connectProgressManager.stopConnectProgress()
|
||||
if (!inProgress.value) { return completable }
|
||||
if (result != null) {
|
||||
val (connectionLink, connectionPlan) = result
|
||||
val (connectionLink, planSimplexName, otherSimplexName, connectionPlan) = result
|
||||
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
|
||||
val connectOtherLink = otherSimplexName?.shortStr
|
||||
val connectOtherButton = otherSimplexName?.let {
|
||||
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)
|
||||
}
|
||||
when (connectionPlan) {
|
||||
is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) {
|
||||
is InvitationLinkPlan.Ok ->
|
||||
@@ -94,8 +97,8 @@ private suspend fun planAndConnectTask(
|
||||
connectionLink,
|
||||
connectionPlan.invitationLinkPlan.contactSLinkData_,
|
||||
ownerVerification = connectionPlan.invitationLinkPlan.ownerVerification,
|
||||
close,
|
||||
cleanup
|
||||
close = close,
|
||||
cleanup = cleanup
|
||||
)
|
||||
} else {
|
||||
Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, no short link data")
|
||||
@@ -157,6 +160,9 @@ private suspend fun planAndConnectTask(
|
||||
connectionLink,
|
||||
connectionPlan.contactAddressPlan.contactSLinkData_,
|
||||
ownerVerification = connectionPlan.contactAddressPlan.ownerVerification,
|
||||
planSimplexName = planSimplexName,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
close,
|
||||
cleanup
|
||||
)
|
||||
@@ -169,6 +175,8 @@ private suspend fun planAndConnectTask(
|
||||
connectDestructive = false,
|
||||
cleanup,
|
||||
ownerVerification = connectionPlan.contactAddressPlan.ownerVerification,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
)
|
||||
}
|
||||
ContactAddressPlan.OwnLink -> {
|
||||
@@ -179,6 +187,8 @@ private suspend fun planAndConnectTask(
|
||||
text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText,
|
||||
connectDestructive = true,
|
||||
cleanup = cleanup,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
)
|
||||
}
|
||||
ContactAddressPlan.ConnectingConfirmReconnect -> {
|
||||
@@ -189,6 +199,8 @@ private suspend fun planAndConnectTask(
|
||||
text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText,
|
||||
connectDestructive = true,
|
||||
cleanup = cleanup,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
)
|
||||
}
|
||||
is ContactAddressPlan.ConnectingProhibit -> {
|
||||
@@ -197,25 +209,40 @@ private suspend fun planAndConnectTask(
|
||||
if (filterKnownContact != null) {
|
||||
filterKnownContact(contact)
|
||||
} else {
|
||||
showOpenKnownContactAlert(chatModel, rhId, close, contact)
|
||||
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
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 {
|
||||
showOpenKnownContactAlert(chatModel, rhId, close, contact)
|
||||
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
is ContactAddressPlan.ContactViaAddress -> {
|
||||
Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress")
|
||||
val contact = connectionPlan.contactAddressPlan.contact
|
||||
askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false)
|
||||
cleanup()
|
||||
// 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()))
|
||||
}
|
||||
if (filterKnownContact != null) {
|
||||
filterKnownContact(contact)
|
||||
} else {
|
||||
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) {
|
||||
@@ -228,6 +255,9 @@ private suspend fun planAndConnectTask(
|
||||
connectionPlan.groupLinkPlan.groupSLinkInfo_,
|
||||
connectionPlan.groupLinkPlan.groupSLinkData_,
|
||||
ownerVerification = connectionPlan.groupLinkPlan.ownerVerification,
|
||||
planSimplexName = planSimplexName,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
close,
|
||||
cleanup
|
||||
)
|
||||
@@ -240,6 +270,8 @@ private suspend fun planAndConnectTask(
|
||||
connectDestructive = false,
|
||||
cleanup = cleanup,
|
||||
ownerVerification = connectionPlan.groupLinkPlan.ownerVerification,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
)
|
||||
}
|
||||
is GroupLinkPlan.OwnLink -> {
|
||||
@@ -248,7 +280,7 @@ private suspend fun planAndConnectTask(
|
||||
if (filterKnownGroup != null) {
|
||||
filterKnownGroup(groupInfo)
|
||||
} else {
|
||||
ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup)
|
||||
ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
|
||||
}
|
||||
}
|
||||
GroupLinkPlan.ConnectingConfirmReconnect -> {
|
||||
@@ -259,6 +291,8 @@ private suspend fun planAndConnectTask(
|
||||
text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText,
|
||||
connectDestructive = true,
|
||||
cleanup = cleanup,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
)
|
||||
}
|
||||
is GroupLinkPlan.ConnectingProhibit -> {
|
||||
@@ -288,10 +322,15 @@ 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 {
|
||||
showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo)
|
||||
showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
@@ -417,6 +456,8 @@ fun askCurrentOrIncognitoProfileAlert(
|
||||
connectDestructive: Boolean,
|
||||
cleanup: (() -> Unit)?,
|
||||
ownerVerification: OwnerVerification? = null,
|
||||
connectOtherButton: String? = null,
|
||||
connectOtherLink: String? = null,
|
||||
) {
|
||||
val fullText = listOfNotNull(text, ownerVerificationMessage(ownerVerification)).joinToString("\n\n").ifEmpty { null }
|
||||
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
|
||||
@@ -441,6 +482,14 @@ fun askCurrentOrIncognitoProfileAlert(
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor)
|
||||
}
|
||||
if (connectOtherButton != null && connectOtherLink != null) {
|
||||
SectionItemView({
|
||||
AlertManager.privacySensitive.hideAlert()
|
||||
withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup) }
|
||||
}) {
|
||||
Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.privacySensitive.hideAlert()
|
||||
cleanup?.invoke()
|
||||
@@ -463,7 +512,11 @@ fun openChat_(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, chat: Cha
|
||||
|
||||
val alertProfileImageSize = 138.dp
|
||||
|
||||
private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact) {
|
||||
// For alerts that show the name inline (not as a profile with an avatar): "Alice" -> "Alice (@alice.testing)".
|
||||
private fun nameWithDomain(name: String, planSimplexName: SimplexNameInfo?): String =
|
||||
name + (planSimplexName?.let { " (${it.shortStr})" } ?: "")
|
||||
|
||||
private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null) {
|
||||
AlertManager.privacySensitive.showOpenChatAlert(
|
||||
profileName = contact.profile.displayName,
|
||||
profileFullName = contact.profile.fullName,
|
||||
@@ -476,10 +529,13 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close:
|
||||
},
|
||||
// the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too
|
||||
profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null,
|
||||
nameCaption = planSimplexName?.shortStr,
|
||||
confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat),
|
||||
onConfirm = {
|
||||
openKnownContact(chatModel, rhId, close, contact)
|
||||
},
|
||||
connectOtherButton = connectOtherButton,
|
||||
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close) } } },
|
||||
onDismiss = null
|
||||
)
|
||||
}
|
||||
@@ -503,11 +559,14 @@ fun ownGroupLinkConfirmConnect(
|
||||
groupInfo: GroupInfo,
|
||||
close: (() -> Unit)?,
|
||||
cleanup: (() -> Unit)?,
|
||||
planSimplexName: SimplexNameInfo? = null,
|
||||
connectOtherButton: String? = null,
|
||||
connectOtherLink: String? = null,
|
||||
) {
|
||||
if (groupInfo.useRelays) {
|
||||
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel),
|
||||
text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), groupInfo.displayName),
|
||||
text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), nameWithDomain(groupInfo.displayName, planSimplexName)),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
@@ -517,6 +576,14 @@ fun ownGroupLinkConfirmConnect(
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.connect_plan_open_channel), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
if (connectOtherButton != null && connectOtherLink != null) {
|
||||
SectionItemView({
|
||||
AlertManager.privacySensitive.hideAlert()
|
||||
withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup) }
|
||||
}) {
|
||||
Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.privacySensitive.hideAlert()
|
||||
cleanup?.invoke()
|
||||
@@ -575,7 +642,7 @@ fun ownGroupLinkConfirmConnect(
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) {
|
||||
private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null) {
|
||||
val subscriberCount = if (groupInfo.useRelays) groupInfo.groupSummary.publicMemberCount?.let { subscriberCountStr(it) } else null
|
||||
AlertManager.privacySensitive.showOpenChatAlert(
|
||||
profileName = groupInfo.groupProfile.displayName,
|
||||
@@ -587,6 +654,7 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: ((
|
||||
icon = groupInfo.chatIconName
|
||||
)
|
||||
},
|
||||
nameCaption = planSimplexName?.shortStr,
|
||||
subtitle = subscriberCount,
|
||||
confirmText = generalGetString(
|
||||
if (groupInfo.useRelays) {
|
||||
@@ -600,6 +668,8 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: ((
|
||||
onConfirm = {
|
||||
openKnownGroup(chatModel, rhId, close, groupInfo)
|
||||
},
|
||||
connectOtherButton = connectOtherButton,
|
||||
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close) } } },
|
||||
onDismiss = null
|
||||
)
|
||||
}
|
||||
@@ -619,6 +689,9 @@ fun showPrepareContactAlert(
|
||||
connectionLink: CreatedConnLink,
|
||||
contactShortLinkData: ContactShortLinkData,
|
||||
ownerVerification: OwnerVerification? = null,
|
||||
planSimplexName: SimplexNameInfo? = null,
|
||||
connectOtherButton: String? = null,
|
||||
connectOtherLink: String? = null,
|
||||
close: (() -> Unit)?,
|
||||
cleanup: (() -> Unit)?
|
||||
) {
|
||||
@@ -636,13 +709,14 @@ fun showPrepareContactAlert(
|
||||
)
|
||||
},
|
||||
profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge,
|
||||
nameCaption = planSimplexName?.shortStr,
|
||||
information = ownerVerificationMessage(ownerVerification),
|
||||
confirmText = generalGetString(MR.strings.connect_plan_open_new_chat),
|
||||
onConfirm = {
|
||||
AlertManager.privacySensitive.hideAlert()
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
withBGApi {
|
||||
val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData)
|
||||
val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData, planSimplexName?.nameDomain)
|
||||
if (chat != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ChatController.chatModel.chatsContext.addChat(chat)
|
||||
@@ -652,6 +726,8 @@ fun showPrepareContactAlert(
|
||||
cleanup?.invoke()
|
||||
}
|
||||
},
|
||||
connectOtherButton = connectOtherButton,
|
||||
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } },
|
||||
onDismiss = {
|
||||
cleanup?.invoke()
|
||||
}
|
||||
@@ -664,6 +740,9 @@ fun showPrepareGroupAlert(
|
||||
groupShortLinkInfo: GroupShortLinkInfo?,
|
||||
groupShortLinkData: GroupShortLinkData,
|
||||
ownerVerification: OwnerVerification? = null,
|
||||
planSimplexName: SimplexNameInfo? = null,
|
||||
connectOtherButton: String? = null,
|
||||
connectOtherLink: String? = null,
|
||||
close: (() -> Unit)?,
|
||||
cleanup: (() -> Unit)?
|
||||
) {
|
||||
@@ -679,6 +758,7 @@ fun showPrepareGroupAlert(
|
||||
icon = if (isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled
|
||||
)
|
||||
},
|
||||
nameCaption = planSimplexName?.shortStr,
|
||||
subtitle = subscriberCount,
|
||||
information = ownerVerificationMessage(ownerVerification),
|
||||
confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group),
|
||||
@@ -686,7 +766,7 @@ fun showPrepareGroupAlert(
|
||||
AlertManager.privacySensitive.hideAlert()
|
||||
withBGApi {
|
||||
val directLink = groupShortLinkInfo?.direct ?: true
|
||||
val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData)
|
||||
val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData, planSimplexName?.nameDomain)
|
||||
if (chat != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val relays = groupShortLinkInfo?.groupRelays
|
||||
@@ -703,6 +783,8 @@ fun showPrepareGroupAlert(
|
||||
cleanup?.invoke()
|
||||
}
|
||||
},
|
||||
connectOtherButton = connectOtherButton,
|
||||
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } },
|
||||
onDismiss = {
|
||||
cleanup?.invoke()
|
||||
}
|
||||
|
||||
+49
-26
@@ -64,7 +64,7 @@ fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) {
|
||||
ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) }
|
||||
},
|
||||
createChannel = {
|
||||
ModalManager.start.showCustomModal { close -> AddChannelView(chatModel, close, closeAll) }
|
||||
ModalManager.start.showCustomModal { close -> AddChannelView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) }
|
||||
},
|
||||
rh = rh,
|
||||
close = close
|
||||
@@ -136,7 +136,8 @@ private fun ModalData.NewChatSheetLayout(
|
||||
}
|
||||
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
||||
val searchShowingSimplexLink = remember { mutableStateOf(false) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
|
||||
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
|
||||
val baseContactTypes = remember { listOf(ContactType.CARD, ContactType.CONTACT_WITH_REQUEST, ContactType.REQUEST, ContactType.RECENT) }
|
||||
val contactTypes by remember(searchText.value.text.isEmpty()) {
|
||||
@@ -313,8 +314,13 @@ private fun ModalData.NewChatSheetLayout(
|
||||
searchText = searchText,
|
||||
searchShowingSimplexLink = searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
|
||||
connectNameCandidate = connectNameCandidate,
|
||||
close = close,
|
||||
)
|
||||
connectNameCandidate.value?.let { candidate ->
|
||||
Divider()
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = close)
|
||||
}
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
|
||||
}
|
||||
}
|
||||
@@ -399,8 +405,13 @@ private fun ModalData.NewChatSheetLayout(
|
||||
searchText = searchText,
|
||||
searchShowingSimplexLink = searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
|
||||
connectNameCandidate = connectNameCandidate,
|
||||
close = close,
|
||||
)
|
||||
connectNameCandidate.value?.let { candidate ->
|
||||
Divider()
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = close)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
@@ -466,7 +477,8 @@ private fun ContactsSearchBar(
|
||||
listState: LazyListState,
|
||||
searchText: MutableState<TextFieldValue>,
|
||||
searchShowingSimplexLink: MutableState<Boolean>,
|
||||
searchChatFilteredBySimplexLink: MutableState<String?>,
|
||||
searchChatFilteredBySimplexLink: MutableState<Set<String>>,
|
||||
connectNameCandidate: MutableState<String?>,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
@@ -485,6 +497,8 @@ private fun ContactsSearchBar(
|
||||
alwaysVisible = true,
|
||||
searchText = searchText,
|
||||
trailingContent = null,
|
||||
// the clear button must line up with the filter icon it replaces, so no reduction here
|
||||
reducedCloseButtonPadding = 0.dp,
|
||||
) {
|
||||
searchText.value = searchText.value.copy(it)
|
||||
}
|
||||
@@ -523,21 +537,26 @@ private fun ContactsSearchBar(
|
||||
snapshotFlow { searchText.value.text }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
when (val target = strConnectTarget(it.trim())) {
|
||||
is ConnectTarget.Link -> {
|
||||
hideKeyboard(view)
|
||||
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
|
||||
searchShowingSimplexLink.value = true
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
connect(
|
||||
link = target.text,
|
||||
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
|
||||
close = close,
|
||||
cleanup = { searchText.value = TextFieldValue() }
|
||||
)
|
||||
}
|
||||
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
|
||||
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
|
||||
val target = strConnectTarget(it.trim())
|
||||
if (target is ConnectTarget.Link) {
|
||||
hideKeyboard(view)
|
||||
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
|
||||
searchShowingSimplexLink.value = true
|
||||
searchChatFilteredBySimplexLink.value = emptySet()
|
||||
connectNameCandidate.value = null
|
||||
connect(
|
||||
link = target.text,
|
||||
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
|
||||
close = close,
|
||||
cleanup = { searchText.value = TextFieldValue() }
|
||||
)
|
||||
} else {
|
||||
// A name is resolved only when its "Connect to …" row is tapped, not on every keystroke. The
|
||||
// simplex-name filter is chat-list only: this contacts/deleted view is a scoped subset, so a
|
||||
// resolved chat id (channel, business, unlisted or active-only contact) may not be present in it.
|
||||
val candidate = nameSearchCandidate(it.trim())
|
||||
connectNameCandidate.value = candidate
|
||||
if (candidate == null && (!searchShowingSimplexLink.value || it.isEmpty())) {
|
||||
if (it.isNotEmpty()) {
|
||||
focusRequester.requestFocus()
|
||||
} else {
|
||||
@@ -547,7 +566,7 @@ private fun ContactsSearchBar(
|
||||
}
|
||||
}
|
||||
searchShowingSimplexLink.value = false
|
||||
searchChatFilteredBySimplexLink.value = null
|
||||
searchChatFilteredBySimplexLink.value = emptySet()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,12 +593,12 @@ private fun ToggleFilterButton() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<String?>, close: () -> Unit, cleanup: (() -> Unit)?) {
|
||||
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<Set<String>>, close: () -> Unit, cleanup: (() -> Unit)?) {
|
||||
withBGApi {
|
||||
planAndConnect(
|
||||
chatModel.remoteHostId(),
|
||||
link,
|
||||
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
|
||||
filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
|
||||
close = close,
|
||||
cleanup = cleanup,
|
||||
)
|
||||
@@ -589,15 +608,15 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
|
||||
private fun filteredContactChats(
|
||||
showUnreadAndFavorites: Boolean,
|
||||
searchShowingSimplexLink: State<Boolean>,
|
||||
searchChatFilteredBySimplexLink: State<String?>,
|
||||
searchChatFilteredBySimplexLink: State<Set<String>>,
|
||||
searchText: String,
|
||||
contactChats: List<Chat>
|
||||
): List<Chat> {
|
||||
val linkChatId = searchChatFilteredBySimplexLink.value
|
||||
val linkChatIds = searchChatFilteredBySimplexLink.value
|
||||
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
|
||||
|
||||
return if (linkChatId != null) {
|
||||
contactChats.filter { it.id == linkChatId }
|
||||
return if (linkChatIds.isNotEmpty()) {
|
||||
contactChats.filter { it.id in linkChatIds }
|
||||
} else {
|
||||
contactChats.filter { chat ->
|
||||
filterChat(
|
||||
@@ -653,7 +672,9 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
|
||||
val listState = remember { appBarHandler.listState }
|
||||
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
||||
val searchShowingSimplexLink = remember { mutableStateOf(false) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
// deleted contacts are not connected to by name, so this candidate only stops per-keystroke resolution
|
||||
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
|
||||
val showUnreadAndFavorites = remember { appPrefs.showUnreadAndFavorites.state }.value
|
||||
val allChats by remember(chatModel.chats.value) {
|
||||
derivedStateOf { filterContactTypes(chatModel.chats.value, listOf(ContactType.CHAT_DELETED)) }
|
||||
@@ -696,6 +717,7 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
|
||||
searchText = searchText,
|
||||
searchShowingSimplexLink = searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
|
||||
connectNameCandidate = connectNameCandidate,
|
||||
close = close,
|
||||
)
|
||||
} else {
|
||||
@@ -705,6 +727,7 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
|
||||
searchText = searchText,
|
||||
searchShowingSimplexLink = searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
|
||||
connectNameCandidate = connectNameCandidate,
|
||||
close = close,
|
||||
)
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
|
||||
|
||||
+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
-9
@@ -148,7 +148,6 @@ private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) {
|
||||
AppBarTitle(stringResource(MR.strings.connecting_to_desktop))
|
||||
SectionView(stringResource(MR.strings.connecting_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
CtrlDeviceNameText(session, rc)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
CtrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
@@ -257,7 +256,6 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC
|
||||
AppBarTitle(stringResource(MR.strings.verify_connection))
|
||||
SectionView(stringResource(MR.strings.connected_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
CtrlDeviceNameText(session, rc)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
CtrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
@@ -265,16 +263,15 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC
|
||||
|
||||
SectionView(stringResource(MR.strings.verify_code_with_desktop)) {
|
||||
SessionCodeText(sessCode)
|
||||
SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) {
|
||||
Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary)
|
||||
TextIconSpaced(false)
|
||||
Text(generalGetString(MR.strings.confirm_verb))
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) {
|
||||
Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary)
|
||||
TextIconSpaced(false)
|
||||
Text(generalGetString(MR.strings.confirm_verb))
|
||||
}
|
||||
|
||||
SectionView {
|
||||
DisconnectButton(onClick = ::disconnectDesktop)
|
||||
}
|
||||
@@ -312,7 +309,6 @@ private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close:
|
||||
AppBarTitle(stringResource(MR.strings.connected_to_desktop))
|
||||
SectionView(stringResource(MR.strings.connected_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Text(rc.deviceViewName)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
CtrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
@@ -38,12 +39,14 @@ fun CallSettingsLayout(
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.your_calls))
|
||||
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
|
||||
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
|
||||
SectionItemView(editIceServers) { Text(stringResource(MR.strings.webrtc_ice_servers)) }
|
||||
|
||||
val enabled = remember { mutableStateOf(true) }
|
||||
LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it })
|
||||
if (appPlatform.isAndroid) {
|
||||
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
|
||||
val enabled = remember { mutableStateOf(true) }
|
||||
LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it })
|
||||
}
|
||||
SettingsPreferenceItem(null, stringResource(MR.strings.always_use_relay), webrtcPolicyRelay)
|
||||
}
|
||||
SectionTextFooter(
|
||||
|
||||
+40
-35
@@ -134,6 +134,11 @@ fun MorePrivacyView(chatModel: ChatModel) {
|
||||
chatModel.draftChatId.value = null
|
||||
}
|
||||
})
|
||||
SettingsPreferenceItem(
|
||||
painterResource(MR.images.ic_tag),
|
||||
stringResource(MR.strings.verify_simplex_names),
|
||||
chatModel.controller.appPrefs.privacyVerifySimplexNames
|
||||
)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
@@ -665,46 +670,46 @@ fun SimplexLockView(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (performLA.value && laMode.value == LAMode.PASSCODE) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(stringResource(MR.strings.self_destruct_passcode)) {
|
||||
val openInfo = {
|
||||
ModalManager.start.showModal {
|
||||
SelfDestructInfoView()
|
||||
}
|
||||
}
|
||||
if (performLA.value && laMode.value == LAMode.PASSCODE) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(stringResource(MR.strings.self_destruct_passcode)) {
|
||||
val openInfo = {
|
||||
ModalManager.start.showModal {
|
||||
SelfDestructInfoView()
|
||||
}
|
||||
SettingsActionItemWithContent(null, null, click = openInfo) {
|
||||
SharedPreferenceToggleWithIcon(
|
||||
stringResource(MR.strings.enable_self_destruct),
|
||||
painterResource(MR.images.ic_info),
|
||||
openInfo,
|
||||
remember { selfDestructPref.state }.value
|
||||
) {
|
||||
toggleSelfDestruct(selfDestructPref)
|
||||
}
|
||||
}
|
||||
SettingsActionItemWithContent(null, null, click = openInfo) {
|
||||
SharedPreferenceToggleWithIcon(
|
||||
stringResource(MR.strings.enable_self_destruct),
|
||||
painterResource(MR.images.ic_info),
|
||||
openInfo,
|
||||
remember { selfDestructPref.state }.value
|
||||
) {
|
||||
toggleSelfDestruct(selfDestructPref)
|
||||
}
|
||||
}
|
||||
|
||||
if (remember { selfDestructPref.state }.value) {
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
||||
Text(
|
||||
stringResource(MR.strings.self_destruct_new_display_name),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(selfDestructDisplayName, "", { isValidDisplayName(it.trim()) })
|
||||
LaunchedEffect(selfDestructDisplayName.value) {
|
||||
val new = selfDestructDisplayName.value
|
||||
if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) {
|
||||
selfDestructDisplayNamePref.set(new)
|
||||
}
|
||||
if (remember { selfDestructPref.state }.value) {
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
||||
Text(
|
||||
stringResource(MR.strings.self_destruct_new_display_name),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(selfDestructDisplayName, "", { isValidDisplayName(it.trim()) })
|
||||
LaunchedEffect(selfDestructDisplayName.value) {
|
||||
val new = selfDestructDisplayName.value
|
||||
if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) {
|
||||
selfDestructDisplayNamePref.set(new)
|
||||
}
|
||||
}
|
||||
SectionItemView({ changeSelfDestructPassword() }) {
|
||||
Text(
|
||||
stringResource(MR.strings.change_self_destruct_passcode),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionItemView({ changeSelfDestructPassword() }) {
|
||||
Text(
|
||||
stringResource(MR.strings.change_self_destruct_passcode),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
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 SetSimplexDomainView(
|
||||
title: String,
|
||||
footer: String,
|
||||
placeholder: String,
|
||||
simplexName: String,
|
||||
save: suspend (String?) -> Boolean,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val name = rememberSaveable { mutableStateOf(simplexName) }
|
||||
val saving = remember { mutableStateOf(false) }
|
||||
val unchanged = name.value.trim() == simplexName.trim()
|
||||
|
||||
fun addSimplexTLD(s: String): String {
|
||||
return if (s.contains(".")) s else "$s.simplex"
|
||||
}
|
||||
|
||||
fun normalized(): String? {
|
||||
val s = name.value.trim()
|
||||
return when {
|
||||
s.isEmpty() -> null
|
||||
s.startsWith("@") || s.startsWith("#") -> addSimplexTLD(s.substring(1))
|
||||
else -> addSimplexTLD(s)
|
||||
}
|
||||
}
|
||||
|
||||
val doSave: () -> Unit = {
|
||||
withBGApi {
|
||||
saving.value = true
|
||||
val ok = try { save(normalized()) } catch (e: Exception) {
|
||||
Log.e(TAG, "SetSimplexDomainView 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)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-1
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCardShape
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
@@ -33,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(
|
||||
@@ -359,6 +362,37 @@ private fun UserAddressLayout(
|
||||
SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations))
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
SectionView {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_at),
|
||||
stringResource(MR.strings.your_simplex_name),
|
||||
click = {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
val domain = user?.profile?.contactDomain?.shortName
|
||||
SetSimplexDomainView(
|
||||
title = generalGetString(MR.strings.set_simplex_name),
|
||||
footer = generalGetString(MR.strings.set_user_simplex_name_footer),
|
||||
placeholder = "@yourname.testing",
|
||||
simplexName = if (domain == null) "" else "@$domain",
|
||||
save = { simplexDomain ->
|
||||
try {
|
||||
val u = chatModel.controller.apiSetUserDomain(user?.remoteHostId, simplexDomain)
|
||||
withContext(Dispatchers.Main) { chatModel.updateUser(u) }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "apiSetUserDomain: ${e.message}")
|
||||
false
|
||||
}
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
},
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.or_to_share_privately)) {
|
||||
CreateOneTimeLinkButton()
|
||||
@@ -699,7 +733,13 @@ private fun AcceptIncognitoToggle(addressSettingsState: MutableState<AddressSett
|
||||
@Composable
|
||||
private fun AutoReplyEditor(addressSettingsState: MutableState<AddressSettingsState>) {
|
||||
val autoReply = rememberSaveable { mutableStateOf(addressSettingsState.value.autoReply) }
|
||||
TextEditor(autoReply, Modifier.height(100.dp), placeholder = stringResource(MR.strings.enter_welcome_message_optional))
|
||||
TextEditor(
|
||||
autoReply,
|
||||
Modifier.height(100.dp),
|
||||
placeholder = stringResource(MR.strings.enter_welcome_message_optional),
|
||||
contentPadding = PaddingValues(),
|
||||
shape = SectionCardShape
|
||||
)
|
||||
LaunchedEffect(autoReply.value) {
|
||||
if (autoReply.value != addressSettingsState.value.autoReply) {
|
||||
addressSettingsState.value = AddressSettingsState(
|
||||
|
||||
+21
-30
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCardShape
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
@@ -10,9 +11,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
@@ -230,43 +229,35 @@ private fun CustomRelay(
|
||||
}
|
||||
|
||||
SectionView(
|
||||
stringResource(MR.strings.your_relay_address).uppercase(),
|
||||
stringResource(MR.strings.your_relay_address),
|
||||
icon = painterResource(MR.images.ic_error),
|
||||
iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent,
|
||||
) {
|
||||
TextEditor(
|
||||
relayAddress,
|
||||
Modifier.height(144.dp)
|
||||
Modifier.height(144.dp),
|
||||
contentPadding = PaddingValues(),
|
||||
shape = SectionCardShape
|
||||
)
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
|
||||
Column {
|
||||
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
||||
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(MR.strings.your_relay_name).uppercase(),
|
||||
color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp
|
||||
)
|
||||
IconButton(
|
||||
onClick = { if (!validName.value) showInvalidRelayNameAlert(relayName) },
|
||||
enabled = !validName.value,
|
||||
modifier = Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize)
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error), null,
|
||||
tint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
TextEditor(
|
||||
relayName,
|
||||
Modifier,
|
||||
placeholder = generalGetString(MR.strings.enter_relay_name),
|
||||
enabled = relay.value.tested != true
|
||||
)
|
||||
}
|
||||
SectionView(
|
||||
stringResource(MR.strings.your_relay_name),
|
||||
icon = painterResource(MR.images.ic_error),
|
||||
iconTint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent,
|
||||
onIconClick = if (!validName.value) {
|
||||
{ showInvalidRelayNameAlert(relayName) }
|
||||
} else null
|
||||
) {
|
||||
TextEditor(
|
||||
relayName,
|
||||
Modifier,
|
||||
placeholder = generalGetString(MR.strings.enter_relay_name),
|
||||
contentPadding = PaddingValues(),
|
||||
shape = SectionCardShape,
|
||||
enabled = relay.value.tested != true
|
||||
)
|
||||
}
|
||||
if (relay.value.tested != true) {
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.test_relay_to_retrieve_name))
|
||||
|
||||
+21
-29
@@ -268,28 +268,32 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) {
|
||||
if (currentRemoteHost == null && networkUseSocksProxy.value) {
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
|
||||
}
|
||||
val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)
|
||||
|
||||
SectionItemView(
|
||||
{ scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } },
|
||||
disabled = saveDisabled,
|
||||
) {
|
||||
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
SectionDividerSpaced()
|
||||
SectionView {
|
||||
val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)
|
||||
SectionItemView(
|
||||
{ scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } },
|
||||
disabled = saveDisabled,
|
||||
) {
|
||||
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 +955,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()
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<string name="connect_via_link_verb">Connect</string>
|
||||
<string name="connect_via_link_incognito">Connect incognito</string>
|
||||
<string name="connect_plan_open_chat">Open chat</string>
|
||||
<string name="connect_plan_join_name">Join channel %s</string>
|
||||
<string name="connect_plan_connect_to_name">Connect to %s</string>
|
||||
<string name="connect_plan_open_new_chat">Open new chat</string>
|
||||
<string name="connect_plan_open_group">Open group</string>
|
||||
<string name="connect_plan_open_new_group">Open new group</string>
|
||||
@@ -146,6 +148,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 +202,16 @@
|
||||
<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_error">SimpleX name error</string>
|
||||
<string name="simplex_name_no_servers_desc">None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link.</string>
|
||||
<string name="simplex_name_server_no_resolver_desc">Server %1$s does not support name resolution. Configure servers, or use a connection link.</string>
|
||||
<string name="simplex_name_not_found">Name not found</string>
|
||||
<string name="simplex_name_not_found_desc">This SimpleX name is not registered. Please check the name.</string>
|
||||
<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_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>
|
||||
<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>
|
||||
@@ -930,6 +943,17 @@
|
||||
<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="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="simplex_name">SimpleX name</string>
|
||||
<string name="your_simplex_name">Your SimpleX name</string>
|
||||
<string name="set_simplex_name">Set SimpleX name</string>
|
||||
<string name="error_saving_simplex_name">Error saving name</string>
|
||||
<string name="simplex_name_owner_no_channel_link">The SimpleX name %1$s is registered without channel link. Add channel link to the name via the registration page.</string>
|
||||
<string name="simplex_name_owner_no_address">The SimpleX name %1$s is registered without SimpleX address. Add your SimpleX address to the name via the registration page.</string>
|
||||
<string name="set_user_simplex_name_footer">Let people connect to you via name registered with your SimpleX address.</string>
|
||||
<string name="set_channel_simplex_name_footer">Let people join via name registered with this channel link.</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 +2177,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>
|
||||
|
||||
+7
-2
@@ -13,9 +13,14 @@ import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
|
||||
internal val vlcFactory: MediaPlayerFactory by lazy { MediaPlayerFactory() }
|
||||
// Serialize the two factory constructions: each MediaPlayerFactory() runs VLC native discovery via
|
||||
// a JDK ServiceLoader, which is not thread-safe. Building both factories concurrently (e.g. vlcFactory
|
||||
// on the render thread while vlcPreviewFactory is built on the preview thread) corrupts the ServiceLoader
|
||||
// enumeration and throws NoSuchElementException from CompoundEnumeration.nextElement.
|
||||
private val vlcFactoryLock = Any()
|
||||
internal val vlcFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory() } }
|
||||
// No hardware acceleration - more secure for previews
|
||||
internal val vlcPreviewFactory: MediaPlayerFactory by lazy { MediaPlayerFactory("--avcodec-hw=none") }
|
||||
internal val vlcPreviewFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory("--avcodec-hw=none") } }
|
||||
|
||||
actual class RecorderNative: RecorderInterface {
|
||||
private var player: MediaPlayer? = null
|
||||
|
||||
Reference in New Issue
Block a user