Add alert to encrypted rooms with visible history (Android). (#5709)
* feat: Add visible history alert to encrypted rooms. - Adds a dismissable alert that is displayed whenever the user opens a room with `history_visibility` != `joined`. When cleared, this is recorded in the app's data store. - When opening a room with `history_visibility` = `joined`, this flag is cleared.` Issue: https://github.com/element-hq/element-meta/issues/2875 * chore: Fix linting issues. * feat: Move alert showing logic into state presenter. * chore: Fix linting issues. * tests: Fixup tests. * feat: Use real link. * chore: Update license header. * chore: Add (c) to license headers. * chore: Add `.` to license header. * feat: Lock alert behind history sharing developer setting. * ci: Trigger record screenshots * feat: Create repo key using session ID for multi-account support. * feat: Use session ID hash for constructing data store. * tests: Correct and update tests. * tests: Update snapshots. * feat: Prevent identity alert from displaying with history visibility alert. * feat: Tidy up HistoryVisibleStatePresenter logic, update previews. * chore: Remove unused import. * chore: Update screenshots. * feat: Add translation string. * chore: Remove redundant temporary translation file.
This commit is contained in:
@@ -13,4 +13,5 @@ object LearnMoreConfig {
|
|||||||
const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification"
|
const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification"
|
||||||
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
|
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
|
||||||
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
|
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
|
||||||
|
const val HISTORY_VISIBLE_URL: String = "https://element.io/en/help#e2ee-history-sharing"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ dependencies {
|
|||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.androidx.constraintlayout.compose)
|
implementation(libs.androidx.constraintlayout.compose)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
implementation(libs.sigpwned.emoji4j)
|
implementation(libs.sigpwned.emoji4j)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
|||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
import io.element.android.features.messages.impl.link.LinkState
|
import io.element.android.features.messages.impl.link.LinkState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
|
||||||
@@ -107,6 +108,7 @@ class MessagesPresenter(
|
|||||||
@Assisted private val timelinePresenter: Presenter<TimelineState>,
|
@Assisted private val timelinePresenter: Presenter<TimelineState>,
|
||||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||||
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
|
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
|
||||||
|
private val historyVisibleStatePresenter: Presenter<HistoryVisibleState>,
|
||||||
private val linkPresenter: Presenter<LinkState>,
|
private val linkPresenter: Presenter<LinkState>,
|
||||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||||
private val customReactionPresenter: Presenter<CustomReactionState>,
|
private val customReactionPresenter: Presenter<CustomReactionState>,
|
||||||
@@ -158,6 +160,7 @@ class MessagesPresenter(
|
|||||||
val timelineState = timelinePresenter.present()
|
val timelineState = timelinePresenter.present()
|
||||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||||
val identityChangeState = identityChangeStatePresenter.present()
|
val identityChangeState = identityChangeStatePresenter.present()
|
||||||
|
val historyVisibleState = historyVisibleStatePresenter.present()
|
||||||
val actionListState = actionListPresenter.present()
|
val actionListState = actionListPresenter.present()
|
||||||
val linkState = linkPresenter.present()
|
val linkState = linkPresenter.present()
|
||||||
val customReactionState = customReactionPresenter.present()
|
val customReactionState = customReactionPresenter.present()
|
||||||
@@ -278,6 +281,7 @@ class MessagesPresenter(
|
|||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
identityChangeState = identityChangeState,
|
identityChangeState = identityChangeState,
|
||||||
|
historyVisibleState = historyVisibleState,
|
||||||
linkState = linkState,
|
linkState = linkState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
customReactionState = customReactionState,
|
customReactionState = customReactionState,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
|
|||||||
|
|
||||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
|
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
import io.element.android.features.messages.impl.link.LinkState
|
import io.element.android.features.messages.impl.link.LinkState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
@@ -40,6 +41,7 @@ data class MessagesState(
|
|||||||
val timelineState: TimelineState,
|
val timelineState: TimelineState,
|
||||||
val timelineProtectionState: TimelineProtectionState,
|
val timelineProtectionState: TimelineProtectionState,
|
||||||
val identityChangeState: IdentityChangeState,
|
val identityChangeState: IdentityChangeState,
|
||||||
|
val historyVisibleState: HistoryVisibleState,
|
||||||
val linkState: LinkState,
|
val linkState: LinkState,
|
||||||
val actionListState: ActionListState,
|
val actionListState: ActionListState,
|
||||||
val customReactionState: CustomReactionState,
|
val customReactionState: CustomReactionState,
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
|
|||||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
|
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||||
|
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
|
||||||
|
import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.aRoomMemberIdentityStateChange
|
||||||
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
||||||
import io.element.android.features.messages.impl.link.LinkState
|
import io.element.android.features.messages.impl.link.LinkState
|
||||||
import io.element.android.features.messages.impl.link.aLinkState
|
import io.element.android.features.messages.impl.link.aLinkState
|
||||||
@@ -48,6 +51,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
|||||||
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@@ -83,6 +87,19 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
|||||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
aMessagesState(
|
||||||
|
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
|
||||||
|
identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange()))
|
||||||
|
),
|
||||||
|
aMessagesState(
|
||||||
|
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
|
||||||
|
historyVisibleState = aHistoryVisibleState(showAlert = true)
|
||||||
|
),
|
||||||
|
aMessagesState(
|
||||||
|
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
|
||||||
|
identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())),
|
||||||
|
historyVisibleState = aHistoryVisibleState(showAlert = true)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +120,7 @@ fun aMessagesState(
|
|||||||
),
|
),
|
||||||
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
||||||
identityChangeState: IdentityChangeState = anIdentityChangeState(),
|
identityChangeState: IdentityChangeState = anIdentityChangeState(),
|
||||||
|
historyVisibleState: HistoryVisibleState = aHistoryVisibleState(),
|
||||||
linkState: LinkState = aLinkState(),
|
linkState: LinkState = aLinkState(),
|
||||||
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
||||||
actionListState: ActionListState = anActionListState(),
|
actionListState: ActionListState = anActionListState(),
|
||||||
@@ -125,6 +143,7 @@ fun aMessagesState(
|
|||||||
voiceMessageComposerState = voiceMessageComposerState,
|
voiceMessageComposerState = voiceMessageComposerState,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
identityChangeState = identityChangeState,
|
identityChangeState = identityChangeState,
|
||||||
|
historyVisibleState = historyVisibleState,
|
||||||
linkState = linkState,
|
linkState = linkState,
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
|
|||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStateView
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
|
||||||
import io.element.android.features.messages.impl.link.LinkEvents
|
import io.element.android.features.messages.impl.link.LinkEvents
|
||||||
import io.element.android.features.messages.impl.link.LinkView
|
import io.element.android.features.messages.impl.link.LinkView
|
||||||
@@ -486,10 +487,17 @@ private fun MessagesViewComposerBottomSheetContents(
|
|||||||
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
|
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
|
||||||
if (state.composerState.suggestions.isEmpty() &&
|
if (state.composerState.suggestions.isEmpty() &&
|
||||||
state.composerState.textEditorState is TextEditorState.Markdown) {
|
state.composerState.textEditorState is TextEditorState.Markdown) {
|
||||||
IdentityChangeStateView(
|
if (state.identityChangeState.roomMemberIdentityStateChanges.isNotEmpty()) {
|
||||||
state = state.identityChangeState,
|
IdentityChangeStateView(
|
||||||
onLinkClick = onLinkClick,
|
state = state.identityChangeState,
|
||||||
)
|
onLinkClick = onLinkClick,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
HistoryVisibleStateView(
|
||||||
|
state = state.historyVisibleState,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
|
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
|
||||||
it.identityState == IdentityState.VerificationViolation
|
it.identityState == IdentityState.VerificationViolation
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.androidutils.hash.hash
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
interface HistoryVisibleAcknowledgementRepository {
|
||||||
|
fun hasAcknowledged(roomId: RoomId): Flow<Boolean>
|
||||||
|
suspend fun setAcknowledged(roomId: RoomId, value: Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(SessionScope::class)
|
||||||
|
class DefaultHistoryVisibleAcknowledgementRepository(
|
||||||
|
sessionId: SessionId,
|
||||||
|
preferenceDataStoreFactory: PreferenceDataStoreFactory,
|
||||||
|
) : HistoryVisibleAcknowledgementRepository {
|
||||||
|
val store =
|
||||||
|
sessionId.value.hash().take(16).let { hash ->
|
||||||
|
preferenceDataStoreFactory.create("elementx_historyvisible_$hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasAcknowledged(roomId: RoomId): Flow<Boolean> {
|
||||||
|
return store.data.map { prefs ->
|
||||||
|
val acknowledged = prefs[booleanPreferencesKey(roomId.value)] ?: false
|
||||||
|
acknowledged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[booleanPreferencesKey(roomId.value)] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
sealed interface HistoryVisibleEvent {
|
||||||
|
data object Acknowledge : HistoryVisibleEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
data class HistoryVisibleState(
|
||||||
|
val showAlert: Boolean,
|
||||||
|
val eventSink: (HistoryVisibleEvent) -> Unit,
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class HistoryVisibleStatePresenter(
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val repository: HistoryVisibleAcknowledgementRepository,
|
||||||
|
private val room: JoinedRoom,
|
||||||
|
) : Presenter<HistoryVisibleState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): HistoryVisibleState {
|
||||||
|
val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
|
||||||
|
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||||
|
// Implicitly assume the alert is initially acknowledged to avoid flashes in UI.
|
||||||
|
val acknowledged by repository.hasAcknowledged(room.roomId).collectAsState(initial = true)
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(roomInfo.historyVisibility, acknowledged) {
|
||||||
|
if (roomInfo.historyVisibility == RoomHistoryVisibility.Joined && acknowledged) {
|
||||||
|
repository.setAcknowledged(room.roomId, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvent(event: HistoryVisibleEvent) {
|
||||||
|
when (event) {
|
||||||
|
is HistoryVisibleEvent.Acknowledge -> coroutineScope.setAcknowledged(room.roomId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HistoryVisibleState(
|
||||||
|
showAlert = isFeatureEnabled && roomInfo.historyVisibility != RoomHistoryVisibility.Joined && roomInfo.isEncrypted == true && !acknowledged,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.setAcknowledged(roomId: RoomId, value: Boolean) = launch {
|
||||||
|
repository.setAcknowledged(roomId, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
class HistoryVisibleStateProvider : PreviewParameterProvider<HistoryVisibleState> {
|
||||||
|
override val values: Sequence<HistoryVisibleState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aHistoryVisibleState(showAlert = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun aHistoryVisibleState(
|
||||||
|
showAlert: Boolean = false,
|
||||||
|
eventSink: (HistoryVisibleEvent) -> Unit = {},
|
||||||
|
) = HistoryVisibleState(
|
||||||
|
showAlert,
|
||||||
|
eventSink = eventSink,
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import io.element.android.appconfig.LearnMoreConfig
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertLevel
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HistoryVisibleStateView(
|
||||||
|
state: HistoryVisibleState,
|
||||||
|
onLinkClick: (String, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (!state.showAlert) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ComposerAlertMolecule(
|
||||||
|
modifier = modifier,
|
||||||
|
avatar = null,
|
||||||
|
showIcon = true,
|
||||||
|
level = ComposerAlertLevel.Info,
|
||||||
|
content = buildAnnotatedString {
|
||||||
|
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
|
||||||
|
val fullText = stringResource(CommonStrings.crypto_history_visible, learnMoreStr)
|
||||||
|
append(fullText)
|
||||||
|
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = ElementTheme.colors.textPrimary
|
||||||
|
),
|
||||||
|
start = learnMoreStartIndex,
|
||||||
|
end = learnMoreStartIndex + learnMoreStr.length,
|
||||||
|
)
|
||||||
|
addLink(
|
||||||
|
url = LinkAnnotation.Url(
|
||||||
|
url = LearnMoreConfig.HISTORY_VISIBLE_URL,
|
||||||
|
linkInteractionListener = {
|
||||||
|
onLinkClick(LearnMoreConfig.HISTORY_VISIBLE_URL, true)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
start = learnMoreStartIndex,
|
||||||
|
end = learnMoreStartIndex + learnMoreStr.length,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
submitText = stringResource(CommonStrings.action_dismiss),
|
||||||
|
onSubmitClick = { state.eventSink(HistoryVisibleEvent.Acknowledge) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun HistoryVisibleStateViewPreview(
|
||||||
|
@PreviewParameter(HistoryVisibleStateProvider::class) state: HistoryVisibleState,
|
||||||
|
) = ElementPreview {
|
||||||
|
HistoryVisibleStateView(
|
||||||
|
state = state,
|
||||||
|
onLinkClick = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.features.messages.impl.MessagesView
|
||||||
|
import io.element.android.features.messages.impl.aMessagesState
|
||||||
|
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun MessagesViewWithHistoryVisiblePreview() = ElementPreview {
|
||||||
|
MessagesView(
|
||||||
|
state = aMessagesState(
|
||||||
|
composerState = aMessageComposerState(
|
||||||
|
textEditorState = aTextEditorStateMarkdown(
|
||||||
|
initialText = "",
|
||||||
|
initialFocus = false,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
historyVisibleState = aHistoryVisibleState(showAlert = true),
|
||||||
|
),
|
||||||
|
onBackClick = {},
|
||||||
|
onRoomDetailsClick = {},
|
||||||
|
onEventContentClick = { _, _ -> false },
|
||||||
|
onUserDataClick = {},
|
||||||
|
onLinkClick = { _, _ -> },
|
||||||
|
onSendLocationClick = {},
|
||||||
|
onCreatePollClick = {},
|
||||||
|
onJoinCallClick = {},
|
||||||
|
onViewAllPinnedMessagesClick = {},
|
||||||
|
knockRequestsBannerView = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ package io.element.android.features.messages.impl.di
|
|||||||
import dev.zacsweers.metro.BindingContainer
|
import dev.zacsweers.metro.BindingContainer
|
||||||
import dev.zacsweers.metro.Binds
|
import dev.zacsweers.metro.Binds
|
||||||
import dev.zacsweers.metro.ContributesTo
|
import dev.zacsweers.metro.ContributesTo
|
||||||
|
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
|
||||||
|
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStatePresenter
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
|
||||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
|
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
|
||||||
@@ -61,4 +63,7 @@ interface MessagesBindsModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter<IdentityChangeState>
|
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter<IdentityChangeState>
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
fun bindHistoryVisibleStatePresenter(presenter: HistoryVisibleStatePresenter): Presenter<HistoryVisibleState>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
|||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState
|
||||||
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
||||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||||
import io.element.android.features.messages.impl.link.aLinkState
|
import io.element.android.features.messages.impl.link.aLinkState
|
||||||
@@ -1355,6 +1356,7 @@ class MessagesPresenterTest {
|
|||||||
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
|
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
|
||||||
timelineProtectionPresenter = { aTimelineProtectionState() },
|
timelineProtectionPresenter = { aTimelineProtectionState() },
|
||||||
identityChangeStatePresenter = { anIdentityChangeState() },
|
identityChangeStatePresenter = { anIdentityChangeState() },
|
||||||
|
historyVisibleStatePresenter = { aHistoryVisibleState() },
|
||||||
linkPresenter = { aLinkState() },
|
linkPresenter = { aLinkState() },
|
||||||
actionListPresenter = { anActionListState(eventSink = actionListEventSink) },
|
actionListPresenter = { anActionListState(eventSink = actionListEventSink) },
|
||||||
customReactionPresenter = { aCustomReactionState() },
|
customReactionPresenter = { aCustomReactionState() },
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
class FakeHistoryVisibleAcknowledgementRepository(
|
||||||
|
private val acknowledgements: MutableMap<RoomId, MutableStateFlow<Boolean>> = mutableMapOf()
|
||||||
|
) : HistoryVisibleAcknowledgementRepository {
|
||||||
|
override fun hasAcknowledged(roomId: RoomId): Flow<Boolean> {
|
||||||
|
return acknowledgements.getOrPut(roomId) {
|
||||||
|
MutableStateFlow(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) {
|
||||||
|
val flow = acknowledgements.getOrPut(roomId) {
|
||||||
|
MutableStateFlow(value)
|
||||||
|
}
|
||||||
|
flow.emit(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Create the repository with a pre-existing entry.
|
||||||
|
*/
|
||||||
|
fun withRoom(roomId: RoomId, acknowledged: Boolean = false): FakeHistoryVisibleAcknowledgementRepository {
|
||||||
|
return FakeHistoryVisibleAcknowledgementRepository(
|
||||||
|
mutableMapOf(
|
||||||
|
roomId to MutableStateFlow(acknowledged)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.historyvisible
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HistoryVisibleStatePresenterTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - not visible if feature disabled`() = runTest {
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true))
|
||||||
|
val presenter = createHistoryVisibleStatePresenter(room, enabled = false, acknowledged = false)
|
||||||
|
presenter.test {
|
||||||
|
assertThat(awaitLastSequentialItem().showAlert).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial with room shared, unencrypted`() = runTest {
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = false))
|
||||||
|
val presenter = createHistoryVisibleStatePresenter(room)
|
||||||
|
presenter.test {
|
||||||
|
assertThat(awaitLastSequentialItem().showAlert).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial with room joined, encrypted`() = runTest {
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = false))
|
||||||
|
val presenter = createHistoryVisibleStatePresenter(room)
|
||||||
|
presenter.test {
|
||||||
|
assertThat(awaitLastSequentialItem().showAlert).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial with room shared, encrypted, unacknowledged`() = runTest {
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
|
||||||
|
val presenter = createHistoryVisibleStatePresenter(room, acknowledged = false)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.showAlert).isFalse()
|
||||||
|
val nextState = awaitItem()
|
||||||
|
assertThat(nextState.showAlert).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial with room shared, encrypted, acknowledged`() = runTest {
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
|
||||||
|
val presenter = createHistoryVisibleStatePresenter(room, acknowledged = true)
|
||||||
|
presenter.test {
|
||||||
|
assertThat(awaitLastSequentialItem().showAlert).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - transition from joined + unencrypted, to shared + encrypted`() = runTest {
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.EnableKeyShareOnInvite.key to true))
|
||||||
|
val repository = FakeHistoryVisibleAcknowledgementRepository()
|
||||||
|
|
||||||
|
room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = false))
|
||||||
|
|
||||||
|
val presenter = HistoryVisibleStatePresenter(
|
||||||
|
featureFlagService,
|
||||||
|
repository,
|
||||||
|
room,
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
// emitted by the feature flag service(?)
|
||||||
|
assertThat(awaitItem().showAlert).isFalse()
|
||||||
|
|
||||||
|
// emitted state from room info assignment
|
||||||
|
assertThat(awaitItem().showAlert).isFalse()
|
||||||
|
|
||||||
|
// room is marked as encrypted
|
||||||
|
room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true))
|
||||||
|
assertThat(awaitItem().showAlert).isFalse()
|
||||||
|
|
||||||
|
// room history visibility is changed to shared
|
||||||
|
room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
|
||||||
|
assertThat(awaitItem().showAlert).isTrue()
|
||||||
|
|
||||||
|
// alert is acknowledged
|
||||||
|
repository.setAcknowledged(room.roomId, true)
|
||||||
|
assertThat(awaitItem().showAlert).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createHistoryVisibleStatePresenter(
|
||||||
|
room: JoinedRoom = FakeJoinedRoom(),
|
||||||
|
enabled: Boolean = true,
|
||||||
|
acknowledged: Boolean = false
|
||||||
|
): HistoryVisibleStatePresenter {
|
||||||
|
return HistoryVisibleStatePresenter(
|
||||||
|
room = room,
|
||||||
|
featureFlagService = FakeFeatureFlagService(mapOf("feature.enableKeyShareOnInvite" to enabled)),
|
||||||
|
repository = FakeHistoryVisibleAcknowledgementRepository.withRoom(room.roomId, acknowledged)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ class KonsistPreviewTest {
|
|||||||
"MessageComposerViewVoicePreview",
|
"MessageComposerViewVoicePreview",
|
||||||
"MessagesReactionButtonAddPreview",
|
"MessagesReactionButtonAddPreview",
|
||||||
"MessagesReactionButtonExtraPreview",
|
"MessagesReactionButtonExtraPreview",
|
||||||
|
"MessagesViewWithHistoryVisiblePreview",
|
||||||
"MessagesViewWithIdentityChangePreview",
|
"MessagesViewWithIdentityChangePreview",
|
||||||
"PendingMemberRowWithLongNamePreview",
|
"PendingMemberRowWithLongNamePreview",
|
||||||
"PinUnlockViewInAppPreview",
|
"PinUnlockViewInAppPreview",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user