Merge branch 'develop' into feature/fga/update-rust-sdk-0.1.29-again
This commit is contained in:
commit
9e5a3d14e5
225 changed files with 1635 additions and 815 deletions
2
.idea/dictionaries/shared.xml
generated
2
.idea/dictionaries/shared.xml
generated
|
|
@ -8,6 +8,8 @@
|
|||
<w>onboarding</w>
|
||||
<w>placeables</w>
|
||||
<w>showkase</w>
|
||||
<w>snackbar</w>
|
||||
<w>swipeable</w>
|
||||
<w>textfields</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
|
|
|
|||
|
|
@ -44,14 +44,14 @@
|
|||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Handle deep-link for notification, uncomment to be able to test deeplink with ./tools/adb/deeplink.sh -->
|
||||
<!--intent-filter>
|
||||
<!-- Handle deep-link for notification ./tools/adb/deeplink.sh -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:host="open"
|
||||
android:scheme="elementx" />
|
||||
</intent-filter-->
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ package io.element.android.x
|
|||
|
||||
import android.app.Application
|
||||
import androidx.startup.AppInitializer
|
||||
import io.element.android.x.di.AppComponent
|
||||
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.info.logApplicationInfo
|
||||
import io.element.android.x.initializer.CrashInitializer
|
||||
|
|
|
|||
|
|
@ -35,14 +35,21 @@ class IntentProviderImpl @Inject constructor(
|
|||
@ApplicationContext private val context: Context,
|
||||
private val deepLinkCreator: DeepLinkCreator,
|
||||
) : IntentProvider {
|
||||
override fun getViewIntent(
|
||||
override fun getViewRoomIntent(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId?,
|
||||
threadId: ThreadId?,
|
||||
): Intent {
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ package io.element.android.appnav
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.modality.BuildContext
|
||||
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.plugins
|
||||
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.createNode
|
||||
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.di.AppScope
|
||||
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.sync.SyncState
|
||||
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.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -89,6 +88,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BackstackNode<LoggedInFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
|
|
@ -139,7 +139,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
observeAnalyticsState()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
syncService.startSync()
|
||||
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
|
||||
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
|
||||
Coil.setImageLoader(imageLoaderFactory)
|
||||
|
|
@ -341,4 +340,13 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,13 +253,10 @@ class RootFlowNode @AssistedInject constructor(
|
|||
Timber.d("Navigating to $deeplinkData")
|
||||
attachSession(deeplinkData.sessionId)
|
||||
.apply {
|
||||
val roomId = deeplinkData.roomId
|
||||
if (roomId == null) {
|
||||
// In case room is not provided, ensure the app navigate back to the room list
|
||||
attachRoot()
|
||||
} else {
|
||||
attachRoom(roomId)
|
||||
// TODO .attachThread(deeplinkData.threadId)
|
||||
when (deeplinkData) {
|
||||
is DeeplinkData.Root -> attachRoot()
|
||||
is DeeplinkData.Room -> attachRoom(deeplinkData)
|
||||
is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.libraries.deeplink.DeeplinkData
|
||||
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 javax.inject.Inject
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ allprojects {
|
|||
config = files("$rootDir/tools/detekt/detekt.yml")
|
||||
}
|
||||
dependencies {
|
||||
detektPlugins("io.nlopez.compose.rules:detekt:0.1.11")
|
||||
detektPlugins("io.nlopez.compose.rules:detekt:0.1.12")
|
||||
}
|
||||
|
||||
// KtLint
|
||||
|
|
@ -247,6 +247,7 @@ koverMerged {
|
|||
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||
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.designsystem.swipe.SwipeableActionsState*"
|
||||
}
|
||||
bound {
|
||||
minValue = 90
|
||||
|
|
|
|||
1
changelog.d/821.bugfix
Normal file
1
changelog.d/821.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Truncate and ellipsize long reactions
|
||||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.Text
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
|
|
@ -92,7 +92,7 @@ fun AddPeopleViewTopBar(
|
|||
onBackPressed: () -> Unit = {},
|
||||
onNextPressed: () -> Unit = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.Text
|
||||
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.ui.components.AvatarActionBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
|
|
@ -181,7 +181,7 @@ fun ConfigureRoomToolbar(
|
|||
onBackPressed: () -> Unit = {},
|
||||
onNextPressed: () -> Unit = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.IconButton
|
||||
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.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.designsystem.R as DrawableR
|
||||
|
|
@ -133,7 +133,7 @@ fun CreateRoomRootViewTopBar(
|
|||
modifier: Modifier = Modifier,
|
||||
onClosePressed: () -> Unit = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(
|
||||
|
|
@ -142,7 +142,7 @@ fun CreateRoomRootViewTopBar(
|
|||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClosePressed) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ dependencies {
|
|||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.libraries.push.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
@ -50,6 +51,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.features.invitelist.test)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.core.RoomId
|
||||
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.extensions.toAnalyticsJoinedRoom
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -49,6 +50,7 @@ class InviteListPresenter @Inject constructor(
|
|||
private val client: MatrixClient,
|
||||
private val store: SeenInvitesStore,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
) : Presenter<InviteListState> {
|
||||
|
||||
@Composable
|
||||
|
|
@ -138,6 +140,7 @@ class InviteListPresenter @Inject constructor(
|
|||
suspend {
|
||||
client.getRoom(roomId)?.use {
|
||||
it.acceptInvitation().getOrThrow()
|
||||
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
|
||||
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
|
||||
}
|
||||
roomId
|
||||
|
|
@ -148,7 +151,9 @@ class InviteListPresenter @Inject constructor(
|
|||
suspend {
|
||||
client.getRoom(roomId)?.use {
|
||||
it.rejectInvitation().getOrThrow()
|
||||
} ?: Unit
|
||||
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
|
||||
}
|
||||
Unit
|
||||
}.runCatchingUpdatingState(declinedAction)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
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.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
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.room.RoomMember
|
||||
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.room.FakeMatrixRoom
|
||||
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 org.junit.Test
|
||||
|
||||
|
|
@ -47,12 +52,8 @@ class InviteListPresenterTests {
|
|||
@Test
|
||||
fun `present - starts empty, adds invites when received`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource()
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
FakeAnalyticsService(),
|
||||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -72,12 +73,8 @@ class InviteListPresenterTests {
|
|||
@Test
|
||||
fun `present - uses user ID and avatar for direct invites`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
FakeAnalyticsService(),
|
||||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -102,12 +99,8 @@ class InviteListPresenterTests {
|
|||
@Test
|
||||
fun `present - includes sender details for room invites`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
FakeAnalyticsService(),
|
||||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -136,6 +129,7 @@ class InviteListPresenterTests {
|
|||
),
|
||||
FakeSeenInvitesStore(),
|
||||
FakeAnalyticsService(),
|
||||
FakeNotificationDrawerManager()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -155,12 +149,8 @@ class InviteListPresenterTests {
|
|||
@Test
|
||||
fun `present - shows confirm dialog for declining room invites`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
FakeAnalyticsService(),
|
||||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -180,12 +170,8 @@ class InviteListPresenterTests {
|
|||
@Test
|
||||
fun `present - hides confirm dialog when cancelling`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
FakeAnalyticsService(),
|
||||
val presenter = createPresenter(
|
||||
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -205,11 +191,12 @@ class InviteListPresenterTests {
|
|||
@Test
|
||||
fun `present - declines invite after confirming`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
|
||||
val client = FakeMatrixClient(
|
||||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
|
||||
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
|
|
@ -225,6 +212,7 @@ class InviteListPresenterTests {
|
|||
skipItems(2)
|
||||
|
||||
Truth.assertThat(room.isInviteRejected).isTrue()
|
||||
Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +223,7 @@ class InviteListPresenterTests {
|
|||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
|
||||
val presenter = createPresenter(client)
|
||||
val ex = Throwable("Ruh roh!")
|
||||
room.givenRejectInviteResult(Result.failure(ex))
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
|
@ -266,7 +254,7 @@ class InviteListPresenterTests {
|
|||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
|
||||
val presenter = createPresenter(client)
|
||||
val ex = Throwable("Ruh roh!")
|
||||
room.givenRejectInviteResult(Result.failure(ex))
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
|
@ -294,11 +282,12 @@ class InviteListPresenterTests {
|
|||
@Test
|
||||
fun `present - accepts invites and sets state on success`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
|
||||
val client = FakeMatrixClient(
|
||||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
|
||||
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
|
|
@ -311,6 +300,7 @@ class InviteListPresenterTests {
|
|||
|
||||
Truth.assertThat(room.isInviteAccepted).isTrue()
|
||||
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,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
|
||||
val presenter = createPresenter(client)
|
||||
val ex = Throwable("Ruh roh!")
|
||||
room.givenAcceptInviteResult(Result.failure(ex))
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
|
@ -346,7 +336,7 @@ class InviteListPresenterTests {
|
|||
roomSummaryDataSource = roomSummaryDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService())
|
||||
val presenter = createPresenter(client)
|
||||
val ex = Throwable("Ruh roh!")
|
||||
room.givenAcceptInviteResult(Result.failure(ex))
|
||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||
|
|
@ -376,6 +366,7 @@ class InviteListPresenterTests {
|
|||
),
|
||||
store,
|
||||
FakeAnalyticsService(),
|
||||
FakeNotificationDrawerManager()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -413,6 +404,7 @@ class InviteListPresenterTests {
|
|||
),
|
||||
store,
|
||||
FakeAnalyticsService(),
|
||||
FakeNotificationDrawerManager()
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -500,4 +492,16 @@ class InviteListPresenterTests {
|
|||
unreadNotificationCount = 0,
|
||||
)
|
||||
)
|
||||
|
||||
private fun createPresenter(
|
||||
client: MatrixClient,
|
||||
seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(),
|
||||
fakeAnalyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager()
|
||||
) = InviteListPresenter(
|
||||
client,
|
||||
seenInvitesStore,
|
||||
fakeAnalyticsService,
|
||||
notificationDrawerManager
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.StaticMapPlaceholder
|
||||
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
|
@ -127,18 +126,9 @@ fun StaticMapView(
|
|||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun StaticMapViewLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StaticMapViewDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
fun StaticMapViewPreview() = ElementPreview {
|
||||
StaticMapView(
|
||||
lat = 0.0,
|
||||
lon = 0.0,
|
||||
|
|
|
|||
|
|
@ -29,13 +29,12 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.location.api.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
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.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
|
@ -83,22 +82,13 @@ internal fun StaticMapPlaceholder(
|
|||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun StaticMapPlaceholderLightPreview(
|
||||
fun StaticMapPlaceholderPreview(
|
||||
@PreviewParameter(BooleanParameterProvider::class) values: Boolean
|
||||
) = ElementPreviewLight { ContentToPreview(values) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StaticMapPlaceholderDarkPreview(
|
||||
@PreviewParameter(BooleanParameterProvider::class) values: Boolean
|
||||
) = ElementPreviewDark { ContentToPreview(values) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(showProgress: Boolean) {
|
||||
) = ElementPreview {
|
||||
StaticMapPlaceholder(
|
||||
showProgress = showProgress,
|
||||
showProgress = values,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(400.dp),
|
||||
onLoadMapClick = {},
|
||||
|
|
|
|||
|
|
@ -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.ElementPreviewLight
|
||||
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.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.designsystem.R as DesignSystemR
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ fun SendLocationView(
|
|||
sheetDragHandle = {},
|
||||
sheetSwipeEnabled = false,
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.screen_share_location_title),
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.IconButton
|
||||
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.TopAppBar
|
||||
import io.element.android.libraries.theme.compound.generated.TypographyTokens
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ fun ShowLocationView(
|
|||
|
||||
Scaffold(modifier,
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.screen_view_location_title),
|
||||
|
|
|
|||
|
|
@ -42,5 +42,6 @@ Díky za trpělivost!"</string>
|
|||
<string name="screen_change_server_title">"Vyberte svůj server"</string>
|
||||
<string name="screen_login_password_hint">"Heslo"</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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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_login_password_hint">"Passwort"</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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -42,5 +42,6 @@ Vă mulțumim pentru răbdare!"</string>
|
|||
<string name="screen_change_server_title">"Selectați serverul"</string>
|
||||
<string name="screen_login_password_hint">"Parola"</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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -42,5 +42,6 @@
|
|||
<string name="screen_change_server_title">"Vyberte svoj server"</string>
|
||||
<string name="screen_login_password_hint">"Heslo"</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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.RadioButton
|
||||
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.Text
|
||||
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.roomListRoomName
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
@ -111,7 +111,7 @@ fun ForwardMessagesView(
|
|||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(CommonStrings.common_forward_message), style = ElementTextStyles.Bold.callout) },
|
||||
navigationIcon = {
|
||||
BackButton(onClick = { onBackButton(state) })
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.Scaffold
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
|
|
@ -83,7 +83,7 @@ fun ReportMessageView(
|
|||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(CommonStrings.action_report_content),
|
||||
|
|
|
|||
|
|
@ -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.timeline.model.AggregatedReaction
|
||||
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.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -132,7 +133,7 @@ private fun IconContent(
|
|||
)
|
||||
|
||||
@Composable
|
||||
fun ReactionContent(
|
||||
private fun ReactionContent(
|
||||
reaction: AggregatedReaction,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Row(
|
||||
|
|
@ -140,7 +141,7 @@ fun ReactionContent(
|
|||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = reaction.key,
|
||||
text = reaction.displayKey,
|
||||
fontSize = 15.sp, lineHeight = reactionEmojiLineHeight
|
||||
)
|
||||
if (reaction.count > 1) {
|
||||
|
|
@ -148,7 +149,7 @@ fun ReactionContent(
|
|||
Text(
|
||||
text = reaction.count.toString(),
|
||||
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"),
|
||||
onClick = {}
|
||||
)
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(aTimelineItemReactions().reactions.first().copy(
|
||||
key = "A very long reaction with many characters that should be truncated"
|
||||
)),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
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.layout.Arrangement
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.wrapContentHeight
|
||||
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.SwipeToDismiss
|
||||
import androidx.compose.material3.rememberDismissState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.toExtraPadding
|
||||
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.event.TimelineItemImageContent
|
||||
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.preview.ElementPreviewDark
|
||||
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.matrix.api.core.EventId
|
||||
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.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventRow(
|
||||
|
|
@ -110,6 +117,7 @@ fun TimelineItemEventRow(
|
|||
onSwipeToReply: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
fun onUserDataClicked() {
|
||||
|
|
@ -121,56 +129,88 @@ fun TimelineItemEventRow(
|
|||
inReplyToClick(inReplyToEventId)
|
||||
}
|
||||
|
||||
if (canReply) {
|
||||
val dismissState = rememberDismissState(
|
||||
confirmValueChange = {
|
||||
if (it == DismissValue.DismissedToEnd) {
|
||||
onSwipeToReply()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
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
|
||||
}
|
||||
)
|
||||
SwipeToDismiss(
|
||||
state = dismissState,
|
||||
background = {
|
||||
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
|
||||
},
|
||||
directions = setOf(DismissDirection.StartToEnd),
|
||||
dismissContent = {
|
||||
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) },
|
||||
)
|
||||
}
|
||||
)
|
||||
} 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) },
|
||||
)
|
||||
} 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 {
|
||||
Spacer(modifier = modifier.height(2.dp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
|
||||
* 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
|
||||
private fun MessageSenderInformation(
|
||||
sender: String,
|
||||
|
|
@ -544,6 +576,7 @@ private fun ContentToPreview() {
|
|||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
|
|
@ -562,6 +595,7 @@ private fun ContentToPreview() {
|
|||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 5f
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
|
|
@ -606,7 +640,8 @@ private fun ContentToPreviewWithReply() {
|
|||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
),
|
||||
inReplyTo = aInReplyToReady(replyContent)
|
||||
inReplyTo = aInReplyToReady(replyContent),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
|
|
@ -625,7 +660,8 @@ private fun ContentToPreviewWithReply() {
|
|||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 5f
|
||||
),
|
||||
inReplyTo = aInReplyToReady(replyContent)
|
||||
inReplyTo = aInReplyToReady(replyContent),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
|
|
@ -699,7 +735,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =
|
||||
|
|
|
|||
|
|
@ -16,8 +16,18 @@
|
|||
|
||||
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 isHighlighted true if the reaction has (also) been sent by the current user.
|
||||
*/
|
||||
|
|
@ -25,4 +35,14 @@ data class AggregatedReaction(
|
|||
val key: String,
|
||||
val count: Int,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@
|
|||
<string name="screen_room_attachment_source_files">"Anhang"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Foto- & Video-Bibliothek"</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_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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@
|
|||
<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_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_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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@
|
|||
<item quantity="one">"%1$d room change"</item>
|
||||
<item quantity="other">"%1$d room changes"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_timeline_less_reactions">"Show less"</string>
|
||||
<plurals name="screen_room_timeline_more_reactions">
|
||||
<item quantity="other">"%1$d more"</item>
|
||||
</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_photo">"Take photo"</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_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_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_retry_send_menu_remove_action">"Remove"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import io.element.android.features.logout.api.LogoutPreferencePresenter
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
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.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.getCurrentUser
|
||||
|
|
@ -43,6 +45,7 @@ class PreferencesRootPresenter @Inject constructor(
|
|||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val buildType: BuildType,
|
||||
private val versionFormatter: VersionFormatter,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Presenter<PreferencesRootState> {
|
||||
|
||||
@Composable
|
||||
|
|
@ -54,6 +57,8 @@ class PreferencesRootPresenter @Inject constructor(
|
|||
initialLoad(matrixUser)
|
||||
}
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
// Session verification status (unknown, not verified, verified)
|
||||
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
|
||||
val sessionIsNotVerified by remember {
|
||||
|
|
@ -67,7 +72,8 @@ class PreferencesRootPresenter @Inject constructor(
|
|||
myUser = matrixUser.value,
|
||||
version = versionFormatter.get(),
|
||||
showCompleteVerification = sessionIsNotVerified,
|
||||
showDeveloperSettings = showDeveloperSettings
|
||||
showDeveloperSettings = showDeveloperSettings,
|
||||
snackbarMessage = snackbarMessage,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
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
|
||||
|
||||
data class PreferencesRootState(
|
||||
|
|
@ -24,5 +25,6 @@ data class PreferencesRootState(
|
|||
val myUser: MatrixUser?,
|
||||
val version: String,
|
||||
val showCompleteVerification: Boolean,
|
||||
val showDeveloperSettings: Boolean
|
||||
val showDeveloperSettings: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@
|
|||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
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(
|
||||
logoutState = aLogoutPreferenceState(),
|
||||
myUser = null,
|
||||
version = "Version 1.1 (1)",
|
||||
showCompleteVerification = true,
|
||||
showDeveloperSettings = true
|
||||
showDeveloperSettings = true,
|
||||
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import androidx.compose.material.icons.outlined.DeveloperMode
|
|||
import androidx.compose.material.icons.outlined.Help
|
||||
import androidx.compose.material.icons.outlined.InsertChart
|
||||
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.ui.Modifier
|
||||
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.theme.components.Divider
|
||||
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.ui.components.MatrixUserProvider
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
|
@ -55,11 +58,20 @@ fun PreferencesRootView(
|
|||
onOpenDeveloperSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
|
||||
// Include pref from other modules
|
||||
PreferenceView(
|
||||
modifier = modifier,
|
||||
onBackPressed = onBackPressed,
|
||||
title = stringResource(id = CommonStrings.common_settings)
|
||||
title = stringResource(id = CommonStrings.common_settings),
|
||||
snackbarHost = {
|
||||
SnackbarHost(snackbarHostState) { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
UserPreferences(state.myUser)
|
||||
if (state.showCompleteVerification) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import com.google.common.truth.Truth.assertThat
|
|||
import io.element.android.features.logout.impl.DefaultLogoutPreferencePresenter
|
||||
import io.element.android.libraries.architecture.Async
|
||||
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.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
|
|
@ -41,7 +42,8 @@ class PreferencesRootPresenterTest {
|
|||
matrixClient,
|
||||
FakeSessionVerificationService(),
|
||||
BuildType.DEBUG,
|
||||
FakeVersionFormatter()
|
||||
FakeVersionFormatter(),
|
||||
SnackbarDispatcher(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import androidx.compose.runtime.State
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
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 dmMember by room.getDirectRoomMember(membersState)
|
||||
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
|
||||
|
||||
when {
|
||||
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
|
||||
canEditTopic -> RoomTopicState.CanAddTopic
|
||||
canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic
|
||||
else -> RoomTopicState.Hidden
|
||||
}
|
||||
}
|
||||
|
|
@ -85,8 +85,8 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
memberCount = room.joinedMemberCount,
|
||||
isEncrypted = room.isEncrypted,
|
||||
canInvite = canInvite,
|
||||
canEdit = canEditAvatar || canEditName || canEditTopic,
|
||||
roomType = roomType.value,
|
||||
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
|
||||
roomType = roomType,
|
||||
roomMemberDetailsState = roomMemberDetailsState,
|
||||
leaveRoomState = leaveRoomState,
|
||||
eventSink = ::handleEvents,
|
||||
|
|
@ -112,20 +112,12 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun getCanInvite(membersState: MatrixRoomMembersState): State<Boolean> {
|
||||
val canInvite = remember(membersState) { mutableStateOf(false) }
|
||||
LaunchedEffect(membersState) {
|
||||
canInvite.value = room.canInvite().getOrElse { false }
|
||||
}
|
||||
return canInvite
|
||||
private fun getCanInvite(membersState: MatrixRoomMembersState) = produceState(false, membersState) {
|
||||
value = room.canInvite().getOrElse { false }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getCanSendStateEvent(membersState: MatrixRoomMembersState, type: StateEventType): State<Boolean> {
|
||||
val canSendEvent = remember(membersState) { mutableStateOf(false) }
|
||||
LaunchedEffect(membersState) {
|
||||
canSendEvent.value = room.canSendStateEvent(type).getOrElse { false }
|
||||
}
|
||||
return canSendEvent
|
||||
private fun getCanSendStateEvent(membersState: MatrixRoomMembersState, type: StateEventType) = produceState(false, membersState) {
|
||||
value = room.canSendStateEvent(type).getOrElse { false }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.Scaffold
|
||||
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.TopAppBar
|
||||
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -102,7 +102,7 @@ fun RoomDetailsEditView(
|
|||
Scaffold(
|
||||
modifier = modifier.clearFocusOnTap(focusManager),
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_room_details_edit_room_title),
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.Scaffold
|
||||
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.Text
|
||||
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.ui.components.CheckableUnresolvedUserRow
|
||||
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
|
||||
|
|
@ -117,7 +117,7 @@ fun RoomInviteMembersTopBar(
|
|||
onBackPressed: () -> Unit = {},
|
||||
onSendPressed: () -> Unit = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreviewDark
|
||||
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.Scaffold
|
||||
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.Text
|
||||
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.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
|
@ -203,7 +203,7 @@ private fun RoomMemberListTopBar(
|
|||
onBackPressed: () -> Unit = {},
|
||||
onInvitePressed: () -> Unit = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -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_title">"Nachrichtenverschlüsselung aktiviert"</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_share_room_title">"Raum teilen"</string>
|
||||
<string name="screen_room_details_updating_room">"Aktualisiere Raum…"</string>
|
||||
|
|
|
|||
|
|
@ -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_title">"Šifrovanie správ je zapnuté"</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_share_room_title">"Zdieľať miestnosť"</string>
|
||||
<string name="screen_room_details_updating_room">"Aktualizácia miestnosti…"</string>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ import app.cash.molecule.RecompositionClock
|
|||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
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.roomdetails.impl.RoomDetailsEvent
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
||||
import io.element.android.features.roomdetails.impl.RoomTopicState
|
||||
|
|
@ -44,13 +47,13 @@ import org.junit.Test
|
|||
@ExperimentalCoroutinesApi
|
||||
class RoomDetailsPresenterTests {
|
||||
|
||||
private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter {
|
||||
private fun aRoomDetailsPresenter(room: MatrixRoom, leaveRoomPresenter: LeaveRoomPresenter = LeaveRoomPresenterFake()): RoomDetailsPresenter {
|
||||
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
|
||||
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
|
||||
return RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMemberId)
|
||||
}
|
||||
}
|
||||
return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, LeaveRoomPresenterFake())
|
||||
return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, leaveRoomPresenter)
|
||||
}
|
||||
|
||||
@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
|
||||
fun `present - initial state when user can edit all attributes`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
|
|
@ -247,6 +308,22 @@ class RoomDetailsPresenterTests {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -231,8 +231,6 @@ fun RoomListContent(
|
|||
SnackbarHost(snackbarHostState) { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
<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_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_title">"Získajte prístup k histórii vašich správ"</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">"Overte, že ste to vy"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -152,7 +152,6 @@ sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extension
|
|||
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
|
||||
sqlite = "androidx.sqlite:sqlite:2.3.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"
|
||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
|
||||
vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
return takeIf { it is R } as R?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,3 +18,7 @@ package io.element.android.libraries.deeplink
|
|||
|
||||
internal const val SCHEME = "elementx"
|
||||
internal const val HOST = "open"
|
||||
|
||||
object DeepLinkPaths {
|
||||
const val INVITE_LIST = "invites"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
|
|||
import javax.inject.Inject
|
||||
|
||||
class DeepLinkCreator @Inject constructor() {
|
||||
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
|
||||
fun room(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
|
||||
return buildString {
|
||||
append("$SCHEME://$HOST/")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.ThreadId
|
||||
|
||||
data class DeeplinkData(
|
||||
val sessionId: SessionId,
|
||||
val roomId: RoomId? = null,
|
||||
val threadId: ThreadId? = null,
|
||||
)
|
||||
sealed interface DeeplinkData {
|
||||
/** Session id is common for all deep links. */
|
||||
val sessionId: SessionId
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.libraries.deeplink
|
|||
|
||||
import android.content.Intent
|
||||
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.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
|
@ -36,12 +37,21 @@ class DeeplinkParser @Inject constructor() {
|
|||
if (host != HOST) return null
|
||||
val pathBits = path.orEmpty().split("/").drop(1)
|
||||
val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null
|
||||
val roomId = pathBits.elementAtOrNull(1)?.let(::RoomId)
|
||||
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId)
|
||||
return DeeplinkData(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
threadId = threadId,
|
||||
)
|
||||
val screenPathComponent = pathBits.elementAtOrNull(1)
|
||||
val roomId = tryOrNull { screenPathComponent?.let(::RoomId) }
|
||||
|
||||
return when {
|
||||
roomId != null -> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +25,20 @@ import org.junit.Test
|
|||
class DeepLinkCreatorTest {
|
||||
|
||||
@Test
|
||||
fun create() {
|
||||
fun room() {
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inviteList() {
|
||||
val sut = DeepLinkCreator()
|
||||
assertThat(sut.inviteList(A_SESSION_ID))
|
||||
.isEqualTo("elementx://open/@alice:server.org/invites")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ class DeeplinkParserTest {
|
|||
"elementx://open/@alice:server.org/!aRoomId:domain"
|
||||
const val A_URI_WITH_ROOM_WITH_THREAD =
|
||||
"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()
|
||||
|
|
@ -43,11 +45,13 @@ class DeeplinkParserTest {
|
|||
@Test
|
||||
fun `nominal cases`() {
|
||||
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)))
|
||||
.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)))
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ android {
|
|||
buildConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
consumerProguardFiles("proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.theme)
|
||||
// Should not be there, but this is a POC
|
||||
|
|
|
|||
4
libraries/designsystem/proguard-rules.pro
vendored
4
libraries/designsystem/proguard-rules.pro
vendored
|
|
@ -18,4 +18,6 @@
|
|||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keep class io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModuleCodegen { }
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ fun PreferenceView(
|
|||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
|
|
@ -64,6 +65,7 @@ fun PreferenceView(
|
|||
onBackPressed = onBackPressed,
|
||||
)
|
||||
},
|
||||
snackbarHost = snackbarHost,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") })
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ import androidx.compose.runtime.State
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -69,14 +68,13 @@ fun SnackbarDispatcher.collectSnackbarMessageAsState(): State<SnackbarMessage?>
|
|||
@Composable
|
||||
fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarMessageText = snackbarMessage?.let {
|
||||
stringResource(id = snackbarMessage.messageResId)
|
||||
}
|
||||
val dispatcher = LocalSnackbarDispatcher.current
|
||||
LaunchedEffect(snackbarMessage) {
|
||||
if (snackbarMessageText == null) return@LaunchedEffect
|
||||
coroutineScope.launch {
|
||||
launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = snackbarMessageText,
|
||||
duration = snackbarMessage.duration,
|
||||
|
|
|
|||
|
|
@ -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.RoomId
|
||||
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(
|
||||
val senderId: UserId,
|
||||
|
|
@ -36,7 +38,60 @@ data class NotificationData(
|
|||
|
||||
data class NotificationEvent(
|
||||
val timestamp: Long,
|
||||
val content: String,
|
||||
val content: NotificationContent,
|
||||
// For images for instance
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,19 +28,19 @@ class NotificationMapper {
|
|||
private val timelineEventMapper = TimelineEventMapper()
|
||||
|
||||
fun map(notificationItem: NotificationItem): NotificationData {
|
||||
return notificationItem.use {
|
||||
return notificationItem.use { item ->
|
||||
NotificationData(
|
||||
senderId = UserId(it.event.senderId()),
|
||||
eventId = EventId(it.event.eventId()),
|
||||
roomId = RoomId(it.roomInfo.id),
|
||||
senderAvatarUrl = it.senderInfo.avatarUrl,
|
||||
senderDisplayName = it.senderInfo.displayName,
|
||||
roomAvatarUrl = it.roomInfo.avatarUrl,
|
||||
roomDisplayName = it.roomInfo.displayName,
|
||||
isDirect = it.roomInfo.isDirect,
|
||||
isEncrypted = it.roomInfo.isEncrypted.orFalse(),
|
||||
isNoisy = it.isNoisy,
|
||||
event = it.event.use { event -> timelineEventMapper.map(event) }
|
||||
senderId = UserId(item.event.senderId()),
|
||||
eventId = EventId(item.event.eventId()),
|
||||
roomId = RoomId(item.roomInfo.id),
|
||||
senderAvatarUrl = item.senderInfo.avatarUrl,
|
||||
senderDisplayName = item.senderInfo.displayName,
|
||||
roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { item.roomInfo.isDirect },
|
||||
roomDisplayName = item.roomInfo.displayName,
|
||||
isDirect = item.roomInfo.isDirect,
|
||||
isEncrypted = item.roomInfo.isEncrypted.orFalse(),
|
||||
isNoisy = item.isNoisy,
|
||||
event = item.event.use { event -> timelineEventMapper.map(event) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ class RustNotificationService(
|
|||
filterByPushRules: Boolean,
|
||||
): Result<NotificationData?> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@
|
|||
|
||||
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.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.MessageType
|
||||
import org.matrix.rustcomponents.sdk.StateEventContent
|
||||
import org.matrix.rustcomponents.sdk.TimelineEvent
|
||||
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) {
|
||||
is TimelineEventType.MessageLike -> content.toContent()
|
||||
is TimelineEventType.State -> content.toContent()
|
||||
}
|
||||
}
|
||||
|
||||
private fun StateEventContent.toContent(): String {
|
||||
private fun StateEventContent.toContent(): NotificationContent.StateEvent {
|
||||
return when (this) {
|
||||
StateEventContent.PolicyRuleRoom -> "PolicyRuleRoom"
|
||||
StateEventContent.PolicyRuleServer -> "PolicyRuleServer"
|
||||
StateEventContent.PolicyRuleUser -> "PolicyRuleUser"
|
||||
StateEventContent.RoomAliases -> "RoomAliases"
|
||||
StateEventContent.RoomAvatar -> "RoomAvatar"
|
||||
StateEventContent.RoomCanonicalAlias -> "RoomCanonicalAlias"
|
||||
StateEventContent.RoomCreate -> "RoomCreate"
|
||||
StateEventContent.RoomEncryption -> "RoomEncryption"
|
||||
StateEventContent.RoomGuestAccess -> "RoomGuestAccess"
|
||||
StateEventContent.RoomHistoryVisibility -> "RoomHistoryVisibility"
|
||||
StateEventContent.RoomJoinRules -> "RoomJoinRules"
|
||||
is StateEventContent.RoomMemberContent -> "$userId is now $membershipState"
|
||||
StateEventContent.RoomName -> "RoomName"
|
||||
StateEventContent.RoomPinnedEvents -> "RoomPinnedEvents"
|
||||
StateEventContent.RoomPowerLevels -> "RoomPowerLevels"
|
||||
StateEventContent.RoomServerAcl -> "RoomServerAcl"
|
||||
StateEventContent.RoomThirdPartyInvite -> "RoomThirdPartyInvite"
|
||||
StateEventContent.RoomTombstone -> "RoomTombstone"
|
||||
StateEventContent.RoomTopic -> "RoomTopic"
|
||||
StateEventContent.SpaceChild -> "SpaceChild"
|
||||
StateEventContent.SpaceParent -> "SpaceParent"
|
||||
StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
|
||||
StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
|
||||
StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
|
||||
StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
|
||||
StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
|
||||
StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
|
||||
StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate
|
||||
StateEventContent.RoomEncryption -> NotificationContent.StateEvent.RoomEncryption
|
||||
StateEventContent.RoomGuestAccess -> NotificationContent.StateEvent.RoomGuestAccess
|
||||
StateEventContent.RoomHistoryVisibility -> NotificationContent.StateEvent.RoomHistoryVisibility
|
||||
StateEventContent.RoomJoinRules -> NotificationContent.StateEvent.RoomJoinRules
|
||||
is StateEventContent.RoomMemberContent -> {
|
||||
NotificationContent.StateEvent.RoomMemberContent(userId, RoomMemberMapper.mapMembership(membershipState))
|
||||
}
|
||||
StateEventContent.RoomName -> NotificationContent.StateEvent.RoomName
|
||||
StateEventContent.RoomPinnedEvents -> NotificationContent.StateEvent.RoomPinnedEvents
|
||||
StateEventContent.RoomPowerLevels -> NotificationContent.StateEvent.RoomPowerLevels
|
||||
StateEventContent.RoomServerAcl -> NotificationContent.StateEvent.RoomServerAcl
|
||||
StateEventContent.RoomThirdPartyInvite -> NotificationContent.StateEvent.RoomThirdPartyInvite
|
||||
StateEventContent.RoomTombstone -> NotificationContent.StateEvent.RoomTombstone
|
||||
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 {
|
||||
when (it) {
|
||||
MessageLikeEventContent.CallAnswer -> "CallAnswer"
|
||||
MessageLikeEventContent.CallCandidates -> "CallCandidates"
|
||||
MessageLikeEventContent.CallHangup -> "CallHangup"
|
||||
MessageLikeEventContent.CallInvite -> "CallInvite"
|
||||
MessageLikeEventContent.KeyVerificationAccept -> "KeyVerificationAccept"
|
||||
MessageLikeEventContent.KeyVerificationCancel -> "KeyVerificationCancel"
|
||||
MessageLikeEventContent.KeyVerificationDone -> "KeyVerificationDone"
|
||||
MessageLikeEventContent.KeyVerificationKey -> "KeyVerificationKey"
|
||||
MessageLikeEventContent.KeyVerificationMac -> "KeyVerificationMac"
|
||||
MessageLikeEventContent.KeyVerificationReady -> "KeyVerificationReady"
|
||||
MessageLikeEventContent.KeyVerificationStart -> "KeyVerificationStart"
|
||||
is MessageLikeEventContent.ReactionContent -> "Reacted to ${it.relatedEventId.take(8)}…"
|
||||
MessageLikeEventContent.RoomEncrypted -> "RoomEncrypted"
|
||||
is MessageLikeEventContent.RoomMessage -> it.messageType.toContent()
|
||||
MessageLikeEventContent.RoomRedaction -> "RoomRedaction"
|
||||
MessageLikeEventContent.Sticker -> "Sticker"
|
||||
MessageLikeEventContent.CallAnswer -> NotificationContent.MessageLike.CallAnswer
|
||||
MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates
|
||||
MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup
|
||||
MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite
|
||||
MessageLikeEventContent.KeyVerificationAccept -> NotificationContent.MessageLike.KeyVerificationAccept
|
||||
MessageLikeEventContent.KeyVerificationCancel -> NotificationContent.MessageLike.KeyVerificationCancel
|
||||
MessageLikeEventContent.KeyVerificationDone -> NotificationContent.MessageLike.KeyVerificationDone
|
||||
MessageLikeEventContent.KeyVerificationKey -> NotificationContent.MessageLike.KeyVerificationKey
|
||||
MessageLikeEventContent.KeyVerificationMac -> NotificationContent.MessageLike.KeyVerificationMac
|
||||
MessageLikeEventContent.KeyVerificationReady -> NotificationContent.MessageLike.KeyVerificationReady
|
||||
MessageLikeEventContent.KeyVerificationStart -> NotificationContent.MessageLike.KeyVerificationStart
|
||||
is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(it.relatedEventId)
|
||||
MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
|
||||
is MessageLikeEventContent.RoomMessage -> {
|
||||
NotificationContent.MessageLike.RoomMessage(EventMessageMapper().mapMessageType(it.messageType))
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,23 +24,30 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.matrix.rustcomponents.sdk.RoomListService
|
||||
import org.matrix.rustcomponents.sdk.RoomListServiceState
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class RustSyncService(
|
||||
private val roomListService: RoomListService,
|
||||
sessionCoroutineScope: CoroutineScope
|
||||
) : SyncService {
|
||||
|
||||
private val isSyncing = AtomicBoolean(false)
|
||||
|
||||
override fun startSync() = runCatching {
|
||||
if (!roomListService.isSyncing()) {
|
||||
if (isSyncing.compareAndSet(false, true)) {
|
||||
Timber.v("Start sync")
|
||||
roomListService.sync()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopSync() = runCatching {
|
||||
if (roomListService.isSyncing()) {
|
||||
if (isSyncing.compareAndSet(true, false)) {
|
||||
Timber.v("Stop sync")
|
||||
roomListService.stopSync()
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +56,10 @@ class RustSyncService(
|
|||
roomListService
|
||||
.stateFlow()
|
||||
.map(RoomListServiceState::toSyncState)
|
||||
.onEach { state ->
|
||||
Timber.v("Sync state=$state")
|
||||
isSyncing.set(state == SyncState.Syncing)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(sessionCoroutineScope, SharingStarted.WhileSubscribed(), SyncState.Idle)
|
||||
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.impl.media.map
|
||||
import org.matrix.rustcomponents.sdk.Message
|
||||
import org.matrix.rustcomponents.sdk.MessageType
|
||||
import org.matrix.rustcomponents.sdk.ProfileDetails
|
||||
import org.matrix.rustcomponents.sdk.RepliedToEventDetails
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
|
||||
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
|
||||
import org.matrix.rustcomponents.sdk.MessageType as RustMessageType
|
||||
|
||||
class EventMessageMapper {
|
||||
|
||||
fun map(message: Message): MessageContent = message.use {
|
||||
val type = it.msgtype().use { type ->
|
||||
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 type = it.msgtype().use(this::mapMessageType)
|
||||
val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId)
|
||||
val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details ->
|
||||
when (details) {
|
||||
|
|
@ -99,6 +68,34 @@ class EventMessageMapper {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ dependencies {
|
|||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
api(projects.libraries.pushproviders.api)
|
||||
api(projects.libraries.pushstore.api)
|
||||
api(projects.libraries.push.api)
|
||||
|
|
@ -52,10 +53,6 @@ dependencies {
|
|||
implementation(projects.services.appnavstate.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.
|
||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@ import com.squareup.anvil.annotations.ContributesBinding
|
|||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
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.NotificationDrawerManager
|
||||
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
|
||||
import io.element.android.libraries.pushproviders.api.Distributor
|
||||
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
|
|
@ -29,13 +28,13 @@ import javax.inject.Inject
|
|||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushService @Inject constructor(
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
|
||||
private val pushersManager: PushersManager,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
private val pushProviders: Set<@JvmSuppressWildcards PushProvider>,
|
||||
) : PushService {
|
||||
override fun notificationStyleChanged() {
|
||||
notificationDrawerManager.notificationStyleChanged()
|
||||
defaultNotificationDrawerManager.notificationStyleChanged()
|
||||
}
|
||||
|
||||
override fun getAvailablePushProviders(): List<PushProvider> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -23,11 +23,16 @@ import io.element.android.libraries.matrix.api.core.ThreadId
|
|||
|
||||
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,
|
||||
roomId: RoomId?,
|
||||
threadId: ThreadId?,
|
||||
): Intent
|
||||
|
||||
/**
|
||||
* Provide an intent to start the application on the invite list.
|
||||
*/
|
||||
fun getInviteListIntent(sessionId: SessionId): Intent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.SingleIn
|
||||
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.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
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.impl.notifications.model.NotifiableEvent
|
||||
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.AppNavigationStateService
|
||||
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.
|
||||
*/
|
||||
@SingleIn(AppScope::class)
|
||||
class NotificationDrawerManager @Inject constructor(
|
||||
class DefaultNotificationDrawerManager @Inject constructor(
|
||||
private val pushDataStore: PushDataStore,
|
||||
private val notifiableEventProcessor: NotifiableEventProcessor,
|
||||
private val notificationRenderer: NotificationRenderer,
|
||||
|
|
@ -58,7 +60,7 @@ class NotificationDrawerManager @Inject constructor(
|
|||
private val dispatchers: CoroutineDispatchers,
|
||||
private val buildMeta: BuildMeta,
|
||||
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.
|
||||
*/
|
||||
|
|
@ -152,12 +154,27 @@ class NotificationDrawerManager @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
|
||||
updateEvents {
|
||||
it.clearMembershipNotificationForSession(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear invitation notification for the provided room.
|
||||
*/
|
||||
fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
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, _ ->
|
||||
action(queuedEvents)
|
||||
}
|
||||
|
|
@ -260,6 +277,6 @@ class NotificationDrawerManager @Inject constructor(
|
|||
}
|
||||
|
||||
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
|
||||
return resolvedEvent.shouldIgnoreMessageEventInRoom(currentAppNavigationState)
|
||||
return resolvedEvent.shouldIgnoreEventInRoom(currentAppNavigationState)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,11 +17,12 @@
|
|||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
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.NotifiableEvent
|
||||
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.shouldIgnoreMessageEventInRoom
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
|
||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -41,7 +42,7 @@ class NotifiableEventProcessor @Inject constructor(
|
|||
val type = when (it) {
|
||||
is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
|
||||
is NotifiableMessageEvent -> when {
|
||||
it.shouldIgnoreMessageEventInRoom(appNavigationState) -> {
|
||||
it.shouldIgnoreEventInRoom(appNavigationState) -> {
|
||||
ProcessedEvent.Type.REMOVE
|
||||
.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
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.RoomId
|
||||
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.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.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.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.systemclock.SystemClock
|
||||
import timber.log.Timber
|
||||
|
|
@ -53,73 +67,163 @@ class NotifiableEventResolver @Inject constructor(
|
|||
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
|
||||
// Restore session
|
||||
val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null
|
||||
// TODO EAx, no need for a session?
|
||||
val notificationData = session.let {// TODO Use make the app crashes
|
||||
it.notificationService().getNotification(
|
||||
val notificationService = session.notificationService()
|
||||
val notificationData = notificationService.getNotification(
|
||||
userId = sessionId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
filterByPushRules = true,
|
||||
)
|
||||
}.fold(
|
||||
{
|
||||
it
|
||||
},
|
||||
{
|
||||
Timber.tag(loggerTag.value).e(it, "Unable to resolve event.")
|
||||
null
|
||||
// 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)
|
||||
filterByPushRules = false,
|
||||
).onFailure {
|
||||
Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.")
|
||||
}.getOrNull()
|
||||
|
||||
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
|
||||
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)
|
||||
|
||||
return notificationData.asNotifiableEvent(sessionId)
|
||||
is NotificationContent.StateEvent.RoomMemberContent -> {
|
||||
if (content.membershipState == RoomMembershipState.INVITE) {
|
||||
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 {
|
||||
return NotifiableMessageEvent(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
noisy = isNoisy,
|
||||
timestamp = event.timestamp,
|
||||
senderName = senderDisplayName,
|
||||
senderId = senderId.value,
|
||||
body = event.content,
|
||||
imageUriString = event.contentUrl,
|
||||
threadId = null,
|
||||
roomName = roomDisplayName,
|
||||
roomIsDirect = isDirect,
|
||||
roomAvatarPath = roomAvatarUrl,
|
||||
senderAvatarPath = senderAvatarUrl,
|
||||
soundName = null,
|
||||
outGoingMessage = false,
|
||||
outGoingMessageFailed = false,
|
||||
isRedacted = false,
|
||||
isUpdated = false
|
||||
)
|
||||
private fun fallbackNotifiableEvent(
|
||||
userId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId
|
||||
) = FallbackNotifiableEvent(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
timestamp = clock.epochMillis(),
|
||||
description = stringProvider.getString(R.string.notification_fallback_content),
|
||||
)
|
||||
|
||||
private fun descriptionFromMessageContent(
|
||||
content: NotificationContent.MessageLike.RoomMessage,
|
||||
): String {
|
||||
return when (val messageType = content.messageType) {
|
||||
is AudioMessageType -> messageType.body
|
||||
is EmoteMessageType -> messageType.body
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO This is a temporary method for EAx.
|
||||
*/
|
||||
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
|
||||
return this ?: NotificationData(
|
||||
eventId = eventId,
|
||||
senderId = UserId("@user:domain"),
|
||||
roomId = roomId,
|
||||
senderAvatarUrl = null,
|
||||
senderDisplayName = null,
|
||||
roomAvatarUrl = null,
|
||||
roomDisplayName = null,
|
||||
isNoisy = false,
|
||||
isEncrypted = false,
|
||||
isDirect = false,
|
||||
event = NotificationEvent(
|
||||
timestamp = clock.epochMillis(),
|
||||
content = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}…",
|
||||
contentUrl = null
|
||||
)
|
||||
)
|
||||
private fun descriptionFromRoomMembershipContent(
|
||||
content: NotificationContent.StateEvent.RoomMemberContent,
|
||||
isDirectRoom: Boolean
|
||||
): String? {
|
||||
return when (content.membershipState) {
|
||||
RoomMembershipState.INVITE -> {
|
||||
if (isDirectRoom) {
|
||||
stringProvider.getString(R.string.notification_invite_body)
|
||||
} else {
|
||||
stringProvider.getString(R.string.notification_room_invite_body)
|
||||
}
|
||||
}
|
||||
else -> 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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ data class NotificationActionIds @Inject constructor(
|
|||
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
|
||||
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_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 push = "${buildMeta.applicationId}.PUSH"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class NotificationBitmapLoader @Inject constructor(
|
|||
return try {
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
|
||||
.transformations(CircleCropTransformation())
|
||||
.build()
|
||||
val result = context.imageLoader.execute(imageRequest)
|
||||
result.drawable?.toBitmap()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import android.content.Intent
|
|||
import androidx.core.app.RemoteInput
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
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.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
|
@ -37,7 +38,7 @@ private val loggerTag = LoggerTag("NotificationBroadcastReceiver", notificationL
|
|||
*/
|
||||
class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager
|
||||
@Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager
|
||||
|
||||
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder
|
||||
//@Inject lateinit var analyticsTracker: AnalyticsTracker
|
||||
|
|
@ -50,24 +51,31 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
|||
Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent")
|
||||
val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return
|
||||
val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId)
|
||||
val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId)
|
||||
when (intent.action) {
|
||||
actionIds.smartReply ->
|
||||
handleSmartReply(intent, context)
|
||||
actionIds.dismissRoom -> if (roomId != null) {
|
||||
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
}
|
||||
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) {
|
||||
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
handleMarkAsRead(sessionId, roomId)
|
||||
}
|
||||
actionIds.join -> if (roomId != null) {
|
||||
notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId)
|
||||
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
handleJoinRoom(sessionId, roomId)
|
||||
}
|
||||
actionIds.reject -> if (roomId != null) {
|
||||
notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId)
|
||||
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
handleRejectRoom(sessionId, roomId)
|
||||
}
|
||||
}
|
||||
|
|
@ -240,6 +248,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
|||
const val KEY_SESSION_ID = "sessionID"
|
||||
const val KEY_ROOM_ID = "roomID"
|
||||
const val KEY_THREAD_ID = "threadID"
|
||||
const val KEY_EVENT_ID = "eventID"
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.SessionId
|
||||
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.NotifiableEvent
|
||||
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 NotifiableMessageEvent -> 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) {
|
||||
is NotifiableMessageEvent -> roomsLeft.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 NotifiableMessageEvent -> 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")
|
||||
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import android.app.Notification
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
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.model.FallbackNotifiableEvent
|
||||
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.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(
|
||||
currentUser: MatrixUser,
|
||||
roomNotifications: List<RoomNotification>,
|
||||
invitationNotifications: List<OneShotNotification>,
|
||||
simpleNotifications: List<OneShotNotification>,
|
||||
fallbackNotifications: List<OneShotNotification>,
|
||||
useCompleteNotificationFormat: Boolean
|
||||
): SummaryNotification {
|
||||
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
|
||||
val invitationMeta = invitationNotifications.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 {
|
||||
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
|
||||
else -> SummaryNotification.Update(
|
||||
|
|
@ -112,6 +132,7 @@ class NotificationFactory @Inject constructor(
|
|||
roomNotifications = roomMeta,
|
||||
invitationNotifications = invitationMeta,
|
||||
simpleNotifications = simpleMeta,
|
||||
fallbackNotifications = fallbackMeta,
|
||||
useCompleteNotificationFormat = useCompleteNotificationFormat
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,12 +37,17 @@ class NotificationIdProvider @Inject constructor() {
|
|||
return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
fun getFallbackNotificationId(sessionId: SessionId): Int {
|
||||
return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
private fun getOffset(sessionId: SessionId): Int {
|
||||
// Compute a int from a string with a low risk of collision.
|
||||
return abs(sessionId.value.hashCode() % 100_000) * 10
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FALLBACK_NOTIFICATION_ID = -1
|
||||
private const val SUMMARY_NOTIFICATION_ID = 0
|
||||
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
|
||||
private const val ROOM_EVENT_NOTIFICATION_ID = 2
|
||||
|
|
|
|||
|
|
@ -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.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.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
|
|
@ -36,16 +37,18 @@ class NotificationRenderer @Inject constructor(
|
|||
useCompleteNotificationFormat: Boolean,
|
||||
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
|
||||
) {
|
||||
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
|
||||
val groupedEvents = eventsToProcess.groupByType()
|
||||
with(notificationFactory) {
|
||||
val roomNotifications = roomEvents.toNotifications(currentUser)
|
||||
val invitationNotifications = invitationEvents.toNotifications()
|
||||
val simpleNotifications = simpleEvents.toNotifications()
|
||||
val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser)
|
||||
val invitationNotifications = groupedEvents.invitationEvents.toNotifications()
|
||||
val simpleNotifications = groupedEvents.simpleEvents.toNotifications()
|
||||
val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications()
|
||||
val summaryNotification = createSummaryNotification(
|
||||
currentUser = currentUser,
|
||||
roomNotifications = roomNotifications,
|
||||
invitationNotifications = invitationNotifications,
|
||||
simpleNotifications = simpleNotifications,
|
||||
fallbackNotifications = fallbackNotifications,
|
||||
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
|
||||
if (summaryNotification is SummaryNotification.Update) {
|
||||
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 simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
|
||||
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList()
|
||||
val fallbackEvents: MutableList<ProcessedEvent<FallbackNotifiableEvent>> = ArrayList()
|
||||
forEach {
|
||||
when (val event = it.event) {
|
||||
is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType())
|
||||
|
|
@ -147,9 +171,12 @@ private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotifica
|
|||
roomEvents.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")
|
||||
|
|
@ -158,5 +185,6 @@ private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventT
|
|||
data class GroupedNotificationEvents(
|
||||
val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>,
|
||||
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
|
||||
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>
|
||||
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>,
|
||||
val fallbackEvents: List<ProcessedEvent<FallbackNotifiableEvent>>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ class NotificationState(
|
|||
) {
|
||||
|
||||
fun <T> updateQueuedEvents(
|
||||
drawerManager: NotificationDrawerManager,
|
||||
action: NotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
|
||||
drawerManager: DefaultNotificationDrawerManager,
|
||||
action: DefaultNotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
|
||||
): T {
|
||||
return synchronized(queuedEvents) {
|
||||
action(drawerManager, queuedEvents, renderedEvents)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@
|
|||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Typeface
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.core.app.NotificationCompat
|
||||
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.user.MatrixUser
|
||||
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.model.NotifiableMessageEvent
|
||||
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 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) {
|
||||
span {
|
||||
span {
|
||||
textStyle = "bold"
|
||||
+String.format("%s: ", event.senderName)
|
||||
buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||
append(event.senderName)
|
||||
append(": ")
|
||||
}
|
||||
+(event.description)
|
||||
append(event.description)
|
||||
}
|
||||
} else {
|
||||
span {
|
||||
span {
|
||||
textStyle = "bold"
|
||||
+String.format("%s: %s ", roomName, event.senderName)
|
||||
buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||
append(roomName)
|
||||
append(": ")
|
||||
event.senderName
|
||||
append(" ")
|
||||
}
|
||||
+(event.description)
|
||||
append(event.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
|
||||
// Use the last event (most recent?)
|
||||
return events.lastOrNull()
|
||||
?.roomAvatarPath
|
||||
return events.reversed().firstNotNullOfOrNull { it.roomAvatarPath }
|
||||
?.let { bitmapLoader.getRoomBitmap(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,12 +49,14 @@ class SummaryGroupMessageCreator @Inject constructor(
|
|||
roomNotifications: List<RoomNotification.Message.Meta>,
|
||||
invitationNotifications: List<OneShotNotification.Append.Meta>,
|
||||
simpleNotifications: List<OneShotNotification.Append.Meta>,
|
||||
fallbackNotifications: List<OneShotNotification.Append.Meta>,
|
||||
useCompleteNotificationFormat: Boolean
|
||||
): Notification {
|
||||
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
|
||||
roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) }
|
||||
invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) }
|
||||
simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) }
|
||||
fallbackNotifications.forEach { style.addLine(it.summaryLine) }
|
||||
}
|
||||
|
||||
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
|
||||
|
|
|
|||
|
|
@ -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.channels.NotificationChannels
|
||||
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.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.SimpleNotifiableEvent
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
|
@ -49,8 +48,6 @@ class NotificationFactory @Inject constructor(
|
|||
private val pendingIntentFactory: PendingIntentFactory,
|
||||
private val markAsReadActionFactory: MarkAsReadActionFactory,
|
||||
private val quickReplyActionFactory: QuickReplyActionFactory,
|
||||
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
|
||||
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
|
||||
) {
|
||||
/**
|
||||
* Create a notification for a Room.
|
||||
|
|
@ -154,22 +151,12 @@ class NotificationFactory @Inject constructor(
|
|||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setColor(accentColor)
|
||||
.addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
.addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
// TODO removed for now, will be added back later
|
||||
// .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
.apply {
|
||||
/*
|
||||
// Build the pending intent for when the notification is clicked
|
||||
val contentIntent = HomeActivity.newIntent(
|
||||
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))
|
||||
|
||||
*/
|
||||
setContentIntent(pendingIntentFactory.createInviteListPendingIntent(inviteNotifiableEvent.sessionId))
|
||||
|
||||
if (inviteNotifiableEvent.noisy) {
|
||||
// Compat
|
||||
|
|
@ -183,6 +170,12 @@ class NotificationFactory @Inject constructor(
|
|||
} else {
|
||||
priority = NotificationCompat.PRIORITY_LOW
|
||||
}
|
||||
setDeleteIntent(
|
||||
pendingIntentFactory.createDismissInvitePendingIntent(
|
||||
inviteNotifiableEvent.sessionId,
|
||||
inviteNotifiableEvent.roomId,
|
||||
)
|
||||
)
|
||||
setAutoCancel(true)
|
||||
}
|
||||
.build()
|
||||
|
|
@ -223,6 +216,39 @@ class NotificationFactory @Inject constructor(
|
|||
.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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ package io.element.android.libraries.push.impl.notifications.factories
|
|||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||
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.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
|
@ -39,19 +41,19 @@ class PendingIntentFactory @Inject constructor(
|
|||
private val actionIds: NotificationActionIds,
|
||||
) {
|
||||
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? {
|
||||
return createPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
|
||||
}
|
||||
|
||||
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? {
|
||||
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
|
||||
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
|
||||
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
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? {
|
||||
val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
|
||||
testActionIntent.action = actionIds.diagnostic
|
||||
|
|
@ -97,4 +128,9 @@ class PendingIntentFactory @Inject constructor(
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -27,8 +27,8 @@ data class InviteNotifiableEvent(
|
|||
override val canBeReplaced: Boolean,
|
||||
val roomName: String?,
|
||||
val noisy: Boolean,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val title: String?,
|
||||
override val description: String,
|
||||
val type: String?,
|
||||
val timestamp: Long,
|
||||
val soundName: String?,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ sealed interface NotifiableEvent : Serializable {
|
|||
val roomId: RoomId
|
||||
val eventId: EventId
|
||||
val editedEventId: EventId?
|
||||
val description: String?
|
||||
|
||||
// Used to know if event should be replaced with the one coming from eventstream
|
||||
val canBeReplaced: Boolean
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
package io.element.android.libraries.push.impl.notifications.model
|
||||
|
||||
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.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
|
@ -54,7 +56,7 @@ data class NotifiableMessageEvent(
|
|||
) : NotifiableEvent {
|
||||
|
||||
val type: String = EventType.MESSAGE
|
||||
val description: String = body ?: ""
|
||||
override val description: String = body ?: ""
|
||||
val title: String = senderName ?: ""
|
||||
|
||||
// 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) }
|
||||
}
|
||||
|
||||
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?
|
||||
): Boolean {
|
||||
val currentSessionId = appNavigationState?.currentSessionId() ?: return false
|
||||
return when (val currentRoomId = appNavigationState.currentRoomId()) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ data class SimpleNotifiableEvent(
|
|||
override val editedEventId: EventId?,
|
||||
val noisy: Boolean,
|
||||
val title: String,
|
||||
val description: String,
|
||||
override val description: String,
|
||||
val type: String?,
|
||||
val timestamp: Long,
|
||||
val soundName: String?,
|
||||
|
|
|
|||
|
|
@ -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.notifications.NotifiableEventResolver
|
||||
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.pushproviders.api.PushData
|
||||
import io.element.android.libraries.pushproviders.api.PushHandler
|
||||
|
|
@ -48,7 +48,7 @@ private val loggerTag = LoggerTag("PushHandler", pushLoggerTag)
|
|||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushHandler @Inject constructor(
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
|
||||
private val notifiableEventResolver: NotifiableEventResolver,
|
||||
private val defaultPushDataStore: DefaultPushDataStore,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
|
|
@ -121,9 +121,9 @@ class DefaultPushHandler @Inject constructor(
|
|||
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")
|
||||
return
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ class DefaultPushHandler @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
notificationDrawerManager.onNotifiableEventReceived(notificationData)
|
||||
defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<string name="notification_channel_listening_for_events">"Listening for events"</string>
|
||||
<string name="notification_channel_noisy">"Noisy 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_invitation_action_join">"Join"</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_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="notification_fallback_content">"Notification"</string>
|
||||
<string name="notification_room_action_quick_reply">"Quick reply"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ class NotifiableEventProcessorTest {
|
|||
@Test
|
||||
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))
|
||||
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
|
||||
|
||||
val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
|
||||
|
|
@ -133,6 +134,7 @@ class NotifiableEventProcessorTest {
|
|||
@Test
|
||||
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))
|
||||
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
|
||||
|
||||
val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ private const val MY_USER_AVATAR_URL = "avatar-url"
|
|||
private const val USE_COMPLETE_NOTIFICATION_FORMAT = true
|
||||
|
||||
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_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed
|
||||
private val A_NOTIFICATION = mockk<Notification>()
|
||||
|
|
@ -202,13 +202,14 @@ class NotificationRendererTest {
|
|||
}
|
||||
|
||||
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(
|
||||
roomNotifications: List<RoomNotification> = emptyList(),
|
||||
invitationNotifications: List<OneShotNotification> = emptyList(),
|
||||
simpleNotifications: List<OneShotNotification> = emptyList(),
|
||||
fallbackNotifications: List<OneShotNotification> = emptyList(),
|
||||
useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT,
|
||||
summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION
|
||||
) {
|
||||
|
|
@ -219,6 +220,7 @@ class NotificationRendererTest {
|
|||
roomNotifications = roomNotifications,
|
||||
invitationNotifications = invitationNotifications,
|
||||
simpleNotifications = simpleNotifications,
|
||||
fallbackNotifications = fallbackNotifications,
|
||||
summaryNotification = summaryNotification
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,12 +36,14 @@ class FakeNotificationFactory {
|
|||
roomNotifications: List<RoomNotification>,
|
||||
invitationNotifications: List<OneShotNotification>,
|
||||
simpleNotifications: List<OneShotNotification>,
|
||||
fallbackNotifications: List<OneShotNotification>,
|
||||
summaryNotification: SummaryNotification
|
||||
) {
|
||||
with(instance) {
|
||||
coEvery { groupedEvents.roomEvents.toNotifications(matrixUser) } returns roomNotifications
|
||||
every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications
|
||||
every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications
|
||||
every { groupedEvents.fallbackEvents.toNotifications() } returns fallbackNotifications
|
||||
|
||||
every {
|
||||
createSummaryNotification(
|
||||
|
|
@ -49,6 +51,7 @@ class FakeNotificationFactory {
|
|||
roomNotifications,
|
||||
invitationNotifications,
|
||||
simpleNotifications,
|
||||
fallbackNotifications,
|
||||
useCompleteNotificationFormat
|
||||
)
|
||||
} returns summaryNotification
|
||||
|
|
|
|||
29
libraries/push/test/build.gradle.kts
Normal file
29
libraries/push/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue