Merge branch 'develop' into fix/start-voice-recording-when-permission-is-granted

This commit is contained in:
Karsten Knappe 2026-02-03 11:23:26 +01:00 committed by GitHub
commit 307e6a7cd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
215 changed files with 2436 additions and 2080 deletions

View file

@ -210,10 +210,7 @@ class MessagesPresenter(
// * History sharing is enabled,
// * The room is encrypted, and:
// * The room's history_visibility allows future users to see content.
val showSharedHistoryIcon = isKeyShareOnInviteEnabled &&
roomInfo.isEncrypted == true &&
(roomInfo.historyVisibility == RoomHistoryVisibility.Shared ||
roomInfo.historyVisibility == RoomHistoryVisibility.WorldReadable)
val topBarSharedHistoryIcon = if (isKeyShareOnInviteEnabled) roomInfo.sharedHistoryIcon() else SharedHistoryIcon.NONE
LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) {
if (roomInfo.isEncrypted == true) {
@ -297,12 +294,24 @@ class MessagesPresenter(
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
showSharedHistoryIcon = showSharedHistoryIcon,
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom,
eventSink = ::handleEvent,
)
}
private fun RoomInfo.sharedHistoryIcon(): SharedHistoryIcon {
if (isEncrypted == true) {
if (historyVisibility == RoomHistoryVisibility.Shared) {
return SharedHistoryIcon.SHARED
} else if (historyVisibility == RoomHistoryVisibility.WorldReadable) {
return SharedHistoryIcon.WORLD_READABLE
}
}
return SharedHistoryIcon.NONE
}
private fun RoomInfo.avatarData(): AvatarData {
return AvatarData(
id = id.value,

View file

@ -54,10 +54,22 @@ data class MessagesState(
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val roomMemberModerationState: RoomMemberModerationState,
/** Should the top bar include the "history" icon? */
val showSharedHistoryIcon: Boolean,
/** Type of "shared history" icon to show in the top bar. */
val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
}
/** Type of "shared history" icon to show in the top bar. */
enum class SharedHistoryIcon {
/** Show no icon at all. */
NONE,
/** history_visibility: shared. */
SHARED,
/** history_visibility: world_readable. */
WORLD_READABLE
}

View file

@ -120,7 +120,7 @@ fun aMessagesState(
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
dmUserVerificationState: IdentityState? = null,
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
showSharedHistoryIcon: Boolean = false,
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
successorRoom: SuccessorRoom? = null,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
@ -148,7 +148,7 @@ fun aMessagesState(
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
showSharedHistoryIcon = showSharedHistoryIcon,
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = successorRoom,
eventSink = eventSink,
)

View file

@ -225,7 +225,7 @@ fun MessagesView(
heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
showSharedHistoryIcon = state.showSharedHistoryIcon,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -29,6 +30,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesState
import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
@ -44,11 +46,11 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -67,22 +69,19 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ContributesNode(RoomScope::class)
@AssistedInject
class ThreadedMessagesNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
timelinePresenterFactory: TimelinePresenter.Factory,
presenterFactory: MessagesPresenter.Factory,
actionListPresenterFactory: ActionListPresenter.Factory,
private val messageComposerPresenterFactory: MessageComposerPresenter.Factory,
private val timelinePresenterFactory: TimelinePresenter.Factory,
private val presenterFactory: MessagesPresenter.Factory,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
@ -96,20 +95,29 @@ class ThreadedMessagesNode(
private val inputs = inputs<Inputs>()
private val callback: Callback = callback()
// TODO use a loading state node to preload this instead of using `runBlocking`
private val threadedTimeline = runBlocking { room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() }
private val timelineController = TimelineController(room, threadedTimeline)
private val presenter = presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
// TODO add special processor for threaded timeline
actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
timelineMode = timelineController.mainTimelineMode(),
),
timelineController = timelineController,
)
private var timelineController: TimelineController? by mutableStateOf(null)
private var presenter: Presenter<MessagesState>? by mutableStateOf(null)
/**
* This should be fast to load, but not faster than several UI frames, which will cause ANRs.
* We'll load the [presenter] in an async way to prevent this.
*/
private suspend fun createPresenter(): Presenter<MessagesState> {
val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow()
val timelineController = TimelineController(room, threadedTimeline)
this.timelineController = timelineController
return presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
// TODO add special processor for threaded timeline
actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
timelineMode = timelineController.mainTimelineMode(),
),
timelineController = timelineController,
)
}
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
@ -130,7 +138,10 @@ class ThreadedMessagesNode(
super.onBuilt()
lifecycle.subscribe(
onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
analyticsService.capture(room.toAnalyticsViewRoom())
lifecycleScope.launch {
presenter = createPresenter()
}
},
onStart = {
appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId)
@ -231,56 +242,61 @@ class ThreadedMessagesNode(
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val state = presenter.present()
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft)
else -> Unit
}
}
MessagesView(
state = state,
onBackClick = this::navigateUp,
onRoomDetailsClick = {},
onEventContentClick = { isLive, event ->
if (isLive) {
callback.handleEventClick(timelineController.mainTimelineMode(), event)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
} else {
false
}
// Only display the actual UI and lifecycle logic if the presenter is loaded
presenter?.present()?.let { state ->
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft)
else -> Unit
}
},
onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab ->
onLinkClick(
activity = activity,
darkTheme = isDark,
url = url,
eventSink = state.timelineState.eventSink,
customTab = customTab,
)
},
onSendLocationClick = callback::navigateToSendLocation,
onCreatePollClick = callback::navigateToCreatePoll,
onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
)
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null
MessagesView(
state = state,
onBackClick = this::navigateUp,
onRoomDetailsClick = {},
onEventContentClick = { isLive, event ->
timelineController?.let { controller ->
if (isLive) {
callback.handleEventClick(controller.mainTimelineMode(), event)
} else {
val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
} else {
false
}
}
} == true
},
onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab ->
onLinkClick(
activity = activity,
darkTheme = isDark,
url = url,
eventSink = state.timelineState.eventSink,
customTab = customTab,
)
},
onSendLocationClick = callback::navigateToSendLocation,
onCreatePollClick = callback::navigateToCreatePoll,
onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
)
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null
}
}
}
}

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.SharedHistoryIcon
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
@ -63,7 +64,7 @@ internal fun MessagesViewTopBar(
heroes: ImmutableList<AvatarData>,
roomCallState: RoomCallState,
dmUserIdentityState: IdentityState?,
showSharedHistoryIcon: Boolean,
sharedHistoryIcon: SharedHistoryIcon,
onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit,
onBackClick: () -> Unit,
@ -110,12 +111,18 @@ internal fun MessagesViewTopBar(
else -> Unit
}
if (showSharedHistoryIcon) {
Icon(
when (sharedHistoryIcon) {
SharedHistoryIcon.NONE -> Unit
SharedHistoryIcon.SHARED -> Icon(
imageVector = CompoundIcons.History(),
tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_shared_history),
)
SharedHistoryIcon.WORLD_READABLE -> Icon(
imageVector = CompoundIcons.UserProfileSolid(),
tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_world_readable_history),
)
}
}
},
@ -178,7 +185,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
heroes: ImmutableList<AvatarData> = persistentListOf(),
roomCallState: RoomCallState = RoomCallState.Unavailable,
dmUserIdentityState: IdentityState? = null,
showSharedHistoryIcon: Boolean = false,
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
) = MessagesViewTopBar(
roomName = roomName,
roomAvatar = roomAvatar,
@ -186,7 +193,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
heroes = heroes,
roomCallState = roomCallState,
dmUserIdentityState = dmUserIdentityState,
showSharedHistoryIcon = showSharedHistoryIcon,
sharedHistoryIcon = sharedHistoryIcon,
onRoomDetailsClick = {},
onJoinCallClick = {},
onBackClick = {},
@ -223,7 +230,12 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
AMessagesViewTopBar(
roomName = "A DM with shared history",
dmUserIdentityState = IdentityState.Verified,
showSharedHistoryIcon = true,
sharedHistoryIcon = SharedHistoryIcon.SHARED,
)
HorizontalDivider()
AMessagesViewTopBar(
roomName = "A room with world_readable history",
sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE,
)
}
}

View file

@ -31,7 +31,7 @@
<string name="screen_report_content_explanation">"Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu."</string>
<string name="screen_report_content_hint">"Sellest sisust teatamise põhjus"</string>
<string name="screen_room_attachment_source_camera">"Kaamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Tee pilt"</string>
<string name="screen_room_attachment_source_camera_photo">"Pildista"</string>
<string name="screen_room_attachment_source_camera_video">"Salvesta video"</string>
<string name="screen_room_attachment_source_files">"Manus"</string>
<string name="screen_room_attachment_source_gallery">"Fotode ja videote galerii"</string>

View file

@ -1217,7 +1217,7 @@ class MessagesPresenterTest {
}
@Test
fun `present - shows a "history" icon if the room is encrypted and history is shared`() = runTest {
fun `present - shows a history icon if the room is encrypted and history is shared`() = runTest {
val presenter = createMessagesPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
@ -1233,7 +1233,28 @@ class MessagesPresenterTest {
awaitItem()
runCurrent()
val state = awaitItem()
assertThat(state.showSharedHistoryIcon).isTrue()
assertThat(state.topBarSharedHistoryIcon).isEqualTo(SharedHistoryIcon.SHARED)
}
}
@Test
fun `present - shows a "world_readable" icon if the room is encrypted and history is world_readable`() = runTest {
val presenter = createMessagesPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(isEncrypted = true, historyVisibility = RoomHistoryVisibility.WorldReadable),
),
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.EnableKeyShareOnInvite.key to true)
)
)
presenter.testWithLifecycleOwner {
awaitItem()
runCurrent()
val state = awaitItem()
assertThat(state.topBarSharedHistoryIcon).isEqualTo(SharedHistoryIcon.WORLD_READABLE)
}
}

View file

@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.messagecomposer.suggestions.Roo
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -22,7 +23,10 @@ import org.junit.Test
class DefaultRoomAliasSuggestionsDataSourceTest {
@Test
fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest {
val roomListService = FakeRoomListService()
val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val sut = DefaultRoomAliasSuggestionsDataSource(
roomListService
)
@ -31,7 +35,7 @@ class DefaultRoomAliasSuggestionsDataSourceTest {
)
sut.getAllRoomAliasSuggestions().test {
assertThat(awaitItem()).isEmpty()
roomListService.postAllRooms(
roomList.summaries.emit(
listOf(
aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null),
aRoomSummaryWithAnAlias,