change (composer suggestions) : remove feature flag

This commit is contained in:
ganfra 2025-03-26 21:46:23 +01:00
parent 48c7e46e47
commit bfe1aa7edf
5 changed files with 106 additions and 77 deletions

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -84,11 +85,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -135,7 +138,6 @@ class MessageComposerPresenter @AssistedInject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var showTextFormatting: Boolean by mutableStateOf(false) internal var showTextFormatting: Boolean by mutableStateOf(false)
@OptIn(FlowPreview::class)
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
override fun present(): MessageComposerState { override fun present(): MessageComposerState {
@ -148,12 +150,6 @@ class MessageComposerPresenter @AssistedInject constructor(
richTextEditorState.isReadyToProcessActions = true richTextEditorState.isReadyToProcessActions = true
} }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
var isMentionsEnabled by remember { mutableStateOf(false) }
var isRoomAliasSuggestionsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
isRoomAliasSuggestionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomAliasSuggestions)
}
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
@ -187,8 +183,6 @@ class MessageComposerPresenter @AssistedInject constructor(
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList())
LaunchedEffect(cameraPermissionState.permissionGranted) { LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) { if (cameraPermissionState.permissionGranted) {
when (pendingEvent) { when (pendingEvent) {
@ -201,35 +195,7 @@ class MessageComposerPresenter @AssistedInject constructor(
} }
val suggestions = remember { mutableStateListOf<ResolvedSuggestion>() } val suggestions = remember { mutableStateListOf<ResolvedSuggestion>() }
LaunchedEffect(isMentionsEnabled) { ResolveSuggestionsEffect(suggestions)
if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean {
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
return !room.isDm() && userCanSendAtRoom
}
// This will trigger a search immediately when `@` is typed
val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() }
// This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work
val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() }
merge(mentionStartTrigger, mentionCompletionTrigger)
.combine(room.membersStateFlow) { suggestion, roomMembersState ->
suggestions.clear()
val result = suggestionsProcessor.process(
suggestion = suggestion,
roomMembersState = roomMembersState,
roomAliasSuggestions = if (isRoomAliasSuggestionsEnabled) roomAliasSuggestions else emptyList(),
currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention,
)
if (result.isNotEmpty()) {
suggestions.addAll(result)
}
}
.collect()
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
// Declare that the user is not typing anymore when the composer is disposed // Declare that the user is not typing anymore when the composer is disposed
@ -409,6 +375,45 @@ class MessageComposerPresenter @AssistedInject constructor(
) )
} }
@OptIn(FlowPreview::class)
@Composable
private fun ResolveSuggestionsEffect(
suggestions: SnapshotStateList<ResolvedSuggestion>,
) {
LaunchedEffect(Unit) {
val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean {
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
return !room.isDm() && userCanSendAtRoom
}
// This will trigger a search immediately when `@` is typed
val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() }
// This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work
val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() }
val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger)
val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource
.getAllRoomAliasSuggestions()
.stateIn(this, SharingStarted.Lazily, emptyList())
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
val result = suggestionsProcessor.process(
suggestion = suggestion,
roomMembersState = roomMembersState,
roomAliasSuggestions = roomAliasSuggestions,
currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention,
)
suggestions.clear()
suggestions.addAll(result)
}
.collect()
}
}
private fun CoroutineScope.sendMessage( private fun CoroutineScope.sendMessage(
markdownTextEditorState: MarkdownTextEditorState, markdownTextEditorState: MarkdownTextEditorState,
richTextEditorState: RichTextEditorState, richTextEditorState: RichTextEditorState,

View file

@ -61,7 +61,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4 import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -1011,16 +1010,10 @@ class MessageComposerPresenterTest {
) )
givenRoomInfo(aRoomInfo(isDirect = false)) givenRoomInfo(aRoomInfo(isDirect = false))
} }
val flagsService = FakeFeatureFlagService( val presenter = createPresenter(this, room)
mapOf(
FeatureFlags.Mentions.key to true,
)
)
val presenter = createPresenter(this, room, featureFlagService = flagsService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
// A null suggestion (no suggestion was received) returns nothing // A null suggestion (no suggestion was received) returns nothing
@ -1078,16 +1071,10 @@ class MessageComposerPresenterTest {
) )
) )
} }
val flagsService = FakeFeatureFlagService( val presenter = createPresenter(this, room)
mapOf(
FeatureFlags.Mentions.key to true,
)
)
val presenter = createPresenter(this, room, featureFlagService = flagsService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
// An empty suggestion returns the joined members that are not the current user, but not the room // An empty suggestion returns the joined members that are not the current user, but not the room
@ -1293,11 +1280,11 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitFirstItem().also { state -> skipItems(2)
awaitItem().also { state ->
assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE)
assertThat(state.textEditorState.messageHtml()).isNull() assertThat(state.textEditorState.messageHtml()).isNull()
} }
assert(loadDraftLambda) assert(loadDraftLambda)
.isCalledOnce() .isCalledOnce()
.with(value(A_ROOM_ID), value(false)) .with(value(A_ROOM_ID), value(false))
@ -1327,7 +1314,8 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitFirstItem().also { state -> skipItems(1)
awaitItem().also { state ->
assertThat(state.showTextFormatting).isTrue() assertThat(state.showTextFormatting).isTrue()
assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE)
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
@ -1360,7 +1348,8 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitFirstItem().also { state -> skipItems(2)
awaitItem().also { state ->
assertThat(state.showTextFormatting).isFalse() assertThat(state.showTextFormatting).isFalse()
assertThat(state.mode).isEqualTo(anEditMode()) assertThat(state.mode).isEqualTo(anEditMode())
assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE)
@ -1406,7 +1395,8 @@ class MessageComposerPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitFirstItem().also { state -> skipItems(2)
awaitItem().also { state ->
assertThat(state.showTextFormatting).isFalse() assertThat(state.showTextFormatting).isFalse()
assertThat(state.mode).isEqualTo(aReplyMode()) assertThat(state.mode).isEqualTo(aReplyMode())
assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE)
@ -1580,8 +1570,7 @@ class MessageComposerPresenterTest {
} }
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
// Skip 2 item if Mentions feature is enabled, else 1 skipItems(1)
skipItems(if (FeatureFlags.Mentions.defaultValue(aBuildMeta())) 2 else 1)
return awaitItem() return awaitItem()
} }
} }

View file

@ -133,7 +133,7 @@ class SuggestionsProcessorTest {
} }
@Test @Test
fun `processing Room suggestion with aliases will return a suggestion`() = runTest { fun `processing Room suggestion with aliases will return a suggestion when matching on alias`() = runTest {
val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
val result = suggestionsProcessor.process( val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("ali"), suggestion = aRoomSuggestion("ali"),
@ -171,7 +171,56 @@ class SuggestionsProcessorTest {
RoomAliasSuggestion( RoomAliasSuggestion(
roomAlias = A_ROOM_ALIAS, roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId, roomId = aRoomSummary.roomId,
roomName = aRoomSummary.info.name, roomName = "Element",
roomAvatarUrl = aRoomSummary.info.avatarUrl,
)
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Room suggestion will return a suggestion when matching on room name`() = runTest {
val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("lement"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = listOf(
RoomAliasSuggestion(
roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId,
roomName = "Element",
roomAvatarUrl = aRoomSummary.info.avatarUrl,
)
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Alias(
roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId,
roomName = "Element",
roomAvatarUrl = aRoomSummary.info.avatarUrl,
)
)
)
}
@Test
fun `processing Room suggestion will not return a suggestion when room has no name`() = runTest {
val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("lement"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = listOf(
RoomAliasSuggestion(
roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId,
roomName = null,
roomAvatarUrl = aRoomSummary.info.avatarUrl, roomAvatarUrl = aRoomSummary.info.avatarUrl,
) )
), ),

View file

@ -80,7 +80,7 @@ class DeveloperSettingsPresenterTest {
presenter.test { presenter.test {
skipItems(2) skipItems(2)
awaitItem().also { state -> awaitItem().also { state ->
val feature = state.features.first() val feature = state.features.first { !it.isEnabled }
state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled)) state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled))
} }
awaitItem().also { state -> awaitItem().also { state ->

View file

@ -54,20 +54,6 @@ enum class FeatureFlags(
defaultValue = { true }, defaultValue = { true },
isFinished = true, isFinished = true,
), ),
Mentions(
key = "feature.mentions",
title = "Mentions",
description = "Type `@` to get mention suggestions and insert them",
defaultValue = { true },
isFinished = false,
),
RoomAliasSuggestions(
key = "feature.roomAliasSuggestions",
title = "Room alias suggestions",
description = "Type `#` to get room alias suggestions and insert them",
defaultValue = { false },
isFinished = false,
),
MarkAsUnread( MarkAsUnread(
key = "feature.markAsUnread", key = "feature.markAsUnread",
title = "Mark as unread", title = "Mark as unread",