Browse Source

Deeplink: handle notification click to open a room.

test/jme/compound-poc
Benoit Marty 1 year ago
parent
commit
b0f14bfb15
  1. 1
      app/build.gradle.kts
  2. 8
      app/src/main/AndroidManifest.xml
  3. 22
      app/src/main/kotlin/io/element/android/x/MainActivity.kt
  4. 20
      app/src/main/kotlin/io/element/android/x/MainNode.kt
  5. 20
      app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt
  6. 1
      appnav/build.gradle.kts
  7. 17
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
  8. 1
      appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt
  9. 30
      appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
  10. 40
      libraries/deeplink/build.gradle.kts
  11. 39
      libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt
  12. 27
      libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt
  13. 47
      libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt
  14. 4
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt
  15. 1
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt
  16. 19
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt
  17. 28
      tools/adb/deeplink.sh

1
app/build.gradle.kts

@ -206,6 +206,7 @@ dependencies { @@ -206,6 +206,7 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir)
implementation(projects.libraries.deeplink)
implementation(projects.tests.uitests)
implementation(projects.anvilannotations)
implementation(projects.appnav)

8
app/src/main/AndroidManifest.xml

@ -40,6 +40,14 @@ @@ -40,6 +40,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>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="open"
android:scheme="elementx" />
</intent-filter-->
</activity>
<provider

22
app/src/main/kotlin/io/element/android/x/MainActivity.kt

@ -28,6 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -28,6 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.x.di.AppBindings
@ -35,6 +36,8 @@ import timber.log.Timber @@ -35,6 +36,8 @@ import timber.log.Timber
class MainActivity : NodeComponentActivity() {
lateinit var mainNode: MainNode
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@ -44,10 +47,23 @@ class MainActivity : NodeComponentActivity() { @@ -44,10 +47,23 @@ class MainActivity : NodeComponentActivity() {
setContent {
ElementTheme {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode(it, appBindings.mainDaggerComponentOwner())
MainNode(
it,
appBindings.mainDaggerComponentOwner(),
plugins = listOf(
object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) {
mainNode = node
mainNode.handleIntent(intent)
}
}
)
)
}
}
}
@ -63,6 +79,8 @@ class MainActivity : NodeComponentActivity() { @@ -63,6 +79,8 @@ class MainActivity : NodeComponentActivity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
Timber.w("onNewIntent")
intent ?: return
mainNode.handleIntent(intent)
}
override fun onSaveInstanceState(outState: Bundle) {

20
app/src/main/kotlin/io/element/android/x/MainNode.kt

@ -16,14 +16,17 @@ @@ -16,14 +16,17 @@
package io.element.android.x
import android.content.Intent
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.appnav.LoggedInFlowNode
import io.element.android.appnav.RoomFlowNode
import io.element.android.appnav.RootFlowNode
@ -35,11 +38,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom @@ -35,11 +38,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.x.di.MainDaggerComponentsOwner
import io.element.android.x.di.RoomComponent
import io.element.android.x.di.SessionComponent
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
class MainNode(
buildContext: BuildContext,
private val mainDaggerComponentOwner: MainDaggerComponentsOwner,
plugins: List<Plugin>,
) :
ParentNode<MainNode.RootNavTarget>(
navModel = PermanentNavModel(
@ -47,6 +52,7 @@ class MainNode( @@ -47,6 +52,7 @@ class MainNode(
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
),
DaggerComponentOwner by mainDaggerComponentOwner {
@ -73,7 +79,13 @@ class MainNode( @@ -73,7 +79,13 @@ class MainNode(
}
override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node {
return createNode<RootFlowNode>(buildContext, plugins = listOf(loggedInFlowNodeCallback, roomFlowNodeCallback))
return createNode<RootFlowNode>(
context = buildContext,
plugins = listOf(
loggedInFlowNodeCallback,
roomFlowNodeCallback,
)
)
}
@Composable
@ -81,6 +93,12 @@ class MainNode( @@ -81,6 +93,12 @@ class MainNode(
Children(navModel = navModel)
}
fun handleIntent(intent: Intent) {
lifecycleScope.launch {
waitForChildAttached<RootFlowNode>().handleIntent(intent)
}
}
@Parcelize
object RootNavTarget : Parcelable
}

20
app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt

@ -18,7 +18,9 @@ package io.element.android.x.intent @@ -18,7 +18,9 @@ package io.element.android.x.intent
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.deeplink.DeepLinkCreator
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.RoomId
@ -28,17 +30,19 @@ import io.element.android.libraries.push.impl.intent.IntentProvider @@ -28,17 +30,19 @@ import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.x.MainActivity
import javax.inject.Inject
// TODO EAx change to deep-link.
@ContributesBinding(AppScope::class)
class IntentProviderImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val deepLinkCreator: DeepLinkCreator,
) : IntentProvider {
override fun getMainIntent(): Intent {
return Intent(context, MainActivity::class.java)
}
override fun getIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): Intent {
// TODO Handle deeplink or pass parameters
return Intent(context, MainActivity::class.java)
override fun getViewIntent(
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()
}
}
}

1
appnav/build.gradle.kts

@ -43,6 +43,7 @@ dependencies { @@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.deeplink)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)

17
appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

@ -33,6 +33,7 @@ import com.bumble.appyx.core.plugin.plugins @@ -33,6 +33,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@ -56,10 +57,7 @@ import io.element.android.libraries.matrix.api.core.RoomId @@ -56,10 +57,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
import kotlin.coroutines.coroutineContext
@ContributesNode(AppScope::class)
class LoggedInFlowNode @AssistedInject constructor(
@ -217,6 +215,19 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -217,6 +215,19 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
suspend fun attachRoot(): Node {
return attachChild {
backstack.singleTop(NavTarget.RoomList)
}
}
suspend fun attachRoom(roomId: RoomId): RoomFlowNode {
return attachChild {
backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.Room(roomId))
}
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {

1
appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt

@ -18,7 +18,6 @@ package io.element.android.appnav @@ -18,7 +18,6 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe

30
appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
package io.element.android.appnav
import android.app.Activity
import android.content.Intent
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -45,6 +46,8 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint @@ -45,6 +46,8 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@ -65,6 +68,7 @@ class RootFlowNode @AssistedInject constructor( @@ -65,6 +68,7 @@ class RootFlowNode @AssistedInject constructor(
private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val deeplinkParser: DeeplinkParser,
) :
BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack(
@ -207,4 +211,30 @@ class RootFlowNode @AssistedInject constructor( @@ -207,4 +211,30 @@ class RootFlowNode @AssistedInject constructor(
CircularProgressIndicator()
}
}
suspend fun handleIntent(intent: Intent) {
deeplinkParser.getFromIntent(intent)
?.let { navigateTo(it) }
}
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
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)
}
}
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
return attachChild {
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))
}
}
}

40
libraries/deeplink/build.gradle.kts

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
/*
* Copyright (c) 2022 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.deeplink"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.libraries.di)
implementation(libs.dagger)
implementation(libs.androidx.corektx)
implementation(projects.libraries.matrix.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}

39
libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
/*
* 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.deeplink
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 javax.inject.Inject
class DeepLinkCreator @Inject constructor() {
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
return buildString {
append("elementx://open/")
append(sessionId.value)
if (roomId != null) {
append("/")
append(roomId.value)
if (threadId != null) {
append("/")
append(threadId.value)
}
}
}
}
}

27
libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
/*
* 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.deeplink
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,
)

47
libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
/*
* 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.deeplink
import android.content.Intent
import android.net.Uri
import io.element.android.libraries.matrix.api.core.asRoomId
import io.element.android.libraries.matrix.api.core.asSessionId
import io.element.android.libraries.matrix.api.core.asThreadId
import javax.inject.Inject
class DeeplinkParser @Inject constructor() {
fun getFromIntent(intent: Intent): DeeplinkData? {
return intent
.takeIf { it.action == Intent.ACTION_VIEW }
?.data
?.toDeeplinkData()
}
private fun Uri.toDeeplinkData(): DeeplinkData? {
if (scheme != "elementx") return null
if (host != "open") return null
val pathBits = path.orEmpty().split("/").drop(1)
val sessionId = pathBits.elementAtOrNull(0)?.asSessionId() ?: return null
val roomId = pathBits.elementAtOrNull(1)?.asRoomId()
val threadId = pathBits.elementAtOrNull(2)?.asThreadId()
return DeeplinkData(
sessionId = sessionId,
roomId = roomId,
threadId = threadId,
)
}
}

4
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt

@ -25,9 +25,7 @@ interface IntentProvider { @@ -25,9 +25,7 @@ interface IntentProvider {
/**
* Provide an intent to start the application.
*/
fun getMainIntent(): Intent
fun getIntent(
fun getViewIntent(
sessionId: SessionId,
roomId: RoomId?,
threadId: ThreadId?,

1
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt

@ -34,7 +34,6 @@ data class NotificationActionIds @Inject constructor( @@ -34,7 +34,6 @@ 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 tapToView = "${buildMeta.applicationId}.NotificationActions.TAP_TO_VIEW_ACTION"
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
val push = "${buildMeta.applicationId}.PUSH"
}

19
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt

@ -482,15 +482,11 @@ class NotificationUtils @Inject constructor( @@ -482,15 +482,11 @@ class NotificationUtils @Inject constructor(
}
private fun buildOpenRoomIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
val roomIntent = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = null)
roomIntent.action = actionIds.tapToView
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
roomIntent.data = createIgnoredUri("openRoom?$sessionId&$roomId")
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = null)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),
roomIntent,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
@ -498,22 +494,17 @@ class NotificationUtils @Inject constructor( @@ -498,22 +494,17 @@ class NotificationUtils @Inject constructor(
private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
val sessionId = roomInfo.sessionId
val roomId = roomInfo.roomId
val threadIntentTap = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
threadIntentTap.action = actionIds.tapToView
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
threadIntentTap.data = createIgnoredUri("openThread?$sessionId&$roomId&$threadId")
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),
threadIntentTap,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun buildOpenHomePendingIntentForSummary(sessionId: SessionId): PendingIntent {
val intent = intentProvider.getIntent(sessionId = sessionId, roomId = null, threadId = null)
intent.data = createIgnoredUri("tapSummary?$sessionId")
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = null, threadId = null)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),

28
tools/adb/deeplink.sh

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
#! /bin/bash
#
# 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.
#
# Format is:
# elementx://open/{sessionId} to open a session
# elementx://open/{sessionId}/{roomId} to open a room
# elementx://open/{sessionId}/{roomId}/{eventId} to open an event
# Open a session
# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org
# Open a room
adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org
# Open a thread
# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org/\\\$threadId
Loading…
Cancel
Save