Merge branch 'develop' into feature/fga/update-rust-sdk-0.1.29-again

This commit is contained in:
ganfra 2023-07-10 15:41:42 +02:00
commit 7fabbc339d
225 changed files with 1635 additions and 815 deletions

View file

@ -8,6 +8,8 @@
<w>onboarding</w> <w>onboarding</w>
<w>placeables</w> <w>placeables</w>
<w>showkase</w> <w>showkase</w>
<w>snackbar</w>
<w>swipeable</w>
<w>textfields</w> <w>textfields</w>
</words> </words>
</dictionary> </dictionary>

View file

@ -44,14 +44,14 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Handle deep-link for notification, uncomment to be able to test deeplink with ./tools/adb/deeplink.sh --> <!-- Handle deep-link for notification ./tools/adb/deeplink.sh -->
<!--intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data <data
android:host="open" android:host="open"
android:scheme="elementx" /> android:scheme="elementx" />
</intent-filter--> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View file

@ -18,8 +18,8 @@ package io.element.android.x
import android.app.Application import android.app.Application
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
import io.element.android.x.di.AppComponent
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.info.logApplicationInfo import io.element.android.x.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer import io.element.android.x.initializer.CrashInitializer

View file

@ -35,14 +35,21 @@ class IntentProviderImpl @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val deepLinkCreator: DeepLinkCreator, private val deepLinkCreator: DeepLinkCreator,
) : IntentProvider { ) : IntentProvider {
override fun getViewIntent( override fun getViewRoomIntent(
sessionId: SessionId, sessionId: SessionId,
roomId: RoomId?, roomId: RoomId?,
threadId: ThreadId?, threadId: ThreadId?,
): Intent { ): Intent {
return Intent(context, MainActivity::class.java).apply { return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
data = deepLinkCreator.create(sessionId, roomId, threadId).toUri() data = deepLinkCreator.room(sessionId, roomId, threadId).toUri()
}
}
override fun getInviteListIntent(sessionId: SessionId): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = deepLinkCreator.inviteList(sessionId).toUri()
} }
} }
} }

View file

@ -18,9 +18,7 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -30,7 +28,6 @@ import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
@ -58,6 +55,7 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -65,6 +63,7 @@ import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -89,6 +88,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>( ) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -139,7 +139,6 @@ class LoggedInFlowNode @AssistedInject constructor(
observeAnalyticsState() observeAnalyticsState()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
syncService.startSync()
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) } plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory() val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory) Coil.setImageLoader(imageLoaderFactory)
@ -341,4 +340,13 @@ class LoggedInFlowNode @AssistedInject constructor(
PermanentChild(navTarget = NavTarget.Permanent) PermanentChild(navTarget = NavTarget.Permanent)
} }
} }
internal suspend fun attachRoom(deeplinkData: DeeplinkData.Room) {
backstack.push(NavTarget.Room(deeplinkData.roomId))
}
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) {
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
backstack.push(NavTarget.InviteList)
}
} }

View file

@ -253,13 +253,10 @@ class RootFlowNode @AssistedInject constructor(
Timber.d("Navigating to $deeplinkData") Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId) attachSession(deeplinkData.sessionId)
.apply { .apply {
val roomId = deeplinkData.roomId when (deeplinkData) {
if (roomId == null) { is DeeplinkData.Root -> attachRoot()
// In case room is not provided, ensure the app navigate back to the room list is DeeplinkData.Room -> attachRoom(deeplinkData)
attachRoot() is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
} else {
attachRoom(roomId)
// TODO .attachThread(deeplinkData.threadId)
} }
} }
} }

View file

@ -21,6 +21,8 @@ import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcIntentResolver import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject

View file

@ -59,7 +59,7 @@ allprojects {
config = files("$rootDir/tools/detekt/detekt.yml") config = files("$rootDir/tools/detekt/detekt.yml")
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.1.11") detektPlugins("io.nlopez.compose.rules:detekt:0.1.12")
} }
// KtLint // KtLint
@ -247,6 +247,7 @@ koverMerged {
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState" excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
excludes += "io.element.android.features.location.impl.map.MapState" excludes += "io.element.android.features.location.impl.map.MapState"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*" excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
} }
bound { bound {
minValue = 90 minValue = 90

1
changelog.d/821.bugfix Normal file
View file

@ -0,0 +1 @@
Truncate and ellipsize long reactions

View file

@ -38,10 +38,10 @@ import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@ -92,7 +92,7 @@ fun AddPeopleViewTopBar(
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
onNextPressed: () -> Unit = {}, onNextPressed: () -> Unit = {},
) { ) {
CenterAlignedTopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
title = { title = {
Text( Text(

View file

@ -57,10 +57,10 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersList import io.element.android.libraries.matrix.ui.components.SelectedUsersList
@ -181,7 +181,7 @@ fun ConfigureRoomToolbar(
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
onNextPressed: () -> Unit = {}, onNextPressed: () -> Unit = {},
) { ) {
CenterAlignedTopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
title = { title = {
Text( Text(

View file

@ -48,11 +48,11 @@ import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.designsystem.R as DrawableR
@ -133,7 +133,7 @@ fun CreateRoomRootViewTopBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClosePressed: () -> Unit = {}, onClosePressed: () -> Unit = {},
) { ) {
CenterAlignedTopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
title = { title = {
Text( Text(
@ -142,7 +142,7 @@ fun CreateRoomRootViewTopBar(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
}, },
actions = { navigationIcon = {
IconButton(onClick = onClosePressed) { IconButton(onClick = onClosePressed) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.libraries.push.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@ -50,6 +51,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invitelist.test) testImplementation(projects.features.invitelist.test)
testImplementation(projects.features.analytics.test) testImplementation(projects.features.analytics.test)

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -49,6 +50,7 @@ class InviteListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val store: SeenInvitesStore, private val store: SeenInvitesStore,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val notificationDrawerManager: NotificationDrawerManager,
) : Presenter<InviteListState> { ) : Presenter<InviteListState> {
@Composable @Composable
@ -138,6 +140,7 @@ class InviteListPresenter @Inject constructor(
suspend { suspend {
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.acceptInvitation().getOrThrow() it.acceptInvitation().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
} }
roomId roomId
@ -148,7 +151,9 @@ class InviteListPresenter @Inject constructor(
suspend { suspend {
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.rejectInvitation().getOrThrow() it.rejectInvitation().getOrThrow()
} ?: Unit notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
}
Unit
}.runCatchingUpdatingState(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }

View file

@ -21,10 +21,12 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.analytics.test.FakeAnalyticsService import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.invitelist.api.SeenInvitesStore
import io.element.android.features.invitelist.test.FakeSeenInvitesStore import io.element.android.features.invitelist.test.FakeSeenInvitesStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -39,6 +41,9 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -47,12 +52,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - starts empty, adds invites when received`() = runTest { fun `present - starts empty, adds invites when received`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource() val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -72,12 +73,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - uses user ID and avatar for direct invites`() = runTest { fun `present - uses user ID and avatar for direct invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -102,12 +99,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - includes sender details for room invites`() = runTest { fun `present - includes sender details for room invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -136,6 +129,7 @@ class InviteListPresenterTests {
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -155,12 +149,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - shows confirm dialog for declining room invites`() = runTest { fun `present - shows confirm dialog for declining room invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -180,12 +170,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - hides confirm dialog when cancelling`() = runTest { fun `present - hides confirm dialog when cancelling`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -205,11 +191,12 @@ class InviteListPresenterTests {
@Test @Test
fun `present - declines invite after confirming`() = runTest { fun `present - declines invite after confirming`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
val client = FakeMatrixClient( val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -225,6 +212,7 @@ class InviteListPresenterTests {
skipItems(2) skipItems(2)
Truth.assertThat(room.isInviteRejected).isTrue() Truth.assertThat(room.isInviteRejected).isTrue()
Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
} }
} }
@ -235,7 +223,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex)) room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -266,7 +254,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex)) room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -294,11 +282,12 @@ class InviteListPresenterTests {
@Test @Test
fun `present - accepts invites and sets state on success`() = runTest { fun `present - accepts invites and sets state on success`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
val client = FakeMatrixClient( val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -311,6 +300,7 @@ class InviteListPresenterTests {
Truth.assertThat(room.isInviteAccepted).isTrue() Truth.assertThat(room.isInviteAccepted).isTrue()
Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Success(A_ROOM_ID)) Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Success(A_ROOM_ID))
Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
} }
} }
@ -321,7 +311,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex)) room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -346,7 +336,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex)) room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -376,6 +366,7 @@ class InviteListPresenterTests {
), ),
store, store,
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -413,6 +404,7 @@ class InviteListPresenterTests {
), ),
store, store,
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -500,4 +492,16 @@ class InviteListPresenterTests {
unreadNotificationCount = 0, unreadNotificationCount = 0,
) )
) )
private fun createPresenter(
client: MatrixClient,
seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(),
fakeAnalyticsService: AnalyticsService = FakeAnalyticsService(),
notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager()
) = InviteListPresenter(
client,
seenInvitesStore,
fakeAnalyticsService,
notificationDrawerManager
)
} }

View file

@ -29,7 +29,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
@ -38,8 +37,8 @@ import coil.request.ImageRequest
import io.element.android.features.location.api.internal.AttributionPlacement import io.element.android.features.location.api.internal.AttributionPlacement
import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
@ -127,18 +126,9 @@ fun StaticMapView(
} }
} }
@Preview @DayNightPreviews
@Composable @Composable
fun StaticMapViewLightPreview() = fun StaticMapViewPreview() = ElementPreview {
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun StaticMapViewDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
StaticMapView( StaticMapView(
lat = 0.0, lat = 0.0,
lon = 0.0, lon = 0.0,

View file

@ -29,13 +29,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.R import io.element.android.features.location.api.R
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -83,22 +82,13 @@ internal fun StaticMapPlaceholder(
} }
} }
@Preview @DayNightPreviews
@Composable @Composable
fun StaticMapPlaceholderLightPreview( fun StaticMapPlaceholderPreview(
@PreviewParameter(BooleanParameterProvider::class) values: Boolean @PreviewParameter(BooleanParameterProvider::class) values: Boolean
) = ElementPreviewLight { ContentToPreview(values) } ) = ElementPreview {
@Preview
@Composable
fun StaticMapPlaceholderDarkPreview(
@PreviewParameter(BooleanParameterProvider::class) values: Boolean
) = ElementPreviewDark { ContentToPreview(values) }
@Composable
private fun ContentToPreview(showProgress: Boolean) {
StaticMapPlaceholder( StaticMapPlaceholder(
showProgress = showProgress, showProgress = values,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(400.dp), modifier = Modifier.size(400.dp),
onLoadMapClick = {}, onLoadMapClick = {},

View file

@ -48,9 +48,9 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DesignSystemR import io.element.android.libraries.designsystem.R as DesignSystemR
@ -91,7 +91,7 @@ fun SendLocationView(
sheetDragHandle = {}, sheetDragHandle = {},
sheetSwipeEnabled = false, sheetSwipeEnabled = false,
topBar = { topBar = {
CenterAlignedTopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = stringResource(CommonStrings.screen_share_location_title), text = stringResource(CommonStrings.screen_share_location_title),

View file

@ -39,11 +39,11 @@ import io.element.android.features.location.impl.map.rememberMapState
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.theme.compound.generated.TypographyTokens import io.element.android.libraries.theme.compound.generated.TypographyTokens
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -61,7 +61,7 @@ fun ShowLocationView(
Scaffold(modifier, Scaffold(modifier,
topBar = { topBar = {
CenterAlignedTopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = stringResource(CommonStrings.screen_view_location_title), text = stringResource(CommonStrings.screen_view_location_title),

View file

@ -42,5 +42,6 @@ Díky za trpělivost!"</string>
<string name="screen_change_server_title">"Vyberte svůj server"</string> <string name="screen_change_server_title">"Vyberte svůj server"</string>
<string name="screen_login_password_hint">"Heslo"</string> <string name="screen_login_password_hint">"Heslo"</string>
<string name="screen_login_submit">"Pokračovat"</string> <string name="screen_login_submit">"Pokračovat"</string>
<string name="screen_login_subtitle">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string>
<string name="screen_login_username_hint">"Uživatelské jméno"</string> <string name="screen_login_username_hint">"Uživatelské jméno"</string>
</resources> </resources>

View file

@ -42,5 +42,6 @@ Vielen Dank für deine Geduld!"</string>
<string name="screen_change_server_title">"Wählen deinen Server"</string> <string name="screen_change_server_title">"Wählen deinen Server"</string>
<string name="screen_login_password_hint">"Passwort"</string> <string name="screen_login_password_hint">"Passwort"</string>
<string name="screen_login_submit">"Weiter"</string> <string name="screen_login_submit">"Weiter"</string>
<string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation"</string>
<string name="screen_login_username_hint">"Benutzername"</string> <string name="screen_login_username_hint">"Benutzername"</string>
</resources> </resources>

View file

@ -42,5 +42,6 @@ Vă mulțumim pentru răbdare!"</string>
<string name="screen_change_server_title">"Selectați serverul"</string> <string name="screen_change_server_title">"Selectați serverul"</string>
<string name="screen_login_password_hint">"Parola"</string> <string name="screen_login_password_hint">"Parola"</string>
<string name="screen_login_submit">"Continuați"</string> <string name="screen_login_submit">"Continuați"</string>
<string name="screen_login_subtitle">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string>
<string name="screen_login_username_hint">"Utilizator"</string> <string name="screen_login_username_hint">"Utilizator"</string>
</resources> </resources>

View file

@ -42,5 +42,6 @@
<string name="screen_change_server_title">"Vyberte svoj server"</string> <string name="screen_change_server_title">"Vyberte svoj server"</string>
<string name="screen_login_password_hint">"Heslo"</string> <string name="screen_login_password_hint">"Heslo"</string>
<string name="screen_login_submit">"Pokračovať"</string> <string name="screen_login_submit">"Pokračovať"</string>
<string name="screen_login_subtitle">"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string>
<string name="screen_login_username_hint">"Používateľské meno"</string> <string name="screen_login_username_hint">"Používateľské meno"</string>
</resources> </resources>

View file

@ -54,7 +54,6 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.RadioButton import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -62,6 +61,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -111,7 +111,7 @@ fun ForwardMessagesView(
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
CenterAlignedTopAppBar( TopAppBar(
title = { Text(stringResource(CommonStrings.common_forward_message), style = ElementTextStyles.Bold.callout) }, title = { Text(stringResource(CommonStrings.common_forward_message), style = ElementTextStyles.Bold.callout) },
navigationIcon = { navigationIcon = {
BackButton(onClick = { onBackButton(state) }) BackButton(onClick = { onBackButton(state) })

View file

@ -50,10 +50,10 @@ import io.element.android.libraries.designsystem.components.button.ButtonWithPro
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@ -83,7 +83,7 @@ fun ReportMessageView(
Scaffold( Scaffold(
topBar = { topBar = {
CenterAlignedTopAppBar( TopAppBar(
title = { title = {
Text( Text(
stringResource(CommonStrings.action_report_content), stringResource(CommonStrings.action_report_content),

View file

@ -44,6 +44,7 @@ import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -132,7 +133,7 @@ private fun IconContent(
) )
@Composable @Composable
fun ReactionContent( private fun ReactionContent(
reaction: AggregatedReaction, reaction: AggregatedReaction,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) = Row( ) = Row(
@ -140,7 +141,7 @@ fun ReactionContent(
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = reaction.key, text = reaction.displayKey,
fontSize = 15.sp, lineHeight = reactionEmojiLineHeight fontSize = 15.sp, lineHeight = reactionEmojiLineHeight
) )
if (reaction.count > 1) { if (reaction.count > 1) {
@ -148,7 +149,7 @@ fun ReactionContent(
Text( Text(
text = reaction.count.toString(), text = reaction.count.toString(),
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
fontSize = 14.sp fontSize = 14.sp,
) )
} }
} }
@ -174,6 +175,12 @@ internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
content = MessagesReactionsButtonContent.Text("12 more"), content = MessagesReactionsButtonContent.Text("12 more"),
onClick = {} onClick = {}
) )
MessagesReactionButton(
content = MessagesReactionsButtonContent.Reaction(aTimelineItemReactions().reactions.first().copy(
key = "A very long reaction with many characters that should be truncated"
)),
onClick = {}
)
} }
} }

View file

@ -14,13 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -35,26 +36,25 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DismissDirection
import androidx.compose.material3.DismissState
import androidx.compose.material3.DismissValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.rememberDismissState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -78,6 +79,9 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -93,7 +97,10 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import org.jsoup.Jsoup import org.jsoup.Jsoup
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
@ -110,6 +117,7 @@ fun TimelineItemEventRow(
onSwipeToReply: () -> Unit, onSwipeToReply: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val coroutineScope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
fun onUserDataClicked() { fun onUserDataClicked() {
@ -121,56 +129,88 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId) inReplyToClick(inReplyToEventId)
} }
if (canReply) { Column(modifier = modifier.fillMaxWidth()) {
val dismissState = rememberDismissState( if (event.groupPosition.isNew()) {
confirmValueChange = { Spacer(modifier = Modifier.height(16.dp))
if (it == DismissValue.DismissedToEnd) { } else {
onSwipeToReply() Spacer(modifier = Modifier.height(2.dp))
}
if (canReply) {
val state: SwipeableActionsState = rememberSwipeableActionsState()
val offset = state.offset.value
val swipeThresholdPx = 40.dp.toPx()
val thresholdCrossed = abs(offset) > swipeThresholdPx
SwipeSensitivity(3f) {
Box(Modifier.fillMaxWidth()) {
Row(modifier = Modifier.matchParentSize()) {
ReplySwipeIndicator({ offset / 120 })
}
TimelineItemEventRowContent(
modifier = Modifier
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
.draggable(
orientation = Orientation.Horizontal,
enabled = !state.isResettingOnRelease,
onDragStopped = {
coroutineScope.launch {
if (thresholdCrossed) {
onSwipeToReply()
}
state.resetOffset()
}
},
state = state.draggableState,
),
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
} }
// Do not dismiss the message, return false!
false
} }
) } else {
SwipeToDismiss( TimelineItemEventRowContent(
state = dismissState, event = event,
background = { isHighlighted = isHighlighted,
ReplySwipeIndicator({ dismissState.toSwipeProgress() }) interactionSource = interactionSource,
}, onClick = onClick,
directions = setOf(DismissDirection.StartToEnd), onLongClick = onLongClick,
dismissContent = { onTimestampClicked = onTimestampClicked,
TimelineItemEventRowContent( inReplyToClicked = ::inReplyToClicked,
event = event, onUserDataClicked = ::onUserDataClicked,
isHighlighted = isHighlighted, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
interactionSource = interactionSource, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onClick = onClick, )
onLongClick = onLongClick, }
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
}
)
} else {
TimelineItemEventRowContent(
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
} }
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage. }
if (event.groupPosition.isNew()) {
Spacer(modifier = modifier.height(16.dp)) /**
} else { * Impact ViewConfiguration.touchSlop by [sensitivityFactor].
Spacer(modifier = modifier.height(2.dp)) * Inspired from https://issuetracker.google.com/u/1/issues/269627294.
* @param sensitivityFactor the factor to multiply the touchSlop by. The highest value, the more the user will
* have to drag to start the drag.
* @param content the content to display.
*/
@Composable
fun SwipeSensitivity(
sensitivityFactor: Float,
content: @Composable () -> Unit,
) {
val current = LocalViewConfiguration.current
CompositionLocalProvider(
LocalViewConfiguration provides object : ViewConfiguration by current {
override val touchSlop: Float
get() = current.touchSlop * sensitivityFactor
}
) {
content()
} }
} }
@ -266,14 +306,6 @@ private fun TimelineItemEventRowContent(
} }
} }
private fun DismissState.toSwipeProgress(): Float {
return when (targetValue) {
DismissValue.Default -> 0f
DismissValue.DismissedToEnd -> progress * 3
DismissValue.DismissedToStart -> progress * 3
}
}
@Composable @Composable
private fun MessageSenderInformation( private fun MessageSenderInformation(
sender: String, sender: String,
@ -544,6 +576,7 @@ private fun ContentToPreview() {
body = "A long text which will be displayed on several lines and" + body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors." " hopefully can be manually adjusted to test different behaviors."
), ),
groupPosition = TimelineItemGroupPosition.First,
), ),
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
@ -562,6 +595,7 @@ private fun ContentToPreview() {
content = aTimelineItemImageContent().copy( content = aTimelineItemImageContent().copy(
aspectRatio = 5f aspectRatio = 5f
), ),
groupPosition = TimelineItemGroupPosition.Last,
), ),
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
@ -606,7 +640,8 @@ private fun ContentToPreviewWithReply() {
body = "A long text which will be displayed on several lines and" + body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors." " hopefully can be manually adjusted to test different behaviors."
), ),
inReplyTo = aInReplyToReady(replyContent) inReplyTo = aInReplyToReady(replyContent),
groupPosition = TimelineItemGroupPosition.First,
), ),
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
@ -625,7 +660,8 @@ private fun ContentToPreviewWithReply() {
content = aTimelineItemImageContent().copy( content = aTimelineItemImageContent().copy(
aspectRatio = 5f aspectRatio = 5f
), ),
inReplyTo = aInReplyToReady(replyContent) inReplyTo = aInReplyToReady(replyContent),
groupPosition = TimelineItemGroupPosition.Last,
), ),
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
@ -699,7 +735,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
} }
} }
@Preview @Preview
@Composable @Composable
internal fun TimelineItemEventRowWithManyReactionsLightPreview() = internal fun TimelineItemEventRowWithManyReactionsLightPreview() =

View file

@ -16,8 +16,18 @@
package io.element.android.features.messages.impl.timeline.model package io.element.android.features.messages.impl.timeline.model
import io.element.android.libraries.core.extensions.ellipsize
/** /**
* @property key the reaction key (e.g. "👍") * Length at which we ellipsize a reaction key for display
*
* Reactions can be free text, so we need to limit the length
* displayed on screen.
*/
private const val MAX_DISPLAY_CHARS = 16
/**
* @property key the full reaction key (e.g. "👍", "YES!")
* @property count the number of users who reacted with this key * @property count the number of users who reacted with this key
* @property isHighlighted true if the reaction has (also) been sent by the current user. * @property isHighlighted true if the reaction has (also) been sent by the current user.
*/ */
@ -25,4 +35,14 @@ data class AggregatedReaction(
val key: String, val key: String,
val count: Int, val count: Int,
val isHighlighted: Boolean = false val isHighlighted: Boolean = false
) ) {
/**
* The key to be displayed on screen.
*
* See [MAX_DISPLAY_CHARS].
*/
val displayKey: String by lazy {
key.ellipsize(MAX_DISPLAY_CHARS)
}
}

View file

@ -10,11 +10,14 @@
<string name="screen_room_attachment_source_files">"Anhang"</string> <string name="screen_room_attachment_source_files">"Anhang"</string>
<string name="screen_room_attachment_source_gallery">"Foto- &amp; Video-Bibliothek"</string> <string name="screen_room_attachment_source_gallery">"Foto- &amp; Video-Bibliothek"</string>
<string name="screen_room_attachment_source_location">"Standort"</string> <string name="screen_room_attachment_source_location">"Standort"</string>
<string name="screen_room_encrypted_history_banner">"Der Nachrichtenverlauf ist in diesem Raum derzeit nicht verfügbar"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Benutzerdetails konnten nicht abgerufen werden"</string> <string name="screen_room_error_failed_retrieving_user_details">"Benutzerdetails konnten nicht abgerufen werden"</string>
<string name="screen_room_invite_again_alert_message">"Möchtest du sie wieder einladen?"</string> <string name="screen_room_invite_again_alert_message">"Möchtest du sie wieder einladen?"</string>
<string name="screen_room_invite_again_alert_title">"Du bist allein in diesem Chat"</string> <string name="screen_room_invite_again_alert_title">"Du bist allein in diesem Chat"</string>
<string name="screen_room_message_copied">"Nachricht kopiert"</string> <string name="screen_room_message_copied">"Nachricht kopiert"</string>
<string name="screen_room_no_permission_to_post">"Du bist keine Berechtigung, um in diesem Raum zu posten"</string> <string name="screen_room_no_permission_to_post">"Du bist keine Berechtigung, um in diesem Raum zu posten"</string>
<string name="screen_room_reactions_show_less">"Weniger anzeigen"</string>
<string name="screen_room_reactions_show_more">"Mehr anzeigen"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Erneut senden"</string> <string name="screen_room_retry_send_menu_send_again_action">"Erneut senden"</string>
<string name="screen_room_retry_send_menu_title">"Ihre Nachricht konnte nicht gesendet werden"</string> <string name="screen_room_retry_send_menu_title">"Ihre Nachricht konnte nicht gesendet werden"</string>
<string name="screen_room_error_failed_processing_media">"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut."</string> <string name="screen_room_error_failed_processing_media">"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut."</string>

View file

@ -11,11 +11,14 @@
<string name="screen_room_attachment_source_files">"Príloha"</string> <string name="screen_room_attachment_source_files">"Príloha"</string>
<string name="screen_room_attachment_source_gallery">"Knižnica fotografií a videí"</string> <string name="screen_room_attachment_source_gallery">"Knižnica fotografií a videí"</string>
<string name="screen_room_attachment_source_location">"Poloha"</string> <string name="screen_room_attachment_source_location">"Poloha"</string>
<string name="screen_room_encrypted_history_banner">"História správ v tejto miestnosti nie je momentálne k dispozícii"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nepodarilo sa získať údaje o používateľovi"</string> <string name="screen_room_error_failed_retrieving_user_details">"Nepodarilo sa získať údaje o používateľovi"</string>
<string name="screen_room_invite_again_alert_message">"Chceli by ste ich pozvať späť?"</string> <string name="screen_room_invite_again_alert_message">"Chceli by ste ich pozvať späť?"</string>
<string name="screen_room_invite_again_alert_title">"V tomto rozhovore ste sami"</string> <string name="screen_room_invite_again_alert_title">"V tomto rozhovore ste sami"</string>
<string name="screen_room_message_copied">"Správa skopírovaná"</string> <string name="screen_room_message_copied">"Správa skopírovaná"</string>
<string name="screen_room_no_permission_to_post">"Nemáte povolenie uverejňovať príspevky v tejto miestnosti"</string> <string name="screen_room_no_permission_to_post">"Nemáte povolenie uverejňovať príspevky v tejto miestnosti"</string>
<string name="screen_room_reactions_show_less">"Zobraziť menej"</string>
<string name="screen_room_reactions_show_more">"Zobraziť viac"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Odoslať znova"</string> <string name="screen_room_retry_send_menu_send_again_action">"Odoslať znova"</string>
<string name="screen_room_retry_send_menu_title">"Vašu správu sa nepodarilo odoslať"</string> <string name="screen_room_retry_send_menu_title">"Vašu správu sa nepodarilo odoslať"</string>
<string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string> <string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>

View file

@ -4,11 +4,9 @@
<item quantity="one">"%1$d room change"</item> <item quantity="one">"%1$d room change"</item>
<item quantity="other">"%1$d room changes"</item> <item quantity="other">"%1$d room changes"</item>
</plurals> </plurals>
<string name="screen_room_timeline_less_reactions">"Show less"</string>
<plurals name="screen_room_timeline_more_reactions"> <plurals name="screen_room_timeline_more_reactions">
<item quantity="other">"%1$d more"</item> <item quantity="other">"%1$d more"</item>
</plurals> </plurals>
<string name="screen_room_timeline_add_reaction">"Add emoji"</string>
<string name="screen_room_attachment_source_camera">"Camera"</string> <string name="screen_room_attachment_source_camera">"Camera"</string>
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string> <string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
<string name="screen_room_attachment_source_camera_video">"Record a video"</string> <string name="screen_room_attachment_source_camera_video">"Record a video"</string>
@ -25,6 +23,8 @@
<string name="screen_room_reactions_show_more">"Show more"</string> <string name="screen_room_reactions_show_more">"Show more"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string> <string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string> <string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
<string name="screen_room_timeline_add_reaction">"Add emoji"</string>
<string name="screen_room_timeline_less_reactions">"Show less"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string> <string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_retry_send_menu_remove_action">"Remove"</string> <string name="screen_room_retry_send_menu_remove_action">"Remove"</string>
</resources> </resources>

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.timeline.model
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import org.junit.Assert.assertEquals
import org.junit.Test
class AggregatedReactionTest {
@Test
fun `reaction display key is shortened`() {
val reaction = AggregatedReaction(
key = "1234567890123456790",
count = 1,
isHighlighted = false
)
assertEquals("1234567890123456…", reaction.displayKey)
}
}

View file

@ -28,6 +28,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.logout.api.LogoutPreferencePresenter import io.element.android.features.logout.api.LogoutPreferencePresenter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.user.getCurrentUser
@ -43,6 +45,7 @@ class PreferencesRootPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val buildType: BuildType, private val buildType: BuildType,
private val versionFormatter: VersionFormatter, private val versionFormatter: VersionFormatter,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<PreferencesRootState> { ) : Presenter<PreferencesRootState> {
@Composable @Composable
@ -54,6 +57,8 @@ class PreferencesRootPresenter @Inject constructor(
initialLoad(matrixUser) initialLoad(matrixUser)
} }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
// Session verification status (unknown, not verified, verified) // Session verification status (unknown, not verified, verified)
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState() val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
val sessionIsNotVerified by remember { val sessionIsNotVerified by remember {
@ -67,7 +72,8 @@ class PreferencesRootPresenter @Inject constructor(
myUser = matrixUser.value, myUser = matrixUser.value,
version = versionFormatter.get(), version = versionFormatter.get(),
showCompleteVerification = sessionIsNotVerified, showCompleteVerification = sessionIsNotVerified,
showDeveloperSettings = showDeveloperSettings showDeveloperSettings = showDeveloperSettings,
snackbarMessage = snackbarMessage,
) )
} }

View file

@ -17,6 +17,7 @@
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.LogoutPreferenceState import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
data class PreferencesRootState( data class PreferencesRootState(
@ -24,5 +25,6 @@ data class PreferencesRootState(
val myUser: MatrixUser?, val myUser: MatrixUser?,
val version: String, val version: String,
val showCompleteVerification: Boolean, val showCompleteVerification: Boolean,
val showDeveloperSettings: Boolean val showDeveloperSettings: Boolean,
val snackbarMessage: SnackbarMessage?,
) )

View file

@ -17,11 +17,14 @@
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.aLogoutPreferenceState import io.element.android.features.logout.api.aLogoutPreferenceState
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.ui.strings.CommonStrings
fun aPreferencesRootState() = PreferencesRootState( fun aPreferencesRootState() = PreferencesRootState(
logoutState = aLogoutPreferenceState(), logoutState = aLogoutPreferenceState(),
myUser = null, myUser = null,
version = "Version 1.1 (1)", version = "Version 1.1 (1)",
showCompleteVerification = true, showCompleteVerification = true,
showDeveloperSettings = true showDeveloperSettings = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
) )

View file

@ -24,6 +24,8 @@ import androidx.compose.material.icons.outlined.DeveloperMode
import androidx.compose.material.icons.outlined.Help import androidx.compose.material.icons.outlined.Help
import androidx.compose.material.icons.outlined.InsertChart import androidx.compose.material.icons.outlined.InsertChart
import androidx.compose.material.icons.outlined.VerifiedUser import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -39,6 +41,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.LargeHeightPreview import io.element.android.libraries.designsystem.preview.LargeHeightPreview
import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
@ -55,11 +58,20 @@ fun PreferencesRootView(
onOpenDeveloperSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
// Include pref from other modules // Include pref from other modules
PreferenceView( PreferenceView(
modifier = modifier, modifier = modifier,
onBackPressed = onBackPressed, onBackPressed = onBackPressed,
title = stringResource(id = CommonStrings.common_settings) title = stringResource(id = CommonStrings.common_settings),
snackbarHost = {
SnackbarHost(snackbarHostState) { data ->
Snackbar(
snackbarData = data,
)
}
}
) { ) {
UserPreferences(state.myUser) UserPreferences(state.myUser)
if (state.showCompleteVerification) { if (state.showCompleteVerification) {

View file

@ -23,6 +23,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.impl.DefaultLogoutPreferencePresenter import io.element.android.features.logout.impl.DefaultLogoutPreferencePresenter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
@ -41,7 +42,8 @@ class PreferencesRootPresenterTest {
matrixClient, matrixClient,
FakeSessionVerificationService(), FakeSessionVerificationService(),
BuildType.DEBUG, BuildType.DEBUG,
FakeVersionFormatter() FakeVersionFormatter(),
SnackbarDispatcher(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()

View file

@ -22,7 +22,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomPresenter
@ -55,14 +55,14 @@ class RoomDetailsPresenter @Inject constructor(
val canEditTopic by getCanSendStateEvent(membersState, StateEventType.ROOM_TOPIC) val canEditTopic by getCanSendStateEvent(membersState, StateEventType.ROOM_TOPIC)
val dmMember by room.getDirectRoomMember(membersState) val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType = getRoomType(dmMember) val roomType by getRoomType(dmMember)
val topicState = remember(canEditTopic, room.topic) { val topicState = remember(canEditTopic, room.topic, roomType) {
val topic = room.topic val topic = room.topic
when { when {
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
canEditTopic -> RoomTopicState.CanAddTopic canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic
else -> RoomTopicState.Hidden else -> RoomTopicState.Hidden
} }
} }
@ -85,8 +85,8 @@ class RoomDetailsPresenter @Inject constructor(
memberCount = room.joinedMemberCount, memberCount = room.joinedMemberCount,
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
canInvite = canInvite, canInvite = canInvite,
canEdit = canEditAvatar || canEditName || canEditTopic, canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
roomType = roomType.value, roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
@ -112,20 +112,12 @@ class RoomDetailsPresenter @Inject constructor(
} }
@Composable @Composable
private fun getCanInvite(membersState: MatrixRoomMembersState): State<Boolean> { private fun getCanInvite(membersState: MatrixRoomMembersState) = produceState(false, membersState) {
val canInvite = remember(membersState) { mutableStateOf(false) } value = room.canInvite().getOrElse { false }
LaunchedEffect(membersState) {
canInvite.value = room.canInvite().getOrElse { false }
}
return canInvite
} }
@Composable @Composable
private fun getCanSendStateEvent(membersState: MatrixRoomMembersState, type: StateEventType): State<Boolean> { private fun getCanSendStateEvent(membersState: MatrixRoomMembersState, type: StateEventType) = produceState(false, membersState) {
val canSendEvent = remember(membersState) { mutableStateOf(false) } value = room.canSendStateEvent(type).getOrElse { false }
LaunchedEffect(membersState) {
canSendEvent.value = room.canSendStateEvent(type).getOrElse { false }
}
return canSendEvent
} }
} }

View file

@ -68,11 +68,11 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -102,7 +102,7 @@ fun RoomDetailsEditView(
Scaffold( Scaffold(
modifier = modifier.clearFocusOnTap(focusManager), modifier = modifier.clearFocusOnTap(focusManager),
topBar = { topBar = {
CenterAlignedTopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = stringResource(id = R.string.screen_room_details_edit_room_title), text = stringResource(id = R.string.screen_room_details_edit_room_title),

View file

@ -39,13 +39,13 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRow import io.element.android.libraries.matrix.ui.components.CheckableUserRow
@ -117,7 +117,7 @@ fun RoomInviteMembersTopBar(
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
onSendPressed: () -> Unit = {}, onSendPressed: () -> Unit = {},
) { ) {
CenterAlignedTopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
title = { title = {
Text( Text(

View file

@ -49,13 +49,13 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -203,7 +203,7 @@ private fun RoomMemberListTopBar(
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
onInvitePressed: () -> Unit = {}, onInvitePressed: () -> Unit = {},
) { ) {
CenterAlignedTopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
title = { title = {
Text( Text(

View file

@ -13,7 +13,7 @@
<string name="screen_room_details_encryption_enabled_subtitle">"Nachrichten sind mit Schlössern gesichert. Nur du und der Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."</string> <string name="screen_room_details_encryption_enabled_subtitle">"Nachrichten sind mit Schlössern gesichert. Nur du und der Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."</string>
<string name="screen_room_details_encryption_enabled_title">"Nachrichtenverschlüsselung aktiviert"</string> <string name="screen_room_details_encryption_enabled_title">"Nachrichtenverschlüsselung aktiviert"</string>
<string name="screen_room_details_invite_people_title">"Personen einladen"</string> <string name="screen_room_details_invite_people_title">"Personen einladen"</string>
<string name="screen_room_details_notification_title">"Benachrichtigung"</string> <string name="screen_room_details_notification_title">"Benachrichtigungen"</string>
<string name="screen_room_details_room_name_label">"Raumname"</string> <string name="screen_room_details_room_name_label">"Raumname"</string>
<string name="screen_room_details_share_room_title">"Raum teilen"</string> <string name="screen_room_details_share_room_title">"Raum teilen"</string>
<string name="screen_room_details_updating_room">"Aktualisiere Raum…"</string> <string name="screen_room_details_updating_room">"Aktualisiere Raum…"</string>

View file

@ -14,7 +14,7 @@
<string name="screen_room_details_encryption_enabled_subtitle">"Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie."</string> <string name="screen_room_details_encryption_enabled_subtitle">"Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie."</string>
<string name="screen_room_details_encryption_enabled_title">"Šifrovanie správ je zapnuté"</string> <string name="screen_room_details_encryption_enabled_title">"Šifrovanie správ je zapnuté"</string>
<string name="screen_room_details_invite_people_title">"Pozvať ľudí"</string> <string name="screen_room_details_invite_people_title">"Pozvať ľudí"</string>
<string name="screen_room_details_notification_title">"Oznámenie"</string> <string name="screen_room_details_notification_title">"Oznámenia"</string>
<string name="screen_room_details_room_name_label">"Názov miestnosti"</string> <string name="screen_room_details_room_name_label">"Názov miestnosti"</string>
<string name="screen_room_details_share_room_title">"Zdieľať miestnosť"</string> <string name="screen_room_details_share_room_title">"Zdieľať miestnosť"</string>
<string name="screen_room_details_updating_room">"Aktualizácia miestnosti…"</string> <string name="screen_room_details_updating_room">"Aktualizácia miestnosti…"</string>

View file

@ -20,7 +20,10 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.RoomTopicState import io.element.android.features.roomdetails.impl.RoomTopicState
@ -44,13 +47,13 @@ import org.junit.Test
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomDetailsPresenterTests { class RoomDetailsPresenterTests {
private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter { private fun aRoomDetailsPresenter(room: MatrixRoom, leaveRoomPresenter: LeaveRoomPresenter = LeaveRoomPresenterFake()): RoomDetailsPresenter {
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMemberId) return RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMemberId)
} }
} }
return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, LeaveRoomPresenterFake()) return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, leaveRoomPresenter)
} }
@Test @Test
@ -173,6 +176,64 @@ class RoomDetailsPresenterTests {
} }
} }
@Test
fun `present - initial state when user can edit attributes in a DM`() = runTest {
val myRoomMember = aRoomMember(A_SESSION_ID)
val otherRoomMember = aRoomMember(A_USER_ID_2)
val room = aMatrixRoom(
isEncrypted = true,
isDirect = true,
).apply {
val roomMembers = listOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true))
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanInviteResult(Result.success(false))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes, but editing is still disallowed because it's a DM
val settledState = awaitItem()
assertThat(settledState.canEdit).isFalse()
// If there is a topic, it's visible
assertThat(settledState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state when in a DM with no topic`() = runTest {
val myRoomMember = aRoomMember(A_SESSION_ID)
val otherRoomMember = aRoomMember(A_USER_ID_2)
val room = aMatrixRoom(
isEncrypted = true,
isDirect = true,
topic = null,
).apply {
val roomMembers = listOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
// There's no topic, so we hide the entire UI for DMs
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - initial state when user can edit all attributes`() = runTest { fun `present - initial state when user can edit all attributes`() = runTest {
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
@ -247,6 +308,22 @@ class RoomDetailsPresenterTests {
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test
fun `present - leave room event is passed on to leave room presenter`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake()
val room = aMatrixRoom()
val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom)
assertThat(leaveRoomPresenter.events).contains(LeaveRoomEvent.ShowConfirmation(room.roomId))
cancelAndIgnoreRemainingEvents()
}
}
} }
fun aMatrixRoom( fun aMatrixRoom(

View file

@ -231,8 +231,6 @@ fun RoomListContent(
SnackbarHost(snackbarHostState) { data -> SnackbarHost(snackbarHostState) { data ->
Snackbar( Snackbar(
snackbarData = data, snackbarData = data,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.primary
) )
} }
}, },

View file

@ -2,6 +2,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_roomlist_a11y_create_message">"Vytvorte novú konverzáciu alebo miestnosť"</string> <string name="screen_roomlist_a11y_create_message">"Vytvorte novú konverzáciu alebo miestnosť"</string>
<string name="screen_roomlist_main_space_title">"Všetky konverzácie"</string> <string name="screen_roomlist_main_space_title">"Všetky konverzácie"</string>
<string name="session_verification_banner_message">"Vyzerá to tak, že používate nové zariadenie. Overte, či ste to vy, aby ste mali prístup k zašifrovaným správam."</string> <string name="session_verification_banner_message">"Vyzerá to tak, že používate nové zariadenie. Overte svoj prístup k zašifrovaným správam pomocou vášho druhého zariadenia."</string>
<string name="session_verification_banner_title">"Získajte prístup k histórii vašich správ"</string> <string name="session_verification_banner_title">"Overte, že ste to vy"</string>
</resources> </resources>

View file

@ -152,7 +152,6 @@ sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extension
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite:2.3.1" sqlite = "androidx.sqlite:sqlite:2.3.1"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
gujun_span = "me.gujun.android:span:1.7"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"

View file

@ -58,6 +58,21 @@ fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String {
} }
} }
/**
* Truncate and ellipsize text if it exceeds the given length.
*
* Throws if length is < 1.
*/
fun String.ellipsize(length: Int): String {
require(length > 1)
if (this.length <= length) {
return this
}
return "${this.take(length)}"
}
inline fun <reified R> Any?.takeAs(): R? { inline fun <reified R> Any?.takeAs(): R? {
return takeIf { it is R } as R? return takeIf { it is R } as R?
} }

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.core.extensions
import org.junit.Assert.assertEquals
import org.junit.Test
class BasicExtensionsTest {
@Test(expected = IllegalArgumentException::class)
fun `test ellipsize at 0`() {
"1234567890".ellipsize(0)
}
@Test
fun `test ellipsize at 1`() {
assertEquals(
"1…",
"1234567890".ellipsize(1)
)
}
@Test
fun `test ellipsize at 5`() {
val output = "1234567890".ellipsize(5)
assertEquals("12345…", output)
}
@Test
fun `test ellipsize noop 1`() {
val input = "12345"
val output = input.ellipsize(5)
assertEquals(input, output)
}
@Test
fun `test ellipsize noop 2`() {
val input = "123"
val output = input.ellipsize(5)
assertEquals(input, output)
}
}

View file

@ -18,3 +18,7 @@ package io.element.android.libraries.deeplink
internal const val SCHEME = "elementx" internal const val SCHEME = "elementx"
internal const val HOST = "open" internal const val HOST = "open"
object DeepLinkPaths {
const val INVITE_LIST = "invites"
}

View file

@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import javax.inject.Inject import javax.inject.Inject
class DeepLinkCreator @Inject constructor() { class DeepLinkCreator @Inject constructor() {
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String { fun room(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
return buildString { return buildString {
append("$SCHEME://$HOST/") append("$SCHEME://$HOST/")
append(sessionId.value) append(sessionId.value)
@ -36,4 +36,13 @@ class DeepLinkCreator @Inject constructor() {
} }
} }
} }
fun inviteList(sessionId: SessionId): String {
return buildString {
append("$SCHEME://$HOST/")
append(sessionId.value)
append("/")
append(DeepLinkPaths.INVITE_LIST)
}
}
} }

View file

@ -20,8 +20,16 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
data class DeeplinkData( sealed interface DeeplinkData {
val sessionId: SessionId, /** Session id is common for all deep links. */
val roomId: RoomId? = null, val sessionId: SessionId
val threadId: ThreadId? = null,
) /** The target is the root of the app, with the given [sessionId]. */
data class Root(override val sessionId: SessionId) : DeeplinkData
/** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId]. */
data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?) : DeeplinkData
/** The target is the invites list, with the given [sessionId]. */
data class InviteList(override val sessionId: SessionId) : DeeplinkData
}

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.deeplink
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -36,12 +37,21 @@ class DeeplinkParser @Inject constructor() {
if (host != HOST) return null if (host != HOST) return null
val pathBits = path.orEmpty().split("/").drop(1) val pathBits = path.orEmpty().split("/").drop(1)
val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null
val roomId = pathBits.elementAtOrNull(1)?.let(::RoomId) val screenPathComponent = pathBits.elementAtOrNull(1)
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId) val roomId = tryOrNull { screenPathComponent?.let(::RoomId) }
return DeeplinkData(
sessionId = sessionId, return when {
roomId = roomId, roomId != null -> {
threadId = threadId, val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId)
) DeeplinkData.Room(sessionId, roomId, threadId)
}
screenPathComponent == DeepLinkPaths.INVITE_LIST -> {
DeeplinkData.InviteList(sessionId)
}
screenPathComponent == null -> {
DeeplinkData.Root(sessionId)
}
else -> null
}
} }
} }

View file

@ -25,13 +25,20 @@ import org.junit.Test
class DeepLinkCreatorTest { class DeepLinkCreatorTest {
@Test @Test
fun create() { fun room() {
val sut = DeepLinkCreator() val sut = DeepLinkCreator()
assertThat(sut.create(A_SESSION_ID, null, null)) assertThat(sut.room(A_SESSION_ID, null, null))
.isEqualTo("elementx://open/@alice:server.org") .isEqualTo("elementx://open/@alice:server.org")
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null)) assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, null))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain") .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain")
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)) assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
} }
@Test
fun inviteList() {
val sut = DeepLinkCreator()
assertThat(sut.inviteList(A_SESSION_ID))
.isEqualTo("elementx://open/@alice:server.org/invites")
}
} }

View file

@ -36,6 +36,8 @@ class DeeplinkParserTest {
"elementx://open/@alice:server.org/!aRoomId:domain" "elementx://open/@alice:server.org/!aRoomId:domain"
const val A_URI_WITH_ROOM_WITH_THREAD = const val A_URI_WITH_ROOM_WITH_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId" "elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
const val A_URI_FOR_INVITE_LIST =
"elementx://open/@alice:server.org/invites"
} }
private val sut = DeeplinkParser() private val sut = DeeplinkParser()
@ -43,11 +45,13 @@ class DeeplinkParserTest {
@Test @Test
fun `nominal cases`() { fun `nominal cases`() {
assertThat(sut.getFromIntent(createIntent(A_URI))) assertThat(sut.getFromIntent(createIntent(A_URI)))
.isEqualTo(DeeplinkData(A_SESSION_ID, null, null)) .isEqualTo(DeeplinkData.Root(A_SESSION_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM))) assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM)))
.isEqualTo(DeeplinkData(A_SESSION_ID, A_ROOM_ID, null)) .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD))) assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD)))
.isEqualTo(DeeplinkData(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)) .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_FOR_INVITE_LIST)))
.isEqualTo(DeeplinkData.InviteList(A_SESSION_ID))
} }
@Test @Test

View file

@ -27,6 +27,13 @@ android {
buildConfig = true buildConfig = true
} }
buildTypes {
getByName("release") {
isMinifyEnabled = true
consumerProguardFiles("proguard-rules.pro")
}
}
dependencies { dependencies {
api(projects.libraries.theme) api(projects.libraries.theme)
// Should not be there, but this is a POC // Should not be there, but this is a POC

View file

@ -18,4 +18,6 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keep class io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModuleCodegen { }

View file

@ -50,6 +50,7 @@ fun PreferenceView(
title: String, title: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Scaffold( Scaffold(
@ -64,6 +65,7 @@ fun PreferenceView(
onBackPressed = onBackPressed, onBackPressed = onBackPressed,
) )
}, },
snackbarHost = snackbarHost,
content = { content = {
Column( Column(
modifier = Modifier modifier = Modifier

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.swipe
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* Inspired from https://github.com/bmarty/swipe/blob/trunk/swipe/src/main/kotlin/me/saket/swipe/SwipeableActionsState.kt
*/
@Composable
fun rememberSwipeableActionsState(): SwipeableActionsState {
return remember { SwipeableActionsState() }
}
@Stable
class SwipeableActionsState {
/**
* The current position (in pixels) of the content.
*/
val offset: State<Float> get() = offsetState
private var offsetState = mutableStateOf(0f)
/**
* Whether the content is currently animating to reset its offset after it was swiped.
*/
var isResettingOnRelease: Boolean by mutableStateOf(false)
private set
val draggableState = DraggableState { delta ->
val targetOffset = offsetState.value + delta
val isAllowed = isResettingOnRelease || targetOffset > 0f
offsetState.value += if (isAllowed) delta else 0f
}
suspend fun resetOffset() {
draggableState.drag(MutatePriority.PreventUserInput) {
isResettingOnRelease = true
try {
Animatable(offsetState.value).animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 300),
) {
dragBy(value - offsetState.value)
}
} finally {
isResettingOnRelease = false
}
}
}
}

View file

@ -1,62 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CenterAlignedTopAppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
androidx.compose.material3.CenterAlignedTopAppBar(
title = title,
modifier = modifier,
navigationIcon = navigationIcon,
actions = actions,
windowInsets = windowInsets,
colors = colors,
scrollBehavior = scrollBehavior,
)
}
@Preview(group = PreviewGroup.AppBars)
@Composable
internal fun CenterAlignedTopAppBarPreview() =
ElementThemedPreview { ContentToPreview() }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContentToPreview() {
CenterAlignedTopAppBar(title = { Text(text = "Title") })
}

View file

@ -25,7 +25,6 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -69,14 +68,13 @@ fun SnackbarDispatcher.collectSnackbarMessageAsState(): State<SnackbarMessage?>
@Composable @Composable
fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState { fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val snackbarMessageText = snackbarMessage?.let { val snackbarMessageText = snackbarMessage?.let {
stringResource(id = snackbarMessage.messageResId) stringResource(id = snackbarMessage.messageResId)
} }
val dispatcher = LocalSnackbarDispatcher.current val dispatcher = LocalSnackbarDispatcher.current
LaunchedEffect(snackbarMessage) { LaunchedEffect(snackbarMessage) {
if (snackbarMessageText == null) return@LaunchedEffect if (snackbarMessageText == null) return@LaunchedEffect
coroutineScope.launch { launch {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
message = snackbarMessageText, message = snackbarMessageText,
duration = snackbarMessage.duration, duration = snackbarMessage.duration,

View file

@ -19,6 +19,8 @@ package io.element.android.libraries.matrix.api.notification
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
data class NotificationData( data class NotificationData(
val senderId: UserId, val senderId: UserId,
@ -36,7 +38,60 @@ data class NotificationData(
data class NotificationEvent( data class NotificationEvent(
val timestamp: Long, val timestamp: Long,
val content: String, val content: NotificationContent,
// For images for instance // For images for instance
val contentUrl: String? val contentUrl: String?
) )
sealed interface NotificationContent {
sealed interface MessageLike : NotificationContent {
object CallAnswer : MessageLike
object CallInvite : MessageLike
object CallHangup : MessageLike
object CallCandidates : MessageLike
object KeyVerificationReady : MessageLike
object KeyVerificationStart : MessageLike
object KeyVerificationCancel : MessageLike
object KeyVerificationAccept : MessageLike
object KeyVerificationKey : MessageLike
object KeyVerificationMac : MessageLike
object KeyVerificationDone : MessageLike
data class ReactionContent(
val relatedEventId: String
) : MessageLike
object RoomEncrypted : MessageLike
data class RoomMessage(
val messageType: MessageType
) : MessageLike
object RoomRedaction : MessageLike
object Sticker : MessageLike
}
sealed interface StateEvent : NotificationContent {
object PolicyRuleRoom : StateEvent
object PolicyRuleServer : StateEvent
object PolicyRuleUser : StateEvent
object RoomAliases : StateEvent
object RoomAvatar : StateEvent
object RoomCanonicalAlias : StateEvent
object RoomCreate : StateEvent
object RoomEncryption : StateEvent
object RoomGuestAccess : StateEvent
object RoomHistoryVisibility : StateEvent
object RoomJoinRules : StateEvent
data class RoomMemberContent(
val userId: String,
val membershipState: RoomMembershipState
) : StateEvent
object RoomName : StateEvent
object RoomPinnedEvents : StateEvent
object RoomPowerLevels : StateEvent
object RoomServerAcl : StateEvent
object RoomThirdPartyInvite : StateEvent
object RoomTombstone : StateEvent
object RoomTopic : StateEvent
object SpaceChild : StateEvent
object SpaceParent : StateEvent
}
}

View file

@ -28,19 +28,19 @@ class NotificationMapper {
private val timelineEventMapper = TimelineEventMapper() private val timelineEventMapper = TimelineEventMapper()
fun map(notificationItem: NotificationItem): NotificationData { fun map(notificationItem: NotificationItem): NotificationData {
return notificationItem.use { return notificationItem.use { item ->
NotificationData( NotificationData(
senderId = UserId(it.event.senderId()), senderId = UserId(item.event.senderId()),
eventId = EventId(it.event.eventId()), eventId = EventId(item.event.eventId()),
roomId = RoomId(it.roomInfo.id), roomId = RoomId(item.roomInfo.id),
senderAvatarUrl = it.senderInfo.avatarUrl, senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = it.senderInfo.displayName, senderDisplayName = item.senderInfo.displayName,
roomAvatarUrl = it.roomInfo.avatarUrl, roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { item.roomInfo.isDirect },
roomDisplayName = it.roomInfo.displayName, roomDisplayName = item.roomInfo.displayName,
isDirect = it.roomInfo.isDirect, isDirect = item.roomInfo.isDirect,
isEncrypted = it.roomInfo.isEncrypted.orFalse(), isEncrypted = item.roomInfo.isEncrypted.orFalse(),
isNoisy = it.isNoisy, isNoisy = item.isNoisy,
event = it.event.use { event -> timelineEventMapper.map(event) } event = item.event.use { event -> timelineEventMapper.map(event) }
) )
} }
} }

View file

@ -36,7 +36,8 @@ class RustNotificationService(
filterByPushRules: Boolean, filterByPushRules: Boolean,
): Result<NotificationData?> { ): Result<NotificationData?> {
return runCatching { return runCatching {
client.getNotificationItem(roomId.value, eventId.value, filterByPushRules)?.use(notificationMapper::map) val item = client.getNotificationItem(roomId.value, eventId.value, filterByPushRules)
item?.use(notificationMapper::map)
} }
} }
} }

View file

@ -16,9 +16,11 @@
package io.element.android.libraries.matrix.impl.notification package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationEvent import io.element.android.libraries.matrix.api.notification.NotificationEvent
import io.element.android.libraries.matrix.impl.room.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
import org.matrix.rustcomponents.sdk.MessageLikeEventContent import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.StateEventContent import org.matrix.rustcomponents.sdk.StateEventContent
import org.matrix.rustcomponents.sdk.TimelineEvent import org.matrix.rustcomponents.sdk.TimelineEvent
import org.matrix.rustcomponents.sdk.TimelineEventType import org.matrix.rustcomponents.sdk.TimelineEventType
@ -38,71 +40,62 @@ class TimelineEventMapper @Inject constructor() {
} }
} }
private fun TimelineEventType.toContent(): String { private fun TimelineEventType.toContent(): NotificationContent {
return when (this) { return when (this) {
is TimelineEventType.MessageLike -> content.toContent() is TimelineEventType.MessageLike -> content.toContent()
is TimelineEventType.State -> content.toContent() is TimelineEventType.State -> content.toContent()
} }
} }
private fun StateEventContent.toContent(): String { private fun StateEventContent.toContent(): NotificationContent.StateEvent {
return when (this) { return when (this) {
StateEventContent.PolicyRuleRoom -> "PolicyRuleRoom" StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
StateEventContent.PolicyRuleServer -> "PolicyRuleServer" StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
StateEventContent.PolicyRuleUser -> "PolicyRuleUser" StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
StateEventContent.RoomAliases -> "RoomAliases" StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
StateEventContent.RoomAvatar -> "RoomAvatar" StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
StateEventContent.RoomCanonicalAlias -> "RoomCanonicalAlias" StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
StateEventContent.RoomCreate -> "RoomCreate" StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate
StateEventContent.RoomEncryption -> "RoomEncryption" StateEventContent.RoomEncryption -> NotificationContent.StateEvent.RoomEncryption
StateEventContent.RoomGuestAccess -> "RoomGuestAccess" StateEventContent.RoomGuestAccess -> NotificationContent.StateEvent.RoomGuestAccess
StateEventContent.RoomHistoryVisibility -> "RoomHistoryVisibility" StateEventContent.RoomHistoryVisibility -> NotificationContent.StateEvent.RoomHistoryVisibility
StateEventContent.RoomJoinRules -> "RoomJoinRules" StateEventContent.RoomJoinRules -> NotificationContent.StateEvent.RoomJoinRules
is StateEventContent.RoomMemberContent -> "$userId is now $membershipState" is StateEventContent.RoomMemberContent -> {
StateEventContent.RoomName -> "RoomName" NotificationContent.StateEvent.RoomMemberContent(userId, RoomMemberMapper.mapMembership(membershipState))
StateEventContent.RoomPinnedEvents -> "RoomPinnedEvents" }
StateEventContent.RoomPowerLevels -> "RoomPowerLevels" StateEventContent.RoomName -> NotificationContent.StateEvent.RoomName
StateEventContent.RoomServerAcl -> "RoomServerAcl" StateEventContent.RoomPinnedEvents -> NotificationContent.StateEvent.RoomPinnedEvents
StateEventContent.RoomThirdPartyInvite -> "RoomThirdPartyInvite" StateEventContent.RoomPowerLevels -> NotificationContent.StateEvent.RoomPowerLevels
StateEventContent.RoomTombstone -> "RoomTombstone" StateEventContent.RoomServerAcl -> NotificationContent.StateEvent.RoomServerAcl
StateEventContent.RoomTopic -> "RoomTopic" StateEventContent.RoomThirdPartyInvite -> NotificationContent.StateEvent.RoomThirdPartyInvite
StateEventContent.SpaceChild -> "SpaceChild" StateEventContent.RoomTombstone -> NotificationContent.StateEvent.RoomTombstone
StateEventContent.SpaceParent -> "SpaceParent" StateEventContent.RoomTopic -> NotificationContent.StateEvent.RoomTopic
StateEventContent.SpaceChild -> NotificationContent.StateEvent.SpaceChild
StateEventContent.SpaceParent -> NotificationContent.StateEvent.SpaceParent
} }
} }
private fun MessageLikeEventContent.toContent(): String { private fun MessageLikeEventContent.toContent(): NotificationContent.MessageLike {
return use { return use {
when (it) { when (it) {
MessageLikeEventContent.CallAnswer -> "CallAnswer" MessageLikeEventContent.CallAnswer -> NotificationContent.MessageLike.CallAnswer
MessageLikeEventContent.CallCandidates -> "CallCandidates" MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates
MessageLikeEventContent.CallHangup -> "CallHangup" MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup
MessageLikeEventContent.CallInvite -> "CallInvite" MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite
MessageLikeEventContent.KeyVerificationAccept -> "KeyVerificationAccept" MessageLikeEventContent.KeyVerificationAccept -> NotificationContent.MessageLike.KeyVerificationAccept
MessageLikeEventContent.KeyVerificationCancel -> "KeyVerificationCancel" MessageLikeEventContent.KeyVerificationCancel -> NotificationContent.MessageLike.KeyVerificationCancel
MessageLikeEventContent.KeyVerificationDone -> "KeyVerificationDone" MessageLikeEventContent.KeyVerificationDone -> NotificationContent.MessageLike.KeyVerificationDone
MessageLikeEventContent.KeyVerificationKey -> "KeyVerificationKey" MessageLikeEventContent.KeyVerificationKey -> NotificationContent.MessageLike.KeyVerificationKey
MessageLikeEventContent.KeyVerificationMac -> "KeyVerificationMac" MessageLikeEventContent.KeyVerificationMac -> NotificationContent.MessageLike.KeyVerificationMac
MessageLikeEventContent.KeyVerificationReady -> "KeyVerificationReady" MessageLikeEventContent.KeyVerificationReady -> NotificationContent.MessageLike.KeyVerificationReady
MessageLikeEventContent.KeyVerificationStart -> "KeyVerificationStart" MessageLikeEventContent.KeyVerificationStart -> NotificationContent.MessageLike.KeyVerificationStart
is MessageLikeEventContent.ReactionContent -> "Reacted to ${it.relatedEventId.take(8)}" is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(it.relatedEventId)
MessageLikeEventContent.RoomEncrypted -> "RoomEncrypted" MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
is MessageLikeEventContent.RoomMessage -> it.messageType.toContent() is MessageLikeEventContent.RoomMessage -> {
MessageLikeEventContent.RoomRedaction -> "RoomRedaction" NotificationContent.MessageLike.RoomMessage(EventMessageMapper().mapMessageType(it.messageType))
MessageLikeEventContent.Sticker -> "Sticker" }
MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction
MessageLikeEventContent.Sticker -> NotificationContent.MessageLike.Sticker
} }
} }
} }
private fun MessageType.toContent(): String {
return when (this) {
is MessageType.Audio -> content.use { it.body }
is MessageType.Emote -> content.body
is MessageType.File -> content.use { it.body }
is MessageType.Image -> content.use { it.body }
is MessageType.Location -> content.body
is MessageType.Notice -> content.body
is MessageType.Text -> content.body
is MessageType.Video -> content.use { it.body }
}
}

View file

@ -24,23 +24,30 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.RoomListServiceState
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
class RustSyncService( class RustSyncService(
private val roomListService: RoomListService, private val roomListService: RoomListService,
sessionCoroutineScope: CoroutineScope sessionCoroutineScope: CoroutineScope
) : SyncService { ) : SyncService {
private val isSyncing = AtomicBoolean(false)
override fun startSync() = runCatching { override fun startSync() = runCatching {
if (!roomListService.isSyncing()) { if (isSyncing.compareAndSet(false, true)) {
Timber.v("Start sync")
roomListService.sync() roomListService.sync()
} }
} }
override fun stopSync() = runCatching { override fun stopSync() = runCatching {
if (roomListService.isSyncing()) { if (isSyncing.compareAndSet(true, false)) {
Timber.v("Stop sync")
roomListService.stopSync() roomListService.stopSync()
} }
} }
@ -49,6 +56,10 @@ class RustSyncService(
roomListService roomListService
.stateFlow() .stateFlow()
.map(RoomListServiceState::toSyncState) .map(RoomListServiceState::toSyncState)
.onEach { state ->
Timber.v("Sync state=$state")
isSyncing.set(state == SyncState.Syncing)
}
.distinctUntilChanged() .distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.WhileSubscribed(), SyncState.Idle) .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
} }

View file

@ -33,48 +33,17 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessag
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import org.matrix.rustcomponents.sdk.Message import org.matrix.rustcomponents.sdk.Message
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.ProfileDetails import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.RepliedToEventDetails import org.matrix.rustcomponents.sdk.RepliedToEventDetails
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
import org.matrix.rustcomponents.sdk.MessageType as RustMessageType
class EventMessageMapper { class EventMessageMapper {
fun map(message: Message): MessageContent = message.use { fun map(message: Message): MessageContent = message.use {
val type = it.msgtype().use { type -> val type = it.msgtype().use(this::mapMessageType)
when (type) {
is MessageType.Audio -> {
AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is MessageType.File -> {
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is MessageType.Image -> {
ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is MessageType.Location -> {
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
}
is MessageType.Notice -> {
NoticeMessageType(type.content.body, type.content.formatted?.map())
}
is MessageType.Text -> {
TextMessageType(type.content.body, type.content.formatted?.map())
}
is MessageType.Emote -> {
EmoteMessageType(type.content.body, type.content.formatted?.map())
}
is MessageType.Video -> {
VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
null -> {
UnknownMessageType
}
}
}
val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId) val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId)
val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details -> val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details ->
when (details) { when (details) {
@ -99,6 +68,34 @@ class EventMessageMapper {
type = type type = type
) )
} }
fun mapMessageType(type: RustMessageType?) = when (type) {
is RustMessageType.Audio -> {
AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is RustMessageType.File -> {
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is RustMessageType.Image -> {
ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is RustMessageType.Notice -> {
NoticeMessageType(type.content.body, type.content.formatted?.map())
}
is RustMessageType.Text -> {
TextMessageType(type.content.body, type.content.formatted?.map())
}
is RustMessageType.Emote -> {
EmoteMessageType(type.content.body, type.content.formatted?.map())
}
is RustMessageType.Video -> {
VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is RustMessageType.Location -> {
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
}
null -> UnknownMessageType
}
} }
private fun RustFormattedBody.map(): FormattedBody = FormattedBody( private fun RustFormattedBody.map(): FormattedBody = FormattedBody(

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.api.notifications
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface NotificationDrawerManager {
fun clearMembershipNotificationForSession(sessionId: SessionId)
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId)
}

View file

@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
api(projects.libraries.pushproviders.api) api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api) api(projects.libraries.pushstore.api)
api(projects.libraries.push.api) api(projects.libraries.push.api)
@ -52,10 +53,6 @@ dependencies {
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
api(libs.gujun.span) {
exclude(group = "com.android.support", module = "support-annotations")
}
// TODO Temporary use the deprecated LocalBroadcastManager, to be changed later. // TODO Temporary use the deprecated LocalBroadcastManager, to be changed later.
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")

View file

@ -20,8 +20,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
@ -29,13 +28,13 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushService @Inject constructor( class DefaultPushService @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager, private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
private val pushersManager: PushersManager, private val pushersManager: PushersManager,
private val userPushStoreFactory: UserPushStoreFactory, private val userPushStoreFactory: UserPushStoreFactory,
private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, private val pushProviders: Set<@JvmSuppressWildcards PushProvider>,
) : PushService { ) : PushService {
override fun notificationStyleChanged() { override fun notificationStyleChanged() {
notificationDrawerManager.notificationStyleChanged() defaultNotificationDrawerManager.notificationStyleChanged()
} }
override fun getAvailablePushProviders(): List<PushProvider> { override fun getAvailablePushProviders(): List<PushProvider> {

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
@Module
@ContributesTo(AppScope::class)
abstract class PushBindsModule {
@Binds
abstract fun bindNotificationDrawerManager(
defaultNotificationDrawerManager: DefaultNotificationDrawerManager
): NotificationDrawerManager
}

View file

@ -23,11 +23,16 @@ import io.element.android.libraries.matrix.api.core.ThreadId
interface IntentProvider { interface IntentProvider {
/** /**
* Provide an intent to start the application. * Provide an intent to start the application on a room or thread.
*/ */
fun getViewIntent( fun getViewRoomIntent(
sessionId: SessionId, sessionId: SessionId,
roomId: RoomId?, roomId: RoomId?,
threadId: ThreadId?, threadId: ThreadId?,
): Intent ): Intent
/**
* Provide an intent to start the application on the invite list.
*/
fun getInviteListIntent(sessionId: SessionId): Intent
} }

View file

@ -24,14 +24,16 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -47,7 +49,7 @@ import javax.inject.Inject
* Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning.
*/ */
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class NotificationDrawerManager @Inject constructor( class DefaultNotificationDrawerManager @Inject constructor(
private val pushDataStore: PushDataStore, private val pushDataStore: PushDataStore,
private val notifiableEventProcessor: NotifiableEventProcessor, private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
@ -58,7 +60,7 @@ class NotificationDrawerManager @Inject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService, private val matrixAuthenticationService: MatrixAuthenticationService,
) { ) : NotificationDrawerManager {
/** /**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
*/ */
@ -152,12 +154,27 @@ class NotificationDrawerManager @Inject constructor(
} }
} }
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
updateEvents {
it.clearMembershipNotificationForSession(sessionId)
}
}
/** /**
* Clear invitation notification for the provided room. * Clear invitation notification for the provided room.
*/ */
fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
updateEvents { updateEvents {
it.clearMemberShipNotificationForRoom(sessionId, roomId) it.clearMembershipNotificationForRoom(sessionId, roomId)
}
}
/**
* Clear the notifications for a single event.
*/
fun clearEvent(eventId: EventId) {
updateEvents {
it.clearEvent(eventId)
} }
} }
@ -183,7 +200,7 @@ class NotificationDrawerManager @Inject constructor(
} }
} }
private fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) { private fun updateEvents(action: DefaultNotificationDrawerManager.(NotificationEventQueue) -> Unit) {
notificationState.updateQueuedEvents(this) { queuedEvents, _ -> notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
action(queuedEvents) action(queuedEvents)
} }
@ -260,6 +277,6 @@ class NotificationDrawerManager @Inject constructor(
} }
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean { fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
return resolvedEvent.shouldIgnoreMessageEventInRoom(currentAppNavigationState) return resolvedEvent.shouldIgnoreEventInRoom(currentAppNavigationState)
} }
} }

View file

@ -17,11 +17,12 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -41,7 +42,7 @@ class NotifiableEventProcessor @Inject constructor(
val type = when (it) { val type = when (it) {
is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
is NotifiableMessageEvent -> when { is NotifiableMessageEvent -> when {
it.shouldIgnoreMessageEventInRoom(appNavigationState) -> { it.shouldIgnoreEventInRoom(appNavigationState) -> {
ProcessedEvent.Type.REMOVE ProcessedEvent.Type.REMOVE
.also { Timber.d("notification message removed due to currently viewing the same room or thread") } .also { Timber.d("notification message removed due to currently viewing the same room or thread") }
} }
@ -53,6 +54,13 @@ class NotifiableEventProcessor @Inject constructor(
EventType.REDACTION -> ProcessedEvent.Type.REMOVE EventType.REDACTION -> ProcessedEvent.Type.REMOVE
else -> ProcessedEvent.Type.KEEP else -> ProcessedEvent.Type.KEEP
} }
is FallbackNotifiableEvent -> when {
it.shouldIgnoreEventInRoom(appNavigationState) -> {
ProcessedEvent.Type.REMOVE
.also { Timber.d("notification fallback removed due to currently viewing the same room or thread") }
}
else -> ProcessedEvent.Type.KEEP
}
} }
ProcessedEvent(type, it) ProcessedEvent(type, it)
} }

View file

@ -22,12 +22,26 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationEvent import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import timber.log.Timber import timber.log.Timber
@ -53,73 +67,163 @@ class NotifiableEventResolver @Inject constructor(
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
// Restore session // Restore session
val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null
// TODO EAx, no need for a session? val notificationService = session.notificationService()
val notificationData = session.let {// TODO Use make the app crashes val notificationData = notificationService.getNotification(
it.notificationService().getNotification(
userId = sessionId, userId = sessionId,
roomId = roomId, roomId = roomId,
eventId = eventId, eventId = eventId,
filterByPushRules = true, // FIXME should be true in the future, but right now it's broken
) // (https://github.com/vector-im/element-x-android/issues/640#issuecomment-1612913658)
}.fold( filterByPushRules = false,
{ ).onFailure {
it Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.")
}, }.getOrNull()
{
Timber.tag(loggerTag.value).e(it, "Unable to resolve event.") // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
null return notificationData?.asNotifiableEvent(sessionId)
?: fallbackNotifiableEvent(sessionId, roomId, eventId)
}
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? {
return when (val content = this.event.content) {
is NotificationContent.MessageLike.RoomMessage -> {
buildNotifiableMessageEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
noisy = isNoisy,
timestamp = event.timestamp,
senderName = senderDisplayName,
senderId = senderId.value,
body = descriptionFromMessageContent(content),
imageUriString = event.contentUrl,
roomName = roomDisplayName,
roomIsDirect = isDirect,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
)
} }
).orDefault(roomId, eventId) is NotificationContent.StateEvent.RoomMemberContent -> {
if (content.membershipState == RoomMembershipState.INVITE) {
return notificationData.asNotifiableEvent(sessionId) InviteNotifiableEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
editedEventId = null,
canBeReplaced = true,
roomName = roomDisplayName,
noisy = isNoisy,
timestamp = event.timestamp,
soundName = null,
isRedacted = false,
isUpdated = false,
description = descriptionFromRoomMembershipContent(content, isDirect) ?: return null,
type = null, // TODO check if type is needed anymore
title = null, // TODO check if title is needed anymore
)
} else {
null
}
}
else -> null
}
} }
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent { private fun fallbackNotifiableEvent(
return NotifiableMessageEvent( userId: SessionId,
sessionId = userId, roomId: RoomId,
roomId = roomId, eventId: EventId
eventId = eventId, ) = FallbackNotifiableEvent(
editedEventId = null, sessionId = userId,
canBeReplaced = true, roomId = roomId,
noisy = isNoisy, eventId = eventId,
timestamp = event.timestamp, editedEventId = null,
senderName = senderDisplayName, canBeReplaced = true,
senderId = senderId.value, isRedacted = false,
body = event.content, isUpdated = false,
imageUriString = event.contentUrl, timestamp = clock.epochMillis(),
threadId = null, description = stringProvider.getString(R.string.notification_fallback_content),
roomName = roomDisplayName, )
roomIsDirect = isDirect,
roomAvatarPath = roomAvatarUrl, private fun descriptionFromMessageContent(
senderAvatarPath = senderAvatarUrl, content: NotificationContent.MessageLike.RoomMessage,
soundName = null, ): String {
outGoingMessage = false, return when (val messageType = content.messageType) {
outGoingMessageFailed = false, is AudioMessageType -> messageType.body
isRedacted = false, is EmoteMessageType -> messageType.body
isUpdated = false is FileMessageType -> messageType.body
) is ImageMessageType -> messageType.body
is NoticeMessageType -> messageType.body
is TextMessageType -> messageType.body
is VideoMessageType -> messageType.body
is LocationMessageType -> messageType.body
is UnknownMessageType -> stringProvider.getString(CommonStrings.common_unsupported_event)
}
} }
/** private fun descriptionFromRoomMembershipContent(
* TODO This is a temporary method for EAx. content: NotificationContent.StateEvent.RoomMemberContent,
*/ isDirectRoom: Boolean
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData { ): String? {
return this ?: NotificationData( return when (content.membershipState) {
eventId = eventId, RoomMembershipState.INVITE -> {
senderId = UserId("@user:domain"), if (isDirectRoom) {
roomId = roomId, stringProvider.getString(R.string.notification_invite_body)
senderAvatarUrl = null, } else {
senderDisplayName = null, stringProvider.getString(R.string.notification_room_invite_body)
roomAvatarUrl = null, }
roomDisplayName = null, }
isNoisy = false, else -> null
isEncrypted = false, }
isDirect = false,
event = NotificationEvent(
timestamp = clock.epochMillis(),
content = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}",
contentUrl = null
)
)
} }
} }
@Suppress("LongParameterList")
private fun buildNotifiableMessageEvent(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
editedEventId: EventId? = null,
canBeReplaced: Boolean = false,
noisy: Boolean,
timestamp: Long,
senderName: String?,
senderId: String?,
body: String?,
// We cannot use Uri? type here, as that could trigger a
// NotSerializableException when persisting this to storage
imageUriString: String? = null,
threadId: ThreadId? = null,
roomName: String? = null,
roomIsDirect: Boolean = false,
roomAvatarPath: String? = null,
senderAvatarPath: String? = null,
soundName: String? = null,
// This is used for >N notification, as the result of a smart reply
outGoingMessage: Boolean = false,
outGoingMessageFailed: Boolean = false,
isRedacted: Boolean = false,
isUpdated: Boolean = false
) = NotifiableMessageEvent(
sessionId = sessionId,
roomId = roomId,
eventId = eventId,
editedEventId = editedEventId,
canBeReplaced = canBeReplaced,
noisy = noisy,
timestamp = timestamp,
senderName = senderName,
senderId = senderId,
body = body,
imageUriString = imageUriString,
threadId = threadId,
roomName = roomName,
roomIsDirect = roomIsDirect,
roomAvatarPath = roomAvatarPath,
senderAvatarPath = senderAvatarPath,
soundName = soundName,
outGoingMessage = outGoingMessage,
outGoingMessageFailed = outGoingMessageFailed,
isRedacted = isRedacted,
isUpdated = isUpdated
)

View file

@ -34,6 +34,8 @@ data class NotificationActionIds @Inject constructor(
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION" val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION" val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION" val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION"
val dismissInvite = "${buildMeta.applicationId}.NotificationActions.DISMISS_INVITE_NOTIF_ACTION"
val dismissEvent = "${buildMeta.applicationId}.NotificationActions.DISMISS_EVENT_NOTIF_ACTION"
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC" val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
val push = "${buildMeta.applicationId}.PUSH" val push = "${buildMeta.applicationId}.PUSH"
} }

View file

@ -49,6 +49,7 @@ class NotificationBitmapLoader @Inject constructor(
return try { return try {
val imageRequest = ImageRequest.Builder(context) val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024))) .data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
.transformations(CircleCropTransformation())
.build() .build()
val result = context.imageLoader.execute(imageRequest) val result = context.imageLoader.execute(imageRequest)
result.drawable?.toBitmap() result.drawable?.toBitmap()

View file

@ -22,6 +22,7 @@ import android.content.Intent
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -37,7 +38,7 @@ private val loggerTag = LoggerTag("NotificationBroadcastReceiver", notificationL
*/ */
class NotificationBroadcastReceiver : BroadcastReceiver() { class NotificationBroadcastReceiver : BroadcastReceiver() {
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager @Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder //@Inject lateinit var activeSessionHolder: ActiveSessionHolder
//@Inject lateinit var analyticsTracker: AnalyticsTracker //@Inject lateinit var analyticsTracker: AnalyticsTracker
@ -50,24 +51,31 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent") Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent")
val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return
val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId)
val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId)
when (intent.action) { when (intent.action) {
actionIds.smartReply -> actionIds.smartReply ->
handleSmartReply(intent, context) handleSmartReply(intent, context)
actionIds.dismissRoom -> if (roomId != null) { actionIds.dismissRoom -> if (roomId != null) {
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
} }
actionIds.dismissSummary -> actionIds.dismissSummary ->
notificationDrawerManager.clearAllEvents(sessionId) defaultNotificationDrawerManager.clearAllEvents(sessionId)
actionIds.dismissInvite -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
}
actionIds.dismissEvent -> if (eventId != null) {
defaultNotificationDrawerManager.clearEvent(eventId)
}
actionIds.markRoomRead -> if (roomId != null) { actionIds.markRoomRead -> if (roomId != null) {
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
handleMarkAsRead(sessionId, roomId) handleMarkAsRead(sessionId, roomId)
} }
actionIds.join -> if (roomId != null) { actionIds.join -> if (roomId != null) {
notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleJoinRoom(sessionId, roomId) handleJoinRoom(sessionId, roomId)
} }
actionIds.reject -> if (roomId != null) { actionIds.reject -> if (roomId != null) {
notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleRejectRoom(sessionId, roomId) handleRejectRoom(sessionId, roomId)
} }
} }
@ -240,6 +248,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
const val KEY_SESSION_ID = "sessionID" const val KEY_SESSION_ID = "sessionID"
const val KEY_ROOM_ID = "roomID" const val KEY_ROOM_ID = "roomID"
const val KEY_THREAD_ID = "threadID" const val KEY_THREAD_ID = "threadID"
const val KEY_EVENT_ID = "eventID"
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"
} }
} }

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -45,6 +46,7 @@ data class NotificationEventQueue constructor(
is InviteNotifiableEvent -> it.copy(isRedacted = true) is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true) is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true) is SimpleNotifiableEvent -> it.copy(isRedacted = true)
is FallbackNotifiableEvent -> it.copy(isRedacted = true)
} }
} }
} }
@ -57,7 +59,8 @@ data class NotificationEventQueue constructor(
when (it) { when (it) {
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId) is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId) is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
else -> false is SimpleNotifiableEvent -> false
is FallbackNotifiableEvent -> roomsLeft.contains(it.roomId)
} }
} }
} }
@ -127,11 +130,21 @@ data class NotificationEventQueue constructor(
is InviteNotifiableEvent -> with.copy(isUpdated = true) is InviteNotifiableEvent -> with.copy(isUpdated = true)
is NotifiableMessageEvent -> with.copy(isUpdated = true) is NotifiableMessageEvent -> with.copy(isUpdated = true)
is SimpleNotifiableEvent -> with.copy(isUpdated = true) is SimpleNotifiableEvent -> with.copy(isUpdated = true)
is FallbackNotifiableEvent -> with.copy(isUpdated = true)
} }
) )
} }
fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { fun clearEvent(eventId: EventId) {
queue.removeAll { it.eventId == eventId }
}
fun clearMembershipNotificationForSession(sessionId: SessionId) {
Timber.d("clearMemberShipOfSession $sessionId")
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId }
}
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
Timber.d("clearMemberShipOfRoom $sessionId, $roomId") Timber.d("clearMemberShipOfRoom $sessionId, $roomId")
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId } queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId }
} }

View file

@ -20,6 +20,7 @@ import android.app.Notification
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
@ -94,16 +95,35 @@ class NotificationFactory @Inject constructor(
} }
} }
fun List<ProcessedEvent<FallbackNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationFactory.createFallbackNotification(event),
OneShotNotification.Append.Meta(
key = event.eventId.value,
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
)
)
}
}
}
fun createSummaryNotification( fun createSummaryNotification(
currentUser: MatrixUser, currentUser: MatrixUser,
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean useCompleteNotificationFormat: Boolean
): SummaryNotification { ): SummaryNotification {
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta } val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta } val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta } val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val fallbackMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
return when { return when {
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update( else -> SummaryNotification.Update(
@ -112,6 +132,7 @@ class NotificationFactory @Inject constructor(
roomNotifications = roomMeta, roomNotifications = roomMeta,
invitationNotifications = invitationMeta, invitationNotifications = invitationMeta,
simpleNotifications = simpleMeta, simpleNotifications = simpleMeta,
fallbackNotifications = fallbackMeta,
useCompleteNotificationFormat = useCompleteNotificationFormat useCompleteNotificationFormat = useCompleteNotificationFormat
) )
) )

View file

@ -37,12 +37,17 @@ class NotificationIdProvider @Inject constructor() {
return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID
} }
fun getFallbackNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID
}
private fun getOffset(sessionId: SessionId): Int { private fun getOffset(sessionId: SessionId): Int {
// Compute a int from a string with a low risk of collision. // Compute a int from a string with a low risk of collision.
return abs(sessionId.value.hashCode() % 100_000) * 10 return abs(sessionId.value.hashCode() % 100_000) * 10
} }
companion object { companion object {
private const val FALLBACK_NOTIFICATION_ID = -1
private const val SUMMARY_NOTIFICATION_ID = 0 private const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2 private const val ROOM_EVENT_NOTIFICATION_ID = 2

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -36,16 +37,18 @@ class NotificationRenderer @Inject constructor(
useCompleteNotificationFormat: Boolean, useCompleteNotificationFormat: Boolean,
eventsToProcess: List<ProcessedEvent<NotifiableEvent>> eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
) { ) {
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() val groupedEvents = eventsToProcess.groupByType()
with(notificationFactory) { with(notificationFactory) {
val roomNotifications = roomEvents.toNotifications(currentUser) val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser)
val invitationNotifications = invitationEvents.toNotifications() val invitationNotifications = groupedEvents.invitationEvents.toNotifications()
val simpleNotifications = simpleEvents.toNotifications() val simpleNotifications = groupedEvents.simpleEvents.toNotifications()
val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications()
val summaryNotification = createSummaryNotification( val summaryNotification = createSummaryNotification(
currentUser = currentUser, currentUser = currentUser,
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications, invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications, simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
useCompleteNotificationFormat = useCompleteNotificationFormat useCompleteNotificationFormat = useCompleteNotificationFormat
) )
@ -118,6 +121,26 @@ class NotificationRenderer @Inject constructor(
} }
} }
fallbackNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.d("Removing fallback notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating fallback notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.key,
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
// Update summary last to avoid briefly displaying it before other notifications // Update summary last to avoid briefly displaying it before other notifications
if (summaryNotification is SummaryNotification.Update) { if (summaryNotification is SummaryNotification.Update) {
Timber.d("Updating summary notification") Timber.d("Updating summary notification")
@ -139,6 +162,7 @@ private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotifica
val roomIdToEventMap: MutableMap<RoomId, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap() val roomIdToEventMap: MutableMap<RoomId, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap()
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList() val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList() val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList()
val fallbackEvents: MutableList<ProcessedEvent<FallbackNotifiableEvent>> = ArrayList()
forEach { forEach {
when (val event = it.event) { when (val event = it.event) {
is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType())
@ -147,9 +171,12 @@ private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotifica
roomEvents.add(it.castedToEventType()) roomEvents.add(it.castedToEventType())
} }
is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType())
is FallbackNotifiableEvent -> {
fallbackEvents.add(it.castedToEventType())
}
} }
} }
return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents) return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents)
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@ -158,5 +185,6 @@ private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventT
data class GroupedNotificationEvents( data class GroupedNotificationEvents(
val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>, val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>,
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>, val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>> val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>,
val fallbackEvents: List<ProcessedEvent<FallbackNotifiableEvent>>,
) )

View file

@ -39,8 +39,8 @@ class NotificationState(
) { ) {
fun <T> updateQueuedEvents( fun <T> updateQueuedEvents(
drawerManager: NotificationDrawerManager, drawerManager: DefaultNotificationDrawerManager,
action: NotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T action: DefaultNotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
): T { ): T {
return synchronized(queuedEvents) { return synchronized(queuedEvents) {
action(drawerManager, queuedEvents, renderedEvents) action(drawerManager, queuedEvents, renderedEvents)

View file

@ -17,8 +17,12 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person import androidx.core.app.Person
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
@ -26,8 +30,6 @@ import io.element.android.libraries.push.impl.notifications.debug.annotateForDeb
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import me.gujun.android.span.Span
import me.gujun.android.span.span
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -151,30 +153,31 @@ class RoomGroupMessageCreator @Inject constructor(
} }
} }
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span { private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence {
return if (roomIsDirect) { return if (roomIsDirect) {
span { buildSpannedString {
span { inSpans(StyleSpan(Typeface.BOLD)) {
textStyle = "bold" append(event.senderName)
+String.format("%s: ", event.senderName) append(": ")
} }
+(event.description) append(event.description)
} }
} else { } else {
span { buildSpannedString {
span { inSpans(StyleSpan(Typeface.BOLD)) {
textStyle = "bold" append(roomName)
+String.format("%s: %s ", roomName, event.senderName) append(": ")
event.senderName
append(" ")
} }
+(event.description) append(event.description)
} }
} }
} }
private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? { private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
// Use the last event (most recent?) // Use the last event (most recent?)
return events.lastOrNull() return events.reversed().firstNotNullOfOrNull { it.roomAvatarPath }
?.roomAvatarPath
?.let { bitmapLoader.getRoomBitmap(it) } ?.let { bitmapLoader.getRoomBitmap(it) }
} }
} }

View file

@ -49,12 +49,14 @@ class SummaryGroupMessageCreator @Inject constructor(
roomNotifications: List<RoomNotification.Message.Meta>, roomNotifications: List<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>, invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>, simpleNotifications: List<OneShotNotification.Append.Meta>,
fallbackNotifications: List<OneShotNotification.Append.Meta>,
useCompleteNotificationFormat: Boolean useCompleteNotificationFormat: Boolean
): Notification { ): Notification {
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) } roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) }
invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) } invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) }
simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) } simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) }
fallbackNotifications.forEach { style.addLine(it.summaryLine) }
} }
val summaryIsNoisy = roomNotifications.any { it.shouldBing } || val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||

View file

@ -32,10 +32,9 @@ import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
@ -49,8 +48,6 @@ class NotificationFactory @Inject constructor(
private val pendingIntentFactory: PendingIntentFactory, private val pendingIntentFactory: PendingIntentFactory,
private val markAsReadActionFactory: MarkAsReadActionFactory, private val markAsReadActionFactory: MarkAsReadActionFactory,
private val quickReplyActionFactory: QuickReplyActionFactory, private val quickReplyActionFactory: QuickReplyActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
) { ) {
/** /**
* Create a notification for a Room. * Create a notification for a Room.
@ -154,22 +151,12 @@ class NotificationFactory @Inject constructor(
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
.setColor(accentColor) .setColor(accentColor)
.addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) // TODO removed for now, will be added back later
.addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) // .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
.apply { .apply {
/*
// Build the pending intent for when the notification is clicked // Build the pending intent for when the notification is clicked
val contentIntent = HomeActivity.newIntent( setContentIntent(pendingIntentFactory.createInviteListPendingIntent(inviteNotifiableEvent.sessionId))
context,
firstStartMainActivity = true,
inviteNotificationRoomId = inviteNotifiableEvent.roomId
)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
*/
if (inviteNotifiableEvent.noisy) { if (inviteNotifiableEvent.noisy) {
// Compat // Compat
@ -183,6 +170,12 @@ class NotificationFactory @Inject constructor(
} else { } else {
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
setDeleteIntent(
pendingIntentFactory.createDismissInvitePendingIntent(
inviteNotifiableEvent.sessionId,
inviteNotifiableEvent.roomId,
)
)
setAutoCancel(true) setAutoCancel(true)
} }
.build() .build()
@ -223,6 +216,39 @@ class NotificationFactory @Inject constructor(
.build() .build()
} }
fun createFallbackNotification(
fallbackNotifiableEvent: FallbackNotifiableEvent,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val smallIcon = R.drawable.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(false)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
.setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8))
.setGroup(fallbackNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.setAutoCancel(true)
// Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite
// and the user won't have access to the room yet, resulting in an error screen.
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId))
.setDeleteIntent(
pendingIntentFactory.createDismissEventPendingIntent(
fallbackNotifiableEvent.sessionId,
fallbackNotifiableEvent.roomId,
fallbackNotifiableEvent.eventId
)
)
.apply {
priority = NotificationCompat.PRIORITY_LOW
setAutoCancel(true)
}
.build()
}
/** /**
* Create the summary notification. * Create the summary notification.
*/ */

View file

@ -19,8 +19,10 @@ package io.element.android.libraries.push.impl.notifications.factories
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.app.PendingIntentCompat
import io.element.android.libraries.androidutils.uri.createIgnoredUri import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -39,19 +41,19 @@ class PendingIntentFactory @Inject constructor(
private val actionIds: NotificationActionIds, private val actionIds: NotificationActionIds,
) { ) {
fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? { fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? {
return createPendingIntent(sessionId = sessionId, roomId = null, threadId = null) return createRoomPendingIntent(sessionId = sessionId, roomId = null, threadId = null)
} }
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? { fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
return createPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null) return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
} }
fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? { fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
return createPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId) return createRoomPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId)
} }
private fun createPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? { private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId) val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
return PendingIntent.getActivity( return PendingIntent.getActivity(
context, context,
clock.epochMillis().toInt(), clock.epochMillis().toInt(),
@ -87,6 +89,35 @@ class PendingIntentFactory @Inject constructor(
) )
} }
fun createDismissInvitePendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissInvite
intent.data = createIgnoredUri("deleteInvite/$sessionId/$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun createDismissEventPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissEvent
intent.data = createIgnoredUri("deleteEvent/$sessionId/$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId.value)
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun createTestPendingIntent(): PendingIntent? { fun createTestPendingIntent(): PendingIntent? {
val testActionIntent = Intent(context, TestNotificationReceiver::class.java) val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
testActionIntent.action = actionIds.diagnostic testActionIntent.action = actionIds.diagnostic
@ -97,4 +128,9 @@ class PendingIntentFactory @Inject constructor(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
} }
fun createInviteListPendingIntent(sessionId: SessionId): PendingIntent {
val intent = intentProvider.getInviteListIntent(sessionId)
return PendingIntentCompat.getActivity(context, 0, intent, 0, false)
}
} }

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.model
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Used for notifications with events that couldn't be retrieved or decrypted, so we don't know their contents.
* These are created separately from message notifications, so they can be displayed differently.
*/
data class FallbackNotifiableEvent(
override val sessionId: SessionId,
override val roomId: RoomId,
override val eventId: EventId,
override val editedEventId: EventId?,
override val description: String?,
override val canBeReplaced: Boolean,
override val isRedacted: Boolean,
override val isUpdated: Boolean,
val timestamp: Long,
) : NotifiableEvent

View file

@ -27,8 +27,8 @@ data class InviteNotifiableEvent(
override val canBeReplaced: Boolean, override val canBeReplaced: Boolean,
val roomName: String?, val roomName: String?,
val noisy: Boolean, val noisy: Boolean,
val title: String, val title: String?,
val description: String, override val description: String,
val type: String?, val type: String?,
val timestamp: Long, val timestamp: Long,
val soundName: String?, val soundName: String?,

View file

@ -29,6 +29,7 @@ sealed interface NotifiableEvent : Serializable {
val roomId: RoomId val roomId: RoomId
val eventId: EventId val eventId: EventId
val editedEventId: EventId? val editedEventId: EventId?
val description: String?
// Used to know if event should be replaced with the one coming from eventstream // Used to know if event should be replaced with the one coming from eventstream
val canBeReplaced: Boolean val canBeReplaced: Boolean

View file

@ -16,6 +16,8 @@
package io.element.android.libraries.push.impl.notifications.model package io.element.android.libraries.push.impl.notifications.model
import android.net.Uri import android.net.Uri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -54,7 +56,7 @@ data class NotifiableMessageEvent(
) : NotifiableEvent { ) : NotifiableEvent {
val type: String = EventType.MESSAGE val type: String = EventType.MESSAGE
val description: String = body ?: "" override val description: String = body ?: ""
val title: String = senderName ?: "" val title: String = senderName ?: ""
// TODO EAx The image has to be downloaded and expose using the file provider. // TODO EAx The image has to be downloaded and expose using the file provider.
@ -64,12 +66,21 @@ data class NotifiableMessageEvent(
get() = imageUriString?.let { Uri.parse(it) } get() = imageUriString?.let { Uri.parse(it) }
} }
fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom( /**
* Used to check if a notification should be ignored based on the current app and navigation state.
*/
fun NotifiableEvent.shouldIgnoreEventInRoom(
appNavigationState: AppNavigationState? appNavigationState: AppNavigationState?
): Boolean { ): Boolean {
val currentSessionId = appNavigationState?.currentSessionId() ?: return false val currentSessionId = appNavigationState?.currentSessionId() ?: return false
return when (val currentRoomId = appNavigationState.currentRoomId()) { return when (val currentRoomId = appNavigationState.currentRoomId()) {
null -> false null -> false
else -> sessionId == currentSessionId && roomId == currentRoomId && threadId == appNavigationState.currentThreadId() else -> isAppInForeground
&& sessionId == currentSessionId
&& roomId == currentRoomId
&& (this as? NotifiableMessageEvent)?.threadId == appNavigationState.currentThreadId()
} }
} }
private val isAppInForeground: Boolean
get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)

View file

@ -26,7 +26,7 @@ data class SimpleNotifiableEvent(
override val editedEventId: EventId?, override val editedEventId: EventId?,
val noisy: Boolean, val noisy: Boolean,
val title: String, val title: String,
val description: String, override val description: String,
val type: String?, val type: String?,
val timestamp: Long, val timestamp: Long,
val soundName: String?, val soundName: String?,

View file

@ -31,7 +31,7 @@ import io.element.android.libraries.push.impl.PushersManager
import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.NotificationActionIds import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.push.impl.store.DefaultPushDataStore import io.element.android.libraries.push.impl.store.DefaultPushDataStore
import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushproviders.api.PushHandler
@ -48,7 +48,7 @@ private val loggerTag = LoggerTag("PushHandler", pushLoggerTag)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushHandler @Inject constructor( class DefaultPushHandler @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager, private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
private val notifiableEventResolver: NotifiableEventResolver, private val notifiableEventResolver: NotifiableEventResolver,
private val defaultPushDataStore: DefaultPushDataStore, private val defaultPushDataStore: DefaultPushDataStore,
private val userPushStoreFactory: UserPushStoreFactory, private val userPushStoreFactory: UserPushStoreFactory,
@ -121,9 +121,9 @@ class DefaultPushHandler @Inject constructor(
return return
} }
val notificationData = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
if (notificationData == null) { if (notifiableEvent == null) {
Timber.w("Unable to get a notification data") Timber.w("Unable to get a notification data")
return return
} }
@ -135,7 +135,7 @@ class DefaultPushHandler @Inject constructor(
return return
} }
notificationDrawerManager.onNotifiableEventReceived(notificationData) defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
} }

View file

@ -4,6 +4,7 @@
<string name="notification_channel_listening_for_events">"Listening for events"</string> <string name="notification_channel_listening_for_events">"Listening for events"</string>
<string name="notification_channel_noisy">"Noisy notifications"</string> <string name="notification_channel_noisy">"Noisy notifications"</string>
<string name="notification_channel_silent">"Silent notifications"</string> <string name="notification_channel_silent">"Silent notifications"</string>
<string name="notification_fallback_content">"Notification"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string> <string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
<string name="notification_invitation_action_join">"Join"</string> <string name="notification_invitation_action_join">"Join"</string>
<string name="notification_invitation_action_reject">"Reject"</string> <string name="notification_invitation_action_reject">"Reject"</string>
@ -47,6 +48,5 @@
<string name="push_distributor_background_sync_android">"Background synchronization"</string> <string name="push_distributor_background_sync_android">"Background synchronization"</string>
<string name="push_distributor_firebase_android">"Google Services"</string> <string name="push_distributor_firebase_android">"Google Services"</string>
<string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string> <string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string>
<string name="notification_fallback_content">"Notification"</string>
<string name="notification_room_action_quick_reply">"Quick reply"</string> <string name="notification_room_action_quick_reply">"Quick reply"</string>
</resources> </resources>

View file

@ -120,6 +120,7 @@ class NotifiableEventProcessorTest {
@Test @Test
fun `given viewing the same room main timeline when processing main timeline message event then removes message`() { fun `given viewing the same room main timeline when processing main timeline message event then removes message`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null)) val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null))
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList()) val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
@ -133,6 +134,7 @@ class NotifiableEventProcessorTest {
@Test @Test
fun `given viewing the same thread timeline when processing thread message event then removes message`() { fun `given viewing the same thread timeline when processing thread message event then removes message`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList()) val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())

View file

@ -208,7 +208,7 @@ class NotificationEventQueueTest {
) )
) )
queue.clearMemberShipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) queue.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)
assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID)))
} }

View file

@ -33,7 +33,7 @@ private const val MY_USER_AVATAR_URL = "avatar-url"
private const val USE_COMPLETE_NOTIFICATION_FORMAT = true private const val USE_COMPLETE_NOTIFICATION_FORMAT = true
private val AN_EVENT_LIST = listOf<ProcessedEvent<NotifiableEvent>>() private val AN_EVENT_LIST = listOf<ProcessedEvent<NotifiableEvent>>()
private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList()) private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList(), emptyList())
private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk())
private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed
private val A_NOTIFICATION = mockk<Notification>() private val A_NOTIFICATION = mockk<Notification>()
@ -202,13 +202,14 @@ class NotificationRendererTest {
} }
private fun givenNoNotifications() { private fun givenNoNotifications() {
givenNotifications(emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) givenNotifications(emptyList(), emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION)
} }
private fun givenNotifications( private fun givenNotifications(
roomNotifications: List<RoomNotification> = emptyList(), roomNotifications: List<RoomNotification> = emptyList(),
invitationNotifications: List<OneShotNotification> = emptyList(), invitationNotifications: List<OneShotNotification> = emptyList(),
simpleNotifications: List<OneShotNotification> = emptyList(), simpleNotifications: List<OneShotNotification> = emptyList(),
fallbackNotifications: List<OneShotNotification> = emptyList(),
useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT, useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT,
summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION
) { ) {
@ -219,6 +220,7 @@ class NotificationRendererTest {
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications, invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications, simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
summaryNotification = summaryNotification summaryNotification = summaryNotification
) )
} }

View file

@ -36,12 +36,14 @@ class FakeNotificationFactory {
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
summaryNotification: SummaryNotification summaryNotification: SummaryNotification
) { ) {
with(instance) { with(instance) {
coEvery { groupedEvents.roomEvents.toNotifications(matrixUser) } returns roomNotifications coEvery { groupedEvents.roomEvents.toNotifications(matrixUser) } returns roomNotifications
every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications
every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications
every { groupedEvents.fallbackEvents.toNotifications() } returns fallbackNotifications
every { every {
createSummaryNotification( createSummaryNotification(
@ -49,6 +51,7 @@ class FakeNotificationFactory {
roomNotifications, roomNotifications,
invitationNotifications, invitationNotifications,
simpleNotifications, simpleNotifications,
fallbackNotifications,
useCompleteNotificationFormat useCompleteNotificationFormat
) )
} returns summaryNotification } returns summaryNotification

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.push.test"
}
dependencies {
api(projects.libraries.push.api)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.test.notifications
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
class FakeNotificationDrawerManager : NotificationDrawerManager {
private val clearMemberShipNotificationForSessionCallsCount = mutableMapOf<String, Int>()
private val clearMemberShipNotificationForRoomCallsCount = mutableMapOf<String, Int>()
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
clearMemberShipNotificationForSessionCallsCount.merge(sessionId.value, 1) { oldValue, value -> oldValue + value }
}
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
val key = getMembershipNotificationKey(sessionId, roomId)
clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value }
}
fun getClearMembershipNotificationForSessionCount(sessionId: SessionId): Int {
return clearMemberShipNotificationForRoomCallsCount[sessionId.value] ?: 0
}
fun getClearMembershipNotificationForRoomCount(sessionId: SessionId, roomId: RoomId): Int {
val key = getMembershipNotificationKey(sessionId, roomId)
return clearMemberShipNotificationForRoomCallsCount[key] ?: 0
}
private fun getMembershipNotificationKey(sessionId: SessionId, roomId: RoomId): String {
return "$sessionId-$roomId"
}
}

Some files were not shown because too many files have changed in this diff Show more