Merge branch 'develop' of https://github.com/vector-im/element-x-android into feature/dla_custom_reactions_layout
6
.github/workflows/build.yml
vendored
|
|
@ -34,10 +34,12 @@ jobs:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.6.0
|
uses: gradle/gradle-build-action@v2.6.1
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
|
env:
|
||||||
|
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||||
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
|
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
|
||||||
- name: Upload debug APKs
|
- name: Upload debug APKs
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|
@ -45,7 +47,7 @@ jobs:
|
||||||
name: elementx-debug
|
name: elementx-debug
|
||||||
path: |
|
path: |
|
||||||
app/build/outputs/apk/debug/*.apk
|
app/build/outputs/apk/debug/*.apk
|
||||||
- uses: rnkdsh/action-upload-diawi@v1.5.0
|
- uses: rnkdsh/action-upload-diawi@v1.5.1
|
||||||
id: diawi
|
id: diawi
|
||||||
# Do not fail the whole build if Diawi upload fails
|
# Do not fail the whole build if Diawi upload fails
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
|
||||||
1
.github/workflows/nightly.yml
vendored
|
|
@ -35,6 +35,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
|
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
|
||||||
env:
|
env:
|
||||||
|
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||||
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
|
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
|
||||||
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
|
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
|
||||||
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
|
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
|
||||||
|
|
|
||||||
2
.github/workflows/nightlyReports.yml
vendored
|
|
@ -62,7 +62,7 @@ jobs:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.6.0
|
uses: gradle/gradle-build-action@v2.6.1
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Dependency analysis
|
- name: Dependency analysis
|
||||||
|
|
|
||||||
2
.github/workflows/quality.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.6.0
|
uses: gradle/gradle-build-action@v2.6.1
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Run code quality check suite
|
- name: Run code quality check suite
|
||||||
|
|
|
||||||
2
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
# Add gradle cache, this should speed up the process
|
# Add gradle cache, this should speed up the process
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.6.0
|
uses: gradle/gradle-build-action@v2.6.1
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Record screenshots
|
- name: Record screenshots
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ if [[ -z ${REPO} ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Deleting previous screenshots"
|
||||||
|
./gradlew removeOldSnapshots --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
||||||
|
|
||||||
echo "Record screenshots"
|
echo "Record screenshots"
|
||||||
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/tests.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/gradle-build-action@v2.6.0
|
uses: gradle/gradle-build-action@v2.6.1
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
|
|
||||||
|
|
|
||||||
1
.idea/dictionaries/shared.xml
generated
|
|
@ -2,6 +2,7 @@
|
||||||
<dictionary name="shared">
|
<dictionary name="shared">
|
||||||
<words>
|
<words>
|
||||||
<w>backstack</w>
|
<w>backstack</w>
|
||||||
|
<w>ftue</w>
|
||||||
<w>homeserver</w>
|
<w>homeserver</w>
|
||||||
<w>kover</w>
|
<w>kover</w>
|
||||||
<w>measurables</w>
|
<w>measurables</w>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ appId: ${APP_ID}
|
||||||
- inputText: ${PASSWORD}
|
- inputText: ${PASSWORD}
|
||||||
- pressKey: Enter
|
- pressKey: Enter
|
||||||
- tapOn: "Continue"
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../assertions/assertWelcomeScreenDisplayed.yaml
|
||||||
|
- tapOn: "Continue"
|
||||||
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
|
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
|
||||||
- tapOn: "Not now"
|
- tapOn: "Not now"
|
||||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
appId: ${APP_ID}
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "welcome_screen-title"
|
||||||
|
timeout: 10_000
|
||||||
|
|
@ -209,6 +209,7 @@ dependencies {
|
||||||
implementation(libs.androidx.core)
|
implementation(libs.androidx.core)
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(libs.androidx.lifecycle.runtime)
|
implementation(libs.androidx.lifecycle.runtime)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.startup)
|
implementation(libs.androidx.startup)
|
||||||
implementation(libs.androidx.preference)
|
implementation(libs.androidx.preference)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,18 @@
|
||||||
android:theme="@style/Theme.ElementX"
|
android:theme="@style/Theme.ElementX"
|
||||||
tools:targetApi="33">
|
tools:targetApi="33">
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name='androidx.lifecycle.ProcessLifecycleInitializer'
|
||||||
|
android:value='androidx.startup' />
|
||||||
|
|
||||||
|
</provider>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ dependencies {
|
||||||
implementation(projects.tests.uitests)
|
implementation(projects.tests.uitests)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
|
|
||||||
|
implementation(projects.features.ftue.api)
|
||||||
|
|
||||||
implementation(projects.services.apperror.impl)
|
implementation(projects.services.apperror.impl)
|
||||||
implementation(projects.services.appnavstate.api)
|
implementation(projects.services.appnavstate.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ package io.element.android.appnav
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
|
@ -41,7 +43,6 @@ import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.appnav.loggedin.LoggedInNode
|
import io.element.android.appnav.loggedin.LoggedInNode
|
||||||
import io.element.android.appnav.room.RoomFlowNode
|
import io.element.android.appnav.room.RoomFlowNode
|
||||||
import io.element.android.appnav.room.RoomLoadedFlowNode
|
import io.element.android.appnav.room.RoomLoadedFlowNode
|
||||||
import io.element.android.features.analytics.api.AnalyticsEntryPoint
|
|
||||||
import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||||
import io.element.android.features.invitelist.api.InviteListEntryPoint
|
import io.element.android.features.invitelist.api.InviteListEntryPoint
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
|
|
@ -49,6 +50,8 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||||
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||||
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
||||||
|
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||||
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
|
|
@ -64,13 +67,10 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||||
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
|
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
|
||||||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
|
@ -81,14 +81,14 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
private val roomListEntryPoint: RoomListEntryPoint,
|
private val roomListEntryPoint: RoomListEntryPoint,
|
||||||
private val preferencesEntryPoint: PreferencesEntryPoint,
|
private val preferencesEntryPoint: PreferencesEntryPoint,
|
||||||
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
||||||
private val analyticsOptInEntryPoint: AnalyticsEntryPoint,
|
|
||||||
private val appNavigationStateService: AppNavigationStateService,
|
private val appNavigationStateService: AppNavigationStateService,
|
||||||
private val verifySessionEntryPoint: VerifySessionEntryPoint,
|
private val verifySessionEntryPoint: VerifySessionEntryPoint,
|
||||||
private val inviteListEntryPoint: InviteListEntryPoint,
|
private val inviteListEntryPoint: InviteListEntryPoint,
|
||||||
private val analyticsService: AnalyticsService,
|
private val ftueEntryPoint: FtueEntryPoint,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val notificationDrawerManager: NotificationDrawerManager,
|
private val notificationDrawerManager: NotificationDrawerManager,
|
||||||
|
private val ftueState: FtueState,
|
||||||
snackbarDispatcher: SnackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : BackstackNode<LoggedInFlowNode.NavTarget>(
|
) : BackstackNode<LoggedInFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -99,19 +99,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
plugins = plugins
|
plugins = plugins
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private fun observeAnalyticsState() {
|
|
||||||
analyticsService.didAskUserConsent()
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.onEach { isConsentAsked ->
|
|
||||||
if (isConsentAsked) {
|
|
||||||
backstack.removeLast(NavTarget.AnalyticsOptIn)
|
|
||||||
} else {
|
|
||||||
backstack.push(NavTarget.AnalyticsOptIn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.launchIn(lifecycleScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onOpenBugReport() = Unit
|
fun onOpenBugReport() = Unit
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +123,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
observeAnalyticsState()
|
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
onCreate = {
|
onCreate = {
|
||||||
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
|
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
|
||||||
|
|
@ -146,6 +133,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
// TODO We do not support Space yet, so directly navigate to main space
|
// TODO We do not support Space yet, so directly navigate to main space
|
||||||
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
||||||
loggedInFlowProcessor.observeEvents(coroutineScope)
|
loggedInFlowProcessor.observeEvents(coroutineScope)
|
||||||
|
|
||||||
|
if (ftueState.shouldDisplayFlow.value) {
|
||||||
|
backstack.push(NavTarget.Ftue)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onResume = {
|
onResume = {
|
||||||
syncService.startSync()
|
syncService.startSync()
|
||||||
|
|
@ -209,7 +200,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
object InviteList : NavTarget
|
object InviteList : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object AnalyticsOptIn : NavTarget
|
object Ftue : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -306,8 +297,13 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
.callback(callback)
|
.callback(callback)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
NavTarget.AnalyticsOptIn -> {
|
NavTarget.Ftue -> {
|
||||||
analyticsOptInEntryPoint.createNode(this, buildContext)
|
ftueEntryPoint.nodeBuilder(this, buildContext)
|
||||||
|
.callback(object : FtueEntryPoint.Callback {
|
||||||
|
override fun onFtueFlowFinished() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
}).build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -335,7 +331,11 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
transitionHandler = rememberDefaultTransitionHandler(),
|
transitionHandler = rememberDefaultTransitionHandler(),
|
||||||
)
|
)
|
||||||
|
|
||||||
PermanentChild(navTarget = NavTarget.Permanent)
|
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
|
||||||
|
|
||||||
|
if (!isFtueDisplayed) {
|
||||||
|
PermanentChild(navTarget = NavTarget.Permanent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,11 @@ import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.appnav.di.MatrixClientsHolder
|
import io.element.android.appnav.di.MatrixClientsHolder
|
||||||
import io.element.android.appnav.intent.IntentResolver
|
import io.element.android.appnav.intent.IntentResolver
|
||||||
import io.element.android.appnav.intent.ResolvedIntent
|
import io.element.android.appnav.intent.ResolvedIntent
|
||||||
|
import io.element.android.appnav.root.RootNavStateFlowFactory
|
||||||
import io.element.android.appnav.root.RootPresenter
|
import io.element.android.appnav.root.RootPresenter
|
||||||
import io.element.android.appnav.root.RootView
|
import io.element.android.appnav.root.RootView
|
||||||
import io.element.android.features.login.api.LoginUserStory
|
|
||||||
import io.element.android.features.login.api.oidc.OidcAction
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||||
import io.element.android.features.preferences.api.CacheService
|
|
||||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
|
|
@ -57,29 +56,22 @@ import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
class RootFlowNode @AssistedInject constructor(
|
class RootFlowNode @AssistedInject constructor(
|
||||||
@Assisted val buildContext: BuildContext,
|
@Assisted val buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val cacheService: CacheService,
|
private val navStateFlowFactory: RootNavStateFlowFactory,
|
||||||
private val matrixClientsHolder: MatrixClientsHolder,
|
private val matrixClientsHolder: MatrixClientsHolder,
|
||||||
private val presenter: RootPresenter,
|
private val presenter: RootPresenter,
|
||||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||||
private val intentResolver: IntentResolver,
|
private val intentResolver: IntentResolver,
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
private val loginUserStory: LoginUserStory,
|
|
||||||
) :
|
) :
|
||||||
BackstackNode<RootFlowNode.NavTarget>(
|
BackstackNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -91,26 +83,25 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
matrixClientsHolder.restore(buildContext.savedStateMap)
|
matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
observeLoggedInState()
|
observeNavState()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(state: MutableSavedStateMap) {
|
override fun onSaveInstanceState(state: MutableSavedStateMap) {
|
||||||
super.onSaveInstanceState(state)
|
super.onSaveInstanceState(state)
|
||||||
matrixClientsHolder.save(state)
|
matrixClientsHolder.saveIntoSavedState(state)
|
||||||
|
navStateFlowFactory.saveIntoSavedState(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeLoggedInState() {
|
private fun observeNavState() {
|
||||||
combine(
|
navStateFlowFactory.create(buildContext.savedStateMap)
|
||||||
cacheService.onClearedCacheEventFlow(),
|
.distinctUntilChanged()
|
||||||
isUserLoggedInFlow(),
|
.onEach { navState ->
|
||||||
) { _, isLoggedIn -> isLoggedIn }
|
Timber.v("navState=$navState")
|
||||||
.onEach { isLoggedIn ->
|
if (navState.isLoggedIn) {
|
||||||
Timber.v("isLoggedIn=$isLoggedIn")
|
|
||||||
if (isLoggedIn) {
|
|
||||||
tryToRestoreLatestSession(
|
tryToRestoreLatestSession(
|
||||||
onSuccess = { switchToLoggedInFlow(it) },
|
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
|
||||||
onFailure = { switchToNotLoggedInFlow() }
|
onFailure = { switchToNotLoggedInFlow() }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -120,19 +111,8 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
|
||||||
private fun switchToLoggedInFlow(sessionId: SessionId) {
|
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
|
||||||
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isUserLoggedInFlow(): Flow<Boolean> {
|
|
||||||
return combine(
|
|
||||||
authenticationService.isLoggedIn(),
|
|
||||||
loginUserStory.loginFlowIsDone
|
|
||||||
) { isLoggedIn, loginFlowIsDone ->
|
|
||||||
isLoggedIn && loginFlowIsDone
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun switchToNotLoggedInFlow() {
|
private fun switchToNotLoggedInFlow() {
|
||||||
|
|
@ -145,14 +125,8 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
onFailure: () -> Unit = {},
|
onFailure: () -> Unit = {},
|
||||||
onSuccess: (SessionId) -> Unit = {},
|
onSuccess: (SessionId) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// If the session is already known it'll be restored by the node hierarchy
|
matrixClientsHolder.getOrRestore(sessionId)
|
||||||
if (matrixClientsHolder.knowSession(sessionId)) {
|
.onSuccess {
|
||||||
Timber.v("Session $sessionId already alive, no need to restore.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authenticationService.restoreSession(sessionId)
|
|
||||||
.onSuccess { matrixClient ->
|
|
||||||
matrixClientsHolder.add(matrixClient)
|
|
||||||
Timber.v("Succeed to restore session $sessionId")
|
Timber.v("Succeed to restore session $sessionId")
|
||||||
onSuccess(sessionId)
|
onSuccess(sessionId)
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +178,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class LoggedInFlow(
|
data class LoggedInFlow(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val navId: UUID = UUID.randomUUID(),
|
val navId: Int
|
||||||
) : NavTarget
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
|
@ -278,11 +252,5 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
|
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CacheService.onClearedCacheEventFlow(): Flow<Unit> {
|
|
||||||
return clearedCacheEventFlow
|
|
||||||
.onEach { sessionId -> matrixClientsHolder.remove(sessionId) }
|
|
||||||
.map { }
|
|
||||||
.onStart { emit((Unit)) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,28 @@ package io.element.android.appnav.di
|
||||||
|
|
||||||
import com.bumble.appyx.core.state.MutableSavedStateMap
|
import com.bumble.appyx.core.state.MutableSavedStateMap
|
||||||
import com.bumble.appyx.core.state.SavedStateMap
|
import com.bumble.appyx.core.state.SavedStateMap
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
|
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
|
||||||
|
|
||||||
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) {
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider {
|
||||||
|
|
||||||
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
|
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
|
||||||
|
private val restoreMutex = Mutex()
|
||||||
fun add(matrixClient: MatrixClient) {
|
|
||||||
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeAll() {
|
fun removeAll() {
|
||||||
sessionIdsToMatrixClient.clear()
|
sessionIdsToMatrixClient.clear()
|
||||||
|
|
@ -44,16 +49,21 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
|
||||||
sessionIdsToMatrixClient.remove(sessionId)
|
sessionIdsToMatrixClient.remove(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isEmpty(): Boolean = sessionIdsToMatrixClient.isEmpty()
|
|
||||||
|
|
||||||
fun knowSession(sessionId: SessionId): Boolean = sessionIdsToMatrixClient.containsKey(sessionId)
|
|
||||||
|
|
||||||
fun getOrNull(sessionId: SessionId): MatrixClient? {
|
fun getOrNull(sessionId: SessionId): MatrixClient? {
|
||||||
return sessionIdsToMatrixClient[sessionId]
|
return sessionIdsToMatrixClient[sessionId]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> {
|
||||||
|
return restoreMutex.withLock {
|
||||||
|
when (val matrixClient = getOrNull(sessionId)) {
|
||||||
|
null -> restore(sessionId)
|
||||||
|
else -> Result.success(matrixClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun restore(state: SavedStateMap?) {
|
fun restoreWithSavedState(state: SavedStateMap?) {
|
||||||
Timber.d("Restore state")
|
Timber.d("Restore state")
|
||||||
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also {
|
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also {
|
||||||
Timber.w("Restore with non-empty map")
|
Timber.w("Restore with non-empty map")
|
||||||
|
|
@ -64,21 +74,25 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
|
||||||
// Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
|
// Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
|
||||||
runBlocking {
|
runBlocking {
|
||||||
sessionIds.forEach { sessionId ->
|
sessionIds.forEach { sessionId ->
|
||||||
Timber.d("Restore matrix session: $sessionId")
|
restore(sessionId)
|
||||||
authenticationService.restoreSession(sessionId)
|
|
||||||
.onSuccess { matrixClient ->
|
|
||||||
add(matrixClient)
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
Timber.e("Fail to restore session")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(state: MutableSavedStateMap) {
|
fun saveIntoSavedState(state: MutableSavedStateMap) {
|
||||||
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
|
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
|
||||||
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
|
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
|
||||||
state[SAVE_INSTANCE_KEY] = sessionKeys
|
state[SAVE_INSTANCE_KEY] = sessionKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun restore(sessionId: SessionId): Result<MatrixClient> {
|
||||||
|
Timber.d("Restore matrix session: $sessionId")
|
||||||
|
return authenticationService.restoreSession(sessionId)
|
||||||
|
.onSuccess { matrixClient ->
|
||||||
|
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
Timber.e("Fail to restore session")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.appnav.loggedin
|
package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -32,14 +31,12 @@ fun LoggedInView(
|
||||||
state: LoggedInState,
|
state: LoggedInState,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val activity = LocalContext.current as? Activity
|
val context = LocalContext.current
|
||||||
|
|
||||||
PermissionsView(
|
PermissionsView(
|
||||||
state = state.permissionsState,
|
state = state.permissionsState,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
openSystemSettings = {
|
openSystemSettings = context::openAppSettingsPage
|
||||||
activity?.let { openAppSettingsPage(it) }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,11 @@ import com.bumble.appyx.core.node.node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.appnav.NodeLifecycleCallback
|
import io.element.android.appnav.NodeLifecycleCallback
|
||||||
import io.element.android.appnav.safeRoot
|
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
|
|
@ -92,9 +92,9 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { isLoaded ->
|
.onEach { isLoaded ->
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
backstack.safeRoot(NavTarget.Loaded)
|
backstack.newRoot(NavTarget.Loaded)
|
||||||
} else {
|
} else {
|
||||||
backstack.safeRoot(NavTarget.Loading)
|
backstack.newRoot(NavTarget.Loading)
|
||||||
}
|
}
|
||||||
}.launchIn(lifecycleScope)
|
}.launchIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* 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.appnav.root
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [RootNavState] produced by [RootNavStateFlowFactory].
|
||||||
|
*/
|
||||||
|
data class RootNavState(
|
||||||
|
/**
|
||||||
|
* This value is incremented when a clear cache is done.
|
||||||
|
* Can be useful to track to force ui state to re-render
|
||||||
|
*/
|
||||||
|
val cacheIndex: Int,
|
||||||
|
/**
|
||||||
|
* true if we are currently loggedIn.
|
||||||
|
*/
|
||||||
|
val isLoggedIn: Boolean
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
/*
|
||||||
|
* 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.appnav.root
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.state.MutableSavedStateMap
|
||||||
|
import com.bumble.appyx.core.state.SavedStateMap
|
||||||
|
import io.element.android.appnav.di.MatrixClientsHolder
|
||||||
|
import io.element.android.features.login.api.LoginUserStory
|
||||||
|
import io.element.android.features.preferences.api.CacheService
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFactory.SAVE_INSTANCE_KEY"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is responsible for creating a flow of [RootNavState].
|
||||||
|
* It gathers data from multiple datasource and creates a unique one.
|
||||||
|
*/
|
||||||
|
class RootNavStateFlowFactory @Inject constructor(
|
||||||
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
|
private val cacheService: CacheService,
|
||||||
|
private val matrixClientsHolder: MatrixClientsHolder,
|
||||||
|
private val loginUserStory: LoginUserStory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var currentCacheIndex = 0
|
||||||
|
|
||||||
|
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
|
||||||
|
return combine(
|
||||||
|
cacheIndexFlow(savedStateMap),
|
||||||
|
isUserLoggedInFlow(),
|
||||||
|
) { cacheIndex, isLoggedIn ->
|
||||||
|
RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveIntoSavedState(stateMap: MutableSavedStateMap) {
|
||||||
|
stateMap[SAVE_INSTANCE_KEY] = currentCacheIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a flow of integer, where each time a clear cache is done, we have a new incremented value.
|
||||||
|
*/
|
||||||
|
private fun cacheIndexFlow(savedStateMap: SavedStateMap?): Flow<Int> {
|
||||||
|
val initialCacheIndex = savedStateMap.getCacheIndexOrDefault()
|
||||||
|
return cacheService.clearedCacheEventFlow
|
||||||
|
.onEach { sessionId ->
|
||||||
|
matrixClientsHolder.remove(sessionId)
|
||||||
|
}
|
||||||
|
.toIndexFlow(initialCacheIndex)
|
||||||
|
.onEach { cacheIndex ->
|
||||||
|
currentCacheIndex = cacheIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUserLoggedInFlow(): Flow<Boolean> {
|
||||||
|
return combine(
|
||||||
|
authenticationService.isLoggedIn(),
|
||||||
|
loginUserStory.loginFlowIsDone
|
||||||
|
) { isLoggedIn, loginFlowIsDone ->
|
||||||
|
isLoggedIn && loginFlowIsDone
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a flow of integer that increments the value by one each time a new element is emitted upstream.
|
||||||
|
*/
|
||||||
|
private fun Flow<Any>.toIndexFlow(initialValue: Int): Flow<Int> = flow {
|
||||||
|
var index = initialValue
|
||||||
|
emit(initialValue)
|
||||||
|
collect {
|
||||||
|
emit(++index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SavedStateMap?.getCacheIndexOrDefault(): Int {
|
||||||
|
return this?.get(SAVE_INSTANCE_KEY) as? Int ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,7 @@ import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||||
import io.element.android.libraries.architecture.childNode
|
import io.element.android.libraries.architecture.childNode
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.services.appnavstate.test.NoopAppNavigationStateService
|
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@ class RoomFlowNodeTest {
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
messagesEntryPoint = messagesEntryPoint,
|
messagesEntryPoint = messagesEntryPoint,
|
||||||
roomDetailsEntryPoint = roomDetailsEntryPoint,
|
roomDetailsEntryPoint = roomDetailsEntryPoint,
|
||||||
appNavigationStateService = NoopAppNavigationStateService(),
|
appNavigationStateService = FakeAppNavigationStateService(),
|
||||||
roomMembershipObserver = RoomMembershipObserver()
|
roomMembershipObserver = RoomMembershipObserver()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,8 @@ koverMerged {
|
||||||
name = "Check code coverage of states"
|
name = "Check code coverage of states"
|
||||||
target = kotlinx.kover.api.VerificationTarget.CLASS
|
target = kotlinx.kover.api.VerificationTarget.CLASS
|
||||||
overrideClassFilter {
|
overrideClassFilter {
|
||||||
includes += "*State"
|
includes += "^*State$"
|
||||||
|
excludes += "io.element.android.appnav.root.RootNavState*"
|
||||||
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
|
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
|
||||||
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
|
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
|
||||||
excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
|
excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
|
||||||
|
|
@ -259,6 +260,11 @@ koverMerged {
|
||||||
excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
|
excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
|
||||||
excludes += "io.element.android.features.messages.impl.timeline.components.ExpandableState*"
|
excludes += "io.element.android.features.messages.impl.timeline.components.ExpandableState*"
|
||||||
excludes += "io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*"
|
excludes += "io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*"
|
||||||
|
excludes += "io.element.android.libraries.maplibre.compose.CameraPositionState*"
|
||||||
|
excludes += "io.element.android.libraries.maplibre.compose.SaveableCameraPositionState"
|
||||||
|
excludes += "io.element.android.libraries.maplibre.compose.SymbolState*"
|
||||||
|
excludes += "io.element.android.features.ftue.api.state.*"
|
||||||
|
excludes += "io.element.android.features.ftue.impl.welcome.state.*"
|
||||||
}
|
}
|
||||||
bound {
|
bound {
|
||||||
minValue = 90
|
minValue = 90
|
||||||
|
|
|
||||||
1
changelog.d/880.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Fix sliding sync loop restarts due to expirations.
|
||||||
42
docs/maps.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Use of maps
|
||||||
|
|
||||||
|
<!--- TOC -->
|
||||||
|
|
||||||
|
* [Overview](#overview)
|
||||||
|
* [Local development with MapTiler](#local-development-with-maptiler)
|
||||||
|
* [Making releasable builds with MapTiler](#making-releasable-builds-with-maptiler)
|
||||||
|
* [Using other map sources or MapTiler styles](#using-other-map-sources-or-maptiler-styles)
|
||||||
|
|
||||||
|
<!--- END -->
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Element Android uses [MapTiler](https://www.maptiler.com/) to provide map
|
||||||
|
imagery where required. MapTiler requires an API key, which we bake in to
|
||||||
|
the app at release time.
|
||||||
|
|
||||||
|
## Local development with MapTiler
|
||||||
|
|
||||||
|
If you're developing the application and want maps to render properly you can
|
||||||
|
sign up for the [MapTiler free tier](https://www.maptiler.com/cloud/pricing/).
|
||||||
|
|
||||||
|
Place your API key in `local.properties` with the key
|
||||||
|
`services.maptiler.apikey`, e.g.:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
services.maptiler.apikey=abCd3fGhijK1mN0pQr5t
|
||||||
|
```
|
||||||
|
|
||||||
|
## Making releasable builds with MapTiler
|
||||||
|
|
||||||
|
To insert the MapTiler API key when building an APK, set the
|
||||||
|
`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build
|
||||||
|
environment.
|
||||||
|
|
||||||
|
## Using other map sources or MapTiler styles
|
||||||
|
|
||||||
|
If you wish to use an alternative map provider, or custom MapTiler styles,
|
||||||
|
you can customise the functions in
|
||||||
|
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`.
|
||||||
|
We've kept this file small and self contained to minimise the chances of merge
|
||||||
|
collisions in forks.
|
||||||
|
|
@ -16,12 +16,11 @@
|
||||||
|
|
||||||
package io.element.android.features.analytics.impl
|
package io.element.android.features.analytics.impl
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
|
@ -48,6 +47,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
|
@ -60,6 +61,7 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnalyticsOptInView(
|
fun AnalyticsOptInView(
|
||||||
|
|
@ -69,6 +71,16 @@ fun AnalyticsOptInView(
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "Analytics", msg = "Root")
|
LogCompositions(tag = "Analytics", msg = "Root")
|
||||||
val eventSink = state.eventSink
|
val eventSink = state.eventSink
|
||||||
|
|
||||||
|
fun onTermsAccepted() {
|
||||||
|
eventSink(AnalyticsOptInEvents.EnableAnalytics(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTermsDeclined() {
|
||||||
|
eventSink(AnalyticsOptInEvents.EnableAnalytics(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = ::onTermsDeclined)
|
||||||
HeaderFooterPage(
|
HeaderFooterPage(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -76,7 +88,13 @@ fun AnalyticsOptInView(
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
header = { AnalyticsOptInHeader(state, onClickTerms) },
|
header = { AnalyticsOptInHeader(state, onClickTerms) },
|
||||||
content = { AnalyticsOptInContent() },
|
content = { AnalyticsOptInContent() },
|
||||||
footer = { AnalyticsOptInFooter(eventSink) })
|
footer = {
|
||||||
|
AnalyticsOptInFooter(
|
||||||
|
onTermsAccepted = ::onTermsAccepted,
|
||||||
|
onTermsDeclined = ::onTermsDeclined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -114,6 +132,19 @@ private fun AnalyticsOptInHeader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CheckIcon(modifier: Modifier = Modifier) {
|
||||||
|
Icon(
|
||||||
|
modifier = modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
|
||||||
|
.padding(2.dp),
|
||||||
|
imageVector = Icons.Rounded.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.textActionAccent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnalyticsOptInContent(
|
private fun AnalyticsOptInContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -125,80 +156,45 @@ private fun AnalyticsOptInContent(
|
||||||
verticalBias = -0.4f
|
verticalBias = -0.4f
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
InfoListOrganism(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
items = persistentListOf(
|
||||||
) {
|
InfoListItem(
|
||||||
AnalyticsOptInContentRow(
|
message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
|
||||||
text = stringResource(id = R.string.screen_analytics_prompt_data_usage),
|
iconComposable = { CheckIcon() },
|
||||||
idx = 0
|
),
|
||||||
)
|
InfoListItem(
|
||||||
AnalyticsOptInContentRow(
|
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
|
||||||
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
|
iconComposable = { CheckIcon() },
|
||||||
idx = 1
|
),
|
||||||
)
|
InfoListItem(
|
||||||
AnalyticsOptInContentRow(
|
message = stringResource(id = R.string.screen_analytics_prompt_settings),
|
||||||
text = stringResource(id = R.string.screen_analytics_prompt_settings),
|
iconComposable = { CheckIcon() },
|
||||||
idx = 2
|
),
|
||||||
)
|
),
|
||||||
}
|
textStyle = ElementTheme.typography.fontBodyMdMedium,
|
||||||
}
|
iconTint = ElementTheme.colors.textPrimary,
|
||||||
}
|
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AnalyticsOptInContentRow(
|
|
||||||
text: String,
|
|
||||||
idx: Int,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val radius = 14.dp
|
|
||||||
val bgShape = when (idx) {
|
|
||||||
0 -> RoundedCornerShape(topStart = radius, topEnd = radius)
|
|
||||||
2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
|
|
||||||
else -> RoundedCornerShape(0.dp)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
color = ElementTheme.colors.temporaryColorBgSpecial,
|
|
||||||
shape = bgShape,
|
|
||||||
)
|
|
||||||
.padding(vertical = 12.dp, horizontal = 20.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(20.dp)
|
|
||||||
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
|
|
||||||
.padding(2.dp),
|
|
||||||
imageVector = Icons.Rounded.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = ElementTheme.colors.textActionAccent,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
text = text,
|
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnalyticsOptInFooter(
|
private fun AnalyticsOptInFooter(
|
||||||
eventSink: (AnalyticsOptInEvents) -> Unit,
|
onTermsAccepted: () -> Unit,
|
||||||
|
onTermsDeclined: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ButtonColumnMolecule(
|
ButtonColumnMolecule(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
|
onClick = onTermsAccepted,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(id = CommonStrings.action_ok))
|
Text(text = stringResource(id = CommonStrings.action_ok))
|
||||||
}
|
}
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
|
onClick = onTermsDeclined,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(id = CommonStrings.action_not_now))
|
Text(text = stringResource(id = CommonStrings.action_not_now))
|
||||||
|
|
|
||||||
|
|
@ -67,4 +67,8 @@ class FakeAnalyticsService(
|
||||||
|
|
||||||
override fun trackError(throwable: Throwable) {
|
override fun trackError(throwable: Throwable) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun reset() {
|
||||||
|
didAskUserConsentFlow.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
features/ftue/api/build.gradle.kts
Normal file
|
|
@ -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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.features.ftue.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.api
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
|
||||||
|
interface FtueEntryPoint : FeatureEntryPoint {
|
||||||
|
|
||||||
|
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||||
|
|
||||||
|
interface NodeBuilder {
|
||||||
|
fun callback(callback: Callback): NodeBuilder
|
||||||
|
fun build(): Node
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onFtueFlowFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.features.ftue.api.state
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface FtueState {
|
||||||
|
val shouldDisplayFlow: StateFlow<Boolean>
|
||||||
|
|
||||||
|
suspend fun reset()
|
||||||
|
}
|
||||||
55
features/ftue/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* 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-compose-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
id("kotlin-parcelize")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.features.ftue.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.anvilannotations)
|
||||||
|
anvil(projects.anvilcodegen)
|
||||||
|
api(projects.features.ftue.api)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.matrixui)
|
||||||
|
implementation(projects.libraries.designsystem)
|
||||||
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.libraries.testtags)
|
||||||
|
implementation(projects.features.analytics.api)
|
||||||
|
implementation(projects.services.analytics.api)
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
|
testImplementation(libs.molecule.runtime)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.features.analytics.test)
|
||||||
|
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.impl
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint {
|
||||||
|
|
||||||
|
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
|
||||||
|
val plugins = ArrayList<Plugin>()
|
||||||
|
|
||||||
|
return object : FtueEntryPoint.NodeBuilder {
|
||||||
|
|
||||||
|
override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder {
|
||||||
|
plugins += callback
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun build(): Node {
|
||||||
|
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.impl
|
||||||
|
|
||||||
|
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.lifecycle.subscribe
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.features.analytics.api.AnalyticsEntryPoint
|
||||||
|
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||||
|
import io.element.android.features.ftue.impl.state.DefaultFtueState
|
||||||
|
import io.element.android.features.ftue.impl.state.FtueStep
|
||||||
|
import io.element.android.features.ftue.impl.welcome.WelcomeNode
|
||||||
|
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.di.AppScope
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
class FtueFlowNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val ftueState: DefaultFtueState,
|
||||||
|
private val analyticsEntryPoint: AnalyticsEntryPoint,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
|
) : BackstackNode<FtueFlowNode.NavTarget>(
|
||||||
|
backstack = BackStack(
|
||||||
|
initialElement = NavTarget.Placeholder,
|
||||||
|
savedStateMap = buildContext.savedStateMap,
|
||||||
|
backPressHandler = NoOpBackstackHandlerStrategy<NavTarget>(),
|
||||||
|
),
|
||||||
|
buildContext = buildContext,
|
||||||
|
plugins = plugins,
|
||||||
|
) {
|
||||||
|
|
||||||
|
sealed interface NavTarget : Parcelable {
|
||||||
|
@Parcelize
|
||||||
|
object Placeholder : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
object WelcomeScreen : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
object AnalyticsOptIn : NavTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
|
||||||
|
|
||||||
|
override fun onBuilt() {
|
||||||
|
super.onBuilt()
|
||||||
|
|
||||||
|
lifecycle.subscribe(onCreate = {
|
||||||
|
lifecycleScope.launch { moveToNextStep() }
|
||||||
|
})
|
||||||
|
|
||||||
|
analyticsService.didAskUserConsent()
|
||||||
|
.drop(1) // We only care about consent passing from not asked to asked state
|
||||||
|
.onEach { didAskUserConsent ->
|
||||||
|
if (didAskUserConsent) {
|
||||||
|
lifecycleScope.launch { moveToNextStep() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(lifecycleScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
return when (navTarget) {
|
||||||
|
NavTarget.Placeholder -> {
|
||||||
|
createNode<PlaceholderNode>(buildContext)
|
||||||
|
}
|
||||||
|
NavTarget.WelcomeScreen -> {
|
||||||
|
val callback = object : WelcomeNode.Callback {
|
||||||
|
override fun onContinueClicked() {
|
||||||
|
ftueState.setWelcomeScreenShown()
|
||||||
|
lifecycleScope.launch { moveToNextStep() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<WelcomeNode>(buildContext, listOf(callback))
|
||||||
|
}
|
||||||
|
NavTarget.AnalyticsOptIn -> {
|
||||||
|
analyticsEntryPoint.createNode(this, buildContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun moveToNextStep() {
|
||||||
|
when (ftueState.getNextStep()) {
|
||||||
|
is FtueStep.WelcomeScreen -> {
|
||||||
|
backstack.newRoot(NavTarget.WelcomeScreen)
|
||||||
|
}
|
||||||
|
is FtueStep.AnalyticsOptIn -> {
|
||||||
|
backstack.replace(NavTarget.AnalyticsOptIn)
|
||||||
|
}
|
||||||
|
null -> callback?.onFtueFlowFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
Children(
|
||||||
|
navModel = backstack,
|
||||||
|
modifier = modifier,
|
||||||
|
transitionHandler = rememberDefaultTransitionHandler(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
class PlaceholderNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
) : Node(buildContext, plugins = plugins)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {
|
||||||
|
override val canHandleBackPressFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
||||||
|
|
||||||
|
override fun onBackPressed() {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.impl.state
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
|
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultFtueState @Inject constructor(
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val welcomeScreenState: WelcomeScreenState,
|
||||||
|
) : FtueState {
|
||||||
|
|
||||||
|
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
|
||||||
|
|
||||||
|
override suspend fun reset() {
|
||||||
|
welcomeScreenState.reset()
|
||||||
|
analyticsService.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
analyticsService.didAskUserConsent()
|
||||||
|
.onEach { updateState() }
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||||
|
when (currentStep) {
|
||||||
|
null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
|
||||||
|
FtueStep.WelcomeScreen
|
||||||
|
)
|
||||||
|
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
|
||||||
|
FtueStep.AnalyticsOptIn
|
||||||
|
)
|
||||||
|
FtueStep.AnalyticsOptIn -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAnyStepIncomplete(): Boolean {
|
||||||
|
return listOf(
|
||||||
|
shouldDisplayWelcomeScreen(),
|
||||||
|
needsAnalyticsOptIn()
|
||||||
|
).any { it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun needsAnalyticsOptIn(): Boolean {
|
||||||
|
// We need this function to not be suspend, so we need to load the value through runBlocking
|
||||||
|
return runBlocking { analyticsService.didAskUserConsent().first().not() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldDisplayWelcomeScreen(): Boolean {
|
||||||
|
return welcomeScreenState.isWelcomeScreenNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWelcomeScreenShown() {
|
||||||
|
welcomeScreenState.setWelcomeScreenShown()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
|
internal fun updateState() {
|
||||||
|
shouldDisplayFlow.value = isAnyStepIncomplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface FtueStep {
|
||||||
|
object WelcomeScreen : FtueStep
|
||||||
|
object AnalyticsOptIn : FtueStep
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.impl.welcome
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
class WelcomeNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onContinueClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onContinueClicked() {
|
||||||
|
plugins.filterIsInstance<Callback>().forEach { it.onContinueClicked() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
WelcomeView(
|
||||||
|
applicationName = buildMeta.applicationName,
|
||||||
|
onContinueClicked = ::onContinueClicked,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.impl.welcome
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AddComment
|
||||||
|
import androidx.compose.material.icons.outlined.Lock
|
||||||
|
import androidx.compose.material.icons.outlined.NewReleases
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.ftue.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||||
|
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
|
||||||
|
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||||
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.testtags.TestTags
|
||||||
|
import io.element.android.libraries.testtags.testTag
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WelcomeView(
|
||||||
|
applicationName: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onContinueClicked: () -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onContinueClicked)
|
||||||
|
OnBoardingPage(
|
||||||
|
modifier = modifier
|
||||||
|
.systemBarsPadding()
|
||||||
|
.fillMaxSize(),
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(78.dp))
|
||||||
|
ElementLogoAtom(size = ElementLogoAtomSize.Medium)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.testTag(TestTags.welcomeScreenTitle),
|
||||||
|
text = stringResource(R.string.screen_welcome_title, applicationName),
|
||||||
|
style = ElementTheme.typography.fontHeadingMdBold,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.screen_welcome_subtitle),
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
InfoListOrganism(
|
||||||
|
items = listItems(),
|
||||||
|
textStyle = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
iconTint = ElementTheme.colors.iconSecondary,
|
||||||
|
backgroundColor = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer = {
|
||||||
|
Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) {
|
||||||
|
Text(text = stringResource(CommonStrings.action_continue))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun listItems() = persistentListOf(
|
||||||
|
InfoListItem(
|
||||||
|
message = stringResource(R.string.screen_welcome_bullet_1),
|
||||||
|
iconVector = Icons.Outlined.NewReleases,
|
||||||
|
),
|
||||||
|
InfoListItem(
|
||||||
|
message = stringResource(R.string.screen_welcome_bullet_2),
|
||||||
|
iconVector = Icons.Outlined.Lock,
|
||||||
|
),
|
||||||
|
InfoListItem(
|
||||||
|
message = stringResource(R.string.screen_welcome_bullet_3),
|
||||||
|
iconVector = Icons.Outlined.AddComment,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@DayNightPreviews
|
||||||
|
@Composable
|
||||||
|
internal fun WelcomeViewPreview() {
|
||||||
|
ElementPreview {
|
||||||
|
WelcomeView(applicationName = "Element X", onContinueClicked = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.impl.welcome.state
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.DefaultPreferences
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class AndroidWelcomeScreenState @Inject constructor(
|
||||||
|
@DefaultPreferences private val sharedPreferences: SharedPreferences,
|
||||||
|
) : WelcomeScreenState {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isWelcomeScreenNeeded(): Boolean {
|
||||||
|
return sharedPreferences.getBoolean(IS_WELCOME_SCREEN_SHOWN, false).not()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setWelcomeScreenShown() {
|
||||||
|
sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reset() {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove(IS_WELCOME_SCREEN_SHOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.impl.welcome.state
|
||||||
|
|
||||||
|
interface WelcomeScreenState {
|
||||||
|
fun isWelcomeScreenNeeded(): Boolean
|
||||||
|
fun setWelcomeScreenShown()
|
||||||
|
fun reset()
|
||||||
|
}
|
||||||
9
features/ftue/impl/src/main/res/values/localazy.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string>
|
||||||
|
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms won’t be available in this update."</string>
|
||||||
|
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
||||||
|
<string name="screen_welcome_button">"Let\'s go!"</string>
|
||||||
|
<string name="screen_welcome_subtitle">"Here’s what you need to know:"</string>
|
||||||
|
<string name="screen_welcome_title">"Welcome to %1$s!"</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* 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.ftue.impl
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||||
|
import io.element.android.features.ftue.impl.state.DefaultFtueState
|
||||||
|
import io.element.android.features.ftue.impl.state.FtueStep
|
||||||
|
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultFtueStateTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given any check being false, should display flow is true`() = runTest {
|
||||||
|
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||||
|
val state = createState(coroutineScope)
|
||||||
|
|
||||||
|
assertThat(state.shouldDisplayFlow.value).isTrue()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
coroutineScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given all checks being true, should display flow is false`() = runTest {
|
||||||
|
val welcomeState = FakeWelcomeState()
|
||||||
|
val analyticsService = FakeAnalyticsService()
|
||||||
|
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||||
|
|
||||||
|
val state = createState(coroutineScope, welcomeState, analyticsService)
|
||||||
|
|
||||||
|
welcomeState.setWelcomeScreenShown()
|
||||||
|
analyticsService.setDidAskUserConsent()
|
||||||
|
state.updateState()
|
||||||
|
|
||||||
|
assertThat(state.shouldDisplayFlow.value).isFalse()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
coroutineScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `traverse flow`() = runTest {
|
||||||
|
val welcomeState = FakeWelcomeState()
|
||||||
|
val analyticsService = FakeAnalyticsService()
|
||||||
|
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||||
|
|
||||||
|
val state = createState(coroutineScope, welcomeState, analyticsService)
|
||||||
|
val steps = mutableListOf<FtueStep?>()
|
||||||
|
|
||||||
|
// First step, welcome screen
|
||||||
|
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||||
|
welcomeState.setWelcomeScreenShown()
|
||||||
|
|
||||||
|
// Second step, analytics opt in
|
||||||
|
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||||
|
analyticsService.setDidAskUserConsent()
|
||||||
|
|
||||||
|
// Final step (null)
|
||||||
|
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||||
|
|
||||||
|
assertThat(steps).containsExactly(
|
||||||
|
FtueStep.WelcomeScreen,
|
||||||
|
FtueStep.AnalyticsOptIn,
|
||||||
|
null, // Final state
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
coroutineScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `if a check for a step is true, start from the next one`() = runTest {
|
||||||
|
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||||
|
val analyticsService = FakeAnalyticsService()
|
||||||
|
val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService)
|
||||||
|
|
||||||
|
state.setWelcomeScreenShown()
|
||||||
|
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||||
|
|
||||||
|
analyticsService.setDidAskUserConsent()
|
||||||
|
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
coroutineScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createState(
|
||||||
|
coroutineScope: CoroutineScope,
|
||||||
|
welcomeState: FakeWelcomeState = FakeWelcomeState(),
|
||||||
|
analyticsService: AnalyticsService = FakeAnalyticsService()
|
||||||
|
) = DefaultFtueState(coroutineScope, analyticsService, welcomeState)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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.ftue.impl.welcome.state
|
||||||
|
|
||||||
|
class FakeWelcomeState : WelcomeScreenState {
|
||||||
|
|
||||||
|
private var isWelcomeScreenNeeded = true
|
||||||
|
|
||||||
|
override fun isWelcomeScreenNeeded(): Boolean {
|
||||||
|
return isWelcomeScreenNeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setWelcomeScreenShown() {
|
||||||
|
isWelcomeScreenNeeded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reset() {
|
||||||
|
isWelcomeScreenNeeded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,14 +14,33 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun readLocalProperty(name: String) = Properties().apply {
|
||||||
|
try {
|
||||||
|
load(rootProject.file("local.properties").reader())
|
||||||
|
} catch (ignored: java.io.IOException) {
|
||||||
|
}
|
||||||
|
}[name]
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.location.api"
|
namespace = "io.element.android.features.location.api"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
resValue(
|
||||||
|
type = "string",
|
||||||
|
name = "maptiler_api_key",
|
||||||
|
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
|
||||||
|
?: readLocalProperty("services.maptiler.apikey") as? String
|
||||||
|
?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImagePainter
|
import coil.compose.AsyncImagePainter
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
import coil.request.ImageRequest
|
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.StaticMapPlaceholder
|
||||||
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
|
import io.element.android.features.location.api.internal.staticMapUrl
|
||||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
|
|
@ -64,6 +63,7 @@ fun StaticMapView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
var retryHash by remember { mutableStateOf(0) }
|
var retryHash by remember { mutableStateOf(0) }
|
||||||
val painter = rememberAsyncImagePainter(
|
val painter = rememberAsyncImagePainter(
|
||||||
model = if (constraints.isZero) {
|
model = if (constraints.isZero) {
|
||||||
|
|
@ -72,17 +72,16 @@ fun StaticMapView(
|
||||||
} else {
|
} else {
|
||||||
ImageRequest.Builder(LocalContext.current)
|
ImageRequest.Builder(LocalContext.current)
|
||||||
.data(
|
.data(
|
||||||
buildStaticMapsApiUrl(
|
staticMapUrl(
|
||||||
|
context = context,
|
||||||
lat = lat,
|
lat = lat,
|
||||||
lon = lon,
|
lon = lon,
|
||||||
desiredZoom = zoom,
|
zoom = zoom,
|
||||||
darkMode = darkMode,
|
darkMode = darkMode,
|
||||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
|
||||||
// Size the map based on DP rather than pixels, as otherwise the features and attribution
|
// Size the map based on DP rather than pixels, as otherwise the features and attribution
|
||||||
// end up being illegibly tiny on high density displays.
|
// end up being illegibly tiny on high density displays.
|
||||||
desiredWidth = constraints.maxWidth.toDp().value.toInt(),
|
width = constraints.maxWidth.toDp().value.toInt(),
|
||||||
desiredHeight = constraints.maxHeight.toDp().value.toInt(),
|
height = constraints.maxHeight.toDp().value.toInt(),
|
||||||
doubleScale = true,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
||||||
|
|
@ -119,7 +118,6 @@ fun StaticMapView(
|
||||||
showProgress = painter.state is AsyncImagePainter.State.Loading,
|
showProgress = painter.state is AsyncImagePainter.State.Loading,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = Modifier.size(width = maxWidth, height = maxHeight),
|
modifier = Modifier.size(width = maxWidth, height = maxHeight),
|
||||||
darkMode = darkMode,
|
|
||||||
onLoadMapClick = { retryHash++ }
|
onLoadMapClick = { retryHash++ }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* 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.location.api.internal
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.element.android.features.location.api.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the URL to an image that contains a statically-generated map of the given location.
|
||||||
|
*/
|
||||||
|
fun staticMapUrl(
|
||||||
|
context: Context,
|
||||||
|
lat: Double,
|
||||||
|
lon: Double,
|
||||||
|
zoom: Double,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
darkMode: Boolean,
|
||||||
|
): String {
|
||||||
|
return "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the URL to a MapLibre style document, used for rendering dynamic maps.
|
||||||
|
*/
|
||||||
|
fun tileStyleUrl(
|
||||||
|
context: Context,
|
||||||
|
darkMode: Boolean,
|
||||||
|
): String {
|
||||||
|
return "${baseUrl(darkMode)}/style.json?key=${context.apiKey}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun baseUrl(darkMode: Boolean) =
|
||||||
|
"https://api.maptiler.com/maps/" +
|
||||||
|
if (darkMode)
|
||||||
|
"dea61faf-292b-4774-9660-58fcef89a7f3"
|
||||||
|
else
|
||||||
|
"9bc819c8-e627-474a-a348-ec144fe3d810"
|
||||||
|
|
||||||
|
private val Context.apiKey: String
|
||||||
|
get() = getString(R.string.maptiler_api_key)
|
||||||
|
|
@ -1,91 +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.features.location.api.internal
|
|
||||||
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private const val API_KEY = "fU3vlMsMn4Jb6dnEIFsx"
|
|
||||||
private const val BASE_URL = "https://api.maptiler.com"
|
|
||||||
private const val LIGHT_MAP_ID = "9bc819c8-e627-474a-a348-ec144fe3d810"
|
|
||||||
private const val DARK_MAP_ID = "dea61faf-292b-4774-9660-58fcef89a7f3"
|
|
||||||
private const val STATIC_MAP_FORMAT = "webp"
|
|
||||||
private const val STATIC_MAP_SCALE_2X = "@2x"
|
|
||||||
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048
|
|
||||||
private const val STATIC_MAP_MAX_ZOOM = 22.0
|
|
||||||
|
|
||||||
fun buildTileServerUrl(
|
|
||||||
darkMode: Boolean
|
|
||||||
): String = if (!darkMode) {
|
|
||||||
"$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY"
|
|
||||||
} else {
|
|
||||||
"$BASE_URL/maps/$DARK_MAP_ID/style.json?key=$API_KEY"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal enum class AttributionPlacement(val value: String) {
|
|
||||||
BottomRight("bottomright"),
|
|
||||||
BottomLeft("bottomleft"),
|
|
||||||
TopLeft("topleft"),
|
|
||||||
TopRight("topright"),
|
|
||||||
Hidden("false"),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a valid URL for maptiler.com static map api based on the given params.
|
|
||||||
*
|
|
||||||
* Coerces width and height to the API maximum of 2048 keeping the requested aspect ratio.
|
|
||||||
* Coerces zoom to the API maximum of 22.
|
|
||||||
*
|
|
||||||
* NB: This will throw if either width or height are <= 0. You need to handle this case upstream
|
|
||||||
* (hint: views can't have negative width or height but can have 0 width or height sometimes).
|
|
||||||
*/
|
|
||||||
internal fun buildStaticMapsApiUrl(
|
|
||||||
lat: Double,
|
|
||||||
lon: Double,
|
|
||||||
desiredZoom: Double,
|
|
||||||
desiredWidth: Int,
|
|
||||||
desiredHeight: Int,
|
|
||||||
darkMode: Boolean,
|
|
||||||
doubleScale: Boolean,
|
|
||||||
attributionPlacement: AttributionPlacement,
|
|
||||||
): String {
|
|
||||||
require(desiredWidth > 0 && desiredHeight > 0) {
|
|
||||||
"Width ($desiredHeight) and height ($desiredHeight) must be > 0"
|
|
||||||
}
|
|
||||||
require(desiredZoom >= 0) { "Zoom ($desiredZoom) must be >= 0" }
|
|
||||||
val zoom = desiredZoom.coerceAtMost(STATIC_MAP_MAX_ZOOM) // API will error if outside 0-22 range.
|
|
||||||
val width: Int
|
|
||||||
val height: Int
|
|
||||||
if (desiredWidth <= STATIC_MAP_MAX_WIDTH_HEIGHT && desiredHeight <= STATIC_MAP_MAX_WIDTH_HEIGHT) {
|
|
||||||
width = desiredWidth
|
|
||||||
height = desiredHeight
|
|
||||||
} else {
|
|
||||||
val aspectRatio = desiredWidth.toDouble() / desiredHeight.toDouble()
|
|
||||||
if (desiredWidth >= desiredHeight) {
|
|
||||||
width = desiredWidth.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT)
|
|
||||||
height = (width / aspectRatio).roundToInt()
|
|
||||||
} else {
|
|
||||||
height = desiredHeight.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT)
|
|
||||||
width = (height * aspectRatio).roundToInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val mapId = if (darkMode) DARK_MAP_ID else LIGHT_MAP_ID
|
|
||||||
val scaleSuffix = if (doubleScale) STATIC_MAP_SCALE_2X else ""
|
|
||||||
|
|
||||||
return "$BASE_URL/maps/$mapId/static/${lon},${lat},${zoom}/${width}x${height}${scaleSuffix}.$STATIC_MAP_FORMAT" +
|
|
||||||
"?key=$API_KEY&attribution=${attributionPlacement.value}"
|
|
||||||
}
|
|
||||||
|
|
@ -38,7 +38,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -46,17 +45,13 @@ internal fun StaticMapPlaceholder(
|
||||||
showProgress: Boolean,
|
showProgress: Boolean,
|
||||||
contentDescription: String?,
|
contentDescription: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
darkMode: Boolean = !ElementTheme.isLightTheme,
|
|
||||||
onLoadMapClick: () -> Unit,
|
onLoadMapClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(
|
painter = painterResource(id = R.drawable.blurred_map),
|
||||||
id = if (darkMode) R.drawable.blurred_map_dark
|
|
||||||
else R.drawable.blurred_map_light
|
|
||||||
),
|
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentScale = ContentScale.FillBounds,
|
contentScale = ContentScale.FillBounds,
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
|
@ -1,117 +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.features.location.api.internal
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class BuildStaticMapsApiUrlTest {
|
|
||||||
@Test
|
|
||||||
fun `buildStaticMapsApiUrl builds light mode url`() {
|
|
||||||
assertThat(
|
|
||||||
buildStaticMapsApiUrl(
|
|
||||||
lat = 1.234,
|
|
||||||
lon = 5.678,
|
|
||||||
desiredZoom = 1.2,
|
|
||||||
desiredWidth = 100,
|
|
||||||
desiredHeight = 200,
|
|
||||||
darkMode = false,
|
|
||||||
doubleScale = false,
|
|
||||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
|
||||||
)
|
|
||||||
).isEqualTo(
|
|
||||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" +
|
|
||||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `buildStaticMapsApiUrl builds dark mode url`() {
|
|
||||||
assertThat(
|
|
||||||
buildStaticMapsApiUrl(
|
|
||||||
lat = 1.234,
|
|
||||||
lon = 5.678,
|
|
||||||
desiredZoom = 1.2,
|
|
||||||
desiredWidth = 100,
|
|
||||||
desiredHeight = 200,
|
|
||||||
darkMode = true,
|
|
||||||
doubleScale = false,
|
|
||||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
|
||||||
)
|
|
||||||
).isEqualTo(
|
|
||||||
"https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp" +
|
|
||||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `buildStaticMapsApiUrl builds double scale mode url`() {
|
|
||||||
assertThat(
|
|
||||||
buildStaticMapsApiUrl(
|
|
||||||
lat = 1.234,
|
|
||||||
lon = 5.678,
|
|
||||||
desiredZoom = 1.2,
|
|
||||||
desiredWidth = 100,
|
|
||||||
desiredHeight = 200,
|
|
||||||
darkMode = false,
|
|
||||||
doubleScale = true,
|
|
||||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
|
||||||
)
|
|
||||||
).isEqualTo(
|
|
||||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200@2x.webp" +
|
|
||||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `buildStaticMapsApiUrl builds no attribution url`() {
|
|
||||||
assertThat(
|
|
||||||
buildStaticMapsApiUrl(
|
|
||||||
lat = 1.234,
|
|
||||||
lon = 5.678,
|
|
||||||
desiredZoom = 1.2,
|
|
||||||
desiredWidth = 100,
|
|
||||||
desiredHeight = 200,
|
|
||||||
darkMode = false,
|
|
||||||
doubleScale = false,
|
|
||||||
attributionPlacement = AttributionPlacement.Hidden,
|
|
||||||
)
|
|
||||||
).isEqualTo(
|
|
||||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" +
|
|
||||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=false"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `buildStaticMapsApiUrl coerces zoom at 22 and width and height at max 2048 keeping aspect ratio`() {
|
|
||||||
assertThat(
|
|
||||||
buildStaticMapsApiUrl(
|
|
||||||
lat = 1.234,
|
|
||||||
lon = 5.678,
|
|
||||||
desiredZoom = 100.0,
|
|
||||||
desiredWidth = 8192,
|
|
||||||
desiredHeight = 4096,
|
|
||||||
darkMode = false,
|
|
||||||
doubleScale = false,
|
|
||||||
attributionPlacement = AttributionPlacement.BottomLeft,
|
|
||||||
)
|
|
||||||
).isEqualTo(
|
|
||||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp" +
|
|
||||||
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -50,7 +50,7 @@ import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
|
||||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
|
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
|
||||||
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
|
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.location.api.internal.buildTileServerUrl
|
import io.element.android.features.location.api.internal.tileStyleUrl
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
@ -102,7 +102,7 @@ fun MapView(
|
||||||
isCompassEnabled = false
|
isCompassEnabled = false
|
||||||
isRotateGesturesEnabled = false
|
isRotateGesturesEnabled = false
|
||||||
}
|
}
|
||||||
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style ->
|
map.setStyle(tileStyleUrl(context, darkMode)) { style ->
|
||||||
mapRefs = MapRefs(
|
mapRefs = MapRefs(
|
||||||
map = map,
|
map = map,
|
||||||
symbolManager = SymbolManager(mapView, map, style).apply {
|
symbolManager = SymbolManager(mapView, map, style).apply {
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
|
@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
||||||
import io.element.android.features.messages.impl.report.ReportMessageNode
|
import io.element.android.features.messages.impl.report.ReportMessageNode
|
||||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||||
|
|
@ -224,6 +225,20 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
backstack.push(navTarget)
|
backstack.push(navTarget)
|
||||||
}
|
}
|
||||||
|
is TimelineItemAudioContent -> {
|
||||||
|
val mediaSource = event.content.audioSource
|
||||||
|
val navTarget = NavTarget.MediaViewer(
|
||||||
|
mediaInfo = MediaInfo(
|
||||||
|
name = event.content.body,
|
||||||
|
mimeType = event.content.mimeType,
|
||||||
|
formattedFileSize = event.content.formattedFileSize,
|
||||||
|
fileExtension = event.content.fileExtension
|
||||||
|
),
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
thumbnailSource = null,
|
||||||
|
)
|
||||||
|
backstack.push(navTarget)
|
||||||
|
}
|
||||||
is TimelineItemLocationContent -> {
|
is TimelineItemLocationContent -> {
|
||||||
val navTarget = NavTarget.LocationViewer(
|
val navTarget = NavTarget.LocationViewer(
|
||||||
location = event.content.location,
|
location = event.content.location,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
|
@ -108,10 +109,10 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||||
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value){
|
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value) {
|
||||||
value = room.displayName
|
value = room.displayName
|
||||||
}
|
}
|
||||||
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value){
|
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value) {
|
||||||
value = room.avatarData()
|
value = room.avatarData()
|
||||||
}
|
}
|
||||||
var hasDismissedInviteDialog by rememberSaveable {
|
var hasDismissedInviteDialog by rememberSaveable {
|
||||||
|
|
@ -250,28 +251,28 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val textContent = messageSummaryFormatter.format(targetEvent)
|
val textContent = messageSummaryFormatter.format(targetEvent)
|
||||||
val attachmentThumbnailInfo = when (targetEvent.content) {
|
val attachmentThumbnailInfo = when (targetEvent.content) {
|
||||||
is TimelineItemImageContent -> AttachmentThumbnailInfo(
|
is TimelineItemImageContent -> AttachmentThumbnailInfo(
|
||||||
mediaSource = targetEvent.content.mediaSource,
|
thumbnailSource = targetEvent.content.thumbnailSource,
|
||||||
textContent = targetEvent.content.body,
|
textContent = targetEvent.content.body,
|
||||||
type = AttachmentThumbnailType.Image,
|
type = AttachmentThumbnailType.Image,
|
||||||
blurHash = targetEvent.content.blurhash,
|
blurHash = targetEvent.content.blurhash,
|
||||||
)
|
)
|
||||||
is TimelineItemVideoContent -> AttachmentThumbnailInfo(
|
is TimelineItemVideoContent -> AttachmentThumbnailInfo(
|
||||||
mediaSource = targetEvent.content.thumbnailSource,
|
thumbnailSource = targetEvent.content.thumbnailSource,
|
||||||
textContent = targetEvent.content.body,
|
textContent = targetEvent.content.body,
|
||||||
type = AttachmentThumbnailType.Video,
|
type = AttachmentThumbnailType.Video,
|
||||||
blurHash = targetEvent.content.blurHash,
|
blurHash = targetEvent.content.blurHash,
|
||||||
)
|
)
|
||||||
is TimelineItemFileContent -> AttachmentThumbnailInfo(
|
is TimelineItemFileContent -> AttachmentThumbnailInfo(
|
||||||
mediaSource = targetEvent.content.thumbnailSource,
|
thumbnailSource = targetEvent.content.thumbnailSource,
|
||||||
textContent = targetEvent.content.body,
|
textContent = targetEvent.content.body,
|
||||||
type = AttachmentThumbnailType.File,
|
type = AttachmentThumbnailType.File,
|
||||||
blurHash = null,
|
)
|
||||||
|
is TimelineItemAudioContent -> AttachmentThumbnailInfo(
|
||||||
|
textContent = targetEvent.content.body,
|
||||||
|
type = AttachmentThumbnailType.Audio,
|
||||||
)
|
)
|
||||||
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
|
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
|
||||||
mediaSource = null,
|
|
||||||
textContent = null,
|
|
||||||
type = AttachmentThumbnailType.Location,
|
type = AttachmentThumbnailType.Location,
|
||||||
blurHash = null,
|
|
||||||
)
|
)
|
||||||
is TimelineItemTextBasedContent,
|
is TimelineItemTextBasedContent,
|
||||||
is TimelineItemRedactedContent,
|
is TimelineItemRedactedContent,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -48,7 +47,7 @@ fun aMessagesState() = MessagesState(
|
||||||
roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
|
roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
|
||||||
userHasPermissionToSendMessage = true,
|
userHasPermissionToSendMessage = true,
|
||||||
composerState = aMessageComposerState().copy(
|
composerState = aMessageComposerState().copy(
|
||||||
text = StableCharSequence("Hello"),
|
text = "Hello",
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
mode = MessageComposerMode.Normal("Hello"),
|
mode = MessageComposerMode.Normal("Hello"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
|
@ -246,8 +247,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
||||||
info = AttachmentThumbnailInfo(
|
info = AttachmentThumbnailInfo(
|
||||||
type = AttachmentThumbnailType.Location,
|
type = AttachmentThumbnailType.Location,
|
||||||
textContent = stringResource(CommonStrings.common_shared_location),
|
textContent = stringResource(CommonStrings.common_shared_location),
|
||||||
mediaSource = null,
|
|
||||||
blurHash = null,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -258,9 +257,9 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
||||||
AttachmentThumbnail(
|
AttachmentThumbnail(
|
||||||
modifier = imageModifier,
|
modifier = imageModifier,
|
||||||
info = AttachmentThumbnailInfo(
|
info = AttachmentThumbnailInfo(
|
||||||
mediaSource = event.content.mediaSource,
|
thumbnailSource = event.content.mediaSource,
|
||||||
textContent = textContent,
|
textContent = textContent,
|
||||||
type = AttachmentThumbnailType.File,
|
type = AttachmentThumbnailType.Image,
|
||||||
blurHash = event.content.blurhash,
|
blurHash = event.content.blurhash,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -272,7 +271,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
||||||
AttachmentThumbnail(
|
AttachmentThumbnail(
|
||||||
modifier = imageModifier,
|
modifier = imageModifier,
|
||||||
info = AttachmentThumbnailInfo(
|
info = AttachmentThumbnailInfo(
|
||||||
mediaSource = event.content.thumbnailSource,
|
thumbnailSource = event.content.thumbnailSource,
|
||||||
textContent = textContent,
|
textContent = textContent,
|
||||||
type = AttachmentThumbnailType.Video,
|
type = AttachmentThumbnailType.Video,
|
||||||
blurHash = event.content.blurHash,
|
blurHash = event.content.blurHash,
|
||||||
|
|
@ -286,10 +285,21 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
||||||
AttachmentThumbnail(
|
AttachmentThumbnail(
|
||||||
modifier = imageModifier,
|
modifier = imageModifier,
|
||||||
info = AttachmentThumbnailInfo(
|
info = AttachmentThumbnailInfo(
|
||||||
mediaSource = null,
|
thumbnailSource = event.content.thumbnailSource,
|
||||||
textContent = textContent,
|
textContent = textContent,
|
||||||
type = AttachmentThumbnailType.File,
|
type = AttachmentThumbnailType.File,
|
||||||
blurHash = null
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content = { ContentForBody(event.content.body) }
|
||||||
|
}
|
||||||
|
is TimelineItemAudioContent -> {
|
||||||
|
icon = {
|
||||||
|
AttachmentThumbnail(
|
||||||
|
modifier = imageModifier,
|
||||||
|
info = AttachmentThumbnailInfo(
|
||||||
|
textContent = textContent,
|
||||||
|
type = AttachmentThumbnailType.Audio,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Attachment
|
import androidx.compose.material.icons.outlined.Attachment
|
||||||
|
import androidx.compose.material.icons.outlined.GraphicEq
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -47,7 +48,6 @@ import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
|
@ -59,7 +59,9 @@ import io.element.android.features.messages.impl.media.helper.formatFileExtensio
|
||||||
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
|
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
|
||||||
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
|
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
|
||||||
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState
|
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState
|
||||||
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||||
import io.element.android.libraries.designsystem.R
|
import io.element.android.libraries.designsystem.R
|
||||||
|
|
@ -103,6 +105,7 @@ fun LocalMediaView(
|
||||||
zoomableState = zoomableState,
|
zoomableState = zoomableState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
//TODO handle audio with exoplayer
|
||||||
else -> MediaFileView(
|
else -> MediaFileView(
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
uri = localMedia?.uri,
|
uri = localMedia?.uri,
|
||||||
|
|
@ -215,6 +218,7 @@ fun MediaFileView(
|
||||||
info: MediaInfo?,
|
info: MediaInfo?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
|
||||||
localMediaViewState.isReady = uri != null
|
localMediaViewState.isReady = uri != null
|
||||||
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
|
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
|
@ -226,12 +230,12 @@ fun MediaFileView(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Attachment,
|
imageVector = if (isAudio) Icons.Outlined.GraphicEq else Icons.Outlined.Attachment,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.background,
|
tint = MaterialTheme.colorScheme.background,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.rotate(-45f),
|
.rotate(if (isAudio) 0f else -45f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ data class MediaInfo(
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|
||||||
fun anImageInfo(): MediaInfo = MediaInfo(
|
fun anImageInfo(): MediaInfo = MediaInfo(
|
||||||
"an image file.jpg", MimeTypes.Jpeg, "4MB","jpg"
|
"an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aVideoInfo(): MediaInfo = MediaInfo(
|
fun aVideoInfo(): MediaInfo = MediaInfo(
|
||||||
|
|
@ -43,3 +43,7 @@ fun aPdfInfo(): MediaInfo = MediaInfo(
|
||||||
fun aFileInfo(): MediaInfo = MediaInfo(
|
fun aFileInfo(): MediaInfo = MediaInfo(
|
||||||
"an apk file.apk", MimeTypes.Apk, "50MB", "apk"
|
"an apk file.apk", MimeTypes.Apk, "50MB", "apk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun anAudioInfo(): MediaInfo = MediaInfo(
|
||||||
|
"an audio file.mp3", MimeTypes.Mp3, "7MB", "mp3"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||||
import io.element.android.features.messages.impl.media.local.aFileInfo
|
import io.element.android.features.messages.impl.media.local.aFileInfo
|
||||||
import io.element.android.features.messages.impl.media.local.aPdfInfo
|
import io.element.android.features.messages.impl.media.local.aPdfInfo
|
||||||
import io.element.android.features.messages.impl.media.local.aVideoInfo
|
import io.element.android.features.messages.impl.media.local.aVideoInfo
|
||||||
|
import io.element.android.features.messages.impl.media.local.anAudioInfo
|
||||||
import io.element.android.features.messages.impl.media.local.anImageInfo
|
import io.element.android.features.messages.impl.media.local.anImageInfo
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
||||||
|
|
@ -59,7 +60,17 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||||
LocalMedia(Uri.EMPTY, aFileInfo())
|
LocalMedia(Uri.EMPTY, aFileInfo())
|
||||||
),
|
),
|
||||||
aFileInfo(),
|
aFileInfo(),
|
||||||
)
|
),
|
||||||
|
aMediaViewerState(
|
||||||
|
Async.Loading(),
|
||||||
|
anAudioInfo(),
|
||||||
|
),
|
||||||
|
aMediaViewerState(
|
||||||
|
Async.Success(
|
||||||
|
LocalMedia(Uri.EMPTY, anAudioInfo())
|
||||||
|
),
|
||||||
|
anAudioInfo(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ sealed interface MessageComposerEvents {
|
||||||
data class SendMessage(val message: String) : MessageComposerEvents
|
data class SendMessage(val message: String) : MessageComposerEvents
|
||||||
object CloseSpecialMode : MessageComposerEvents
|
object CloseSpecialMode : MessageComposerEvents
|
||||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||||
data class UpdateText(val text: CharSequence) : MessageComposerEvents
|
data class UpdateText(val text: String) : MessageComposerEvents
|
||||||
object AddAttachment : MessageComposerEvents
|
object AddAttachment : MessageComposerEvents
|
||||||
object DismissAttachmentMenu : MessageComposerEvents
|
object DismissAttachmentMenu : MessageComposerEvents
|
||||||
sealed interface PickAttachmentSource : MessageComposerEvents {
|
sealed interface PickAttachmentSource : MessageComposerEvents {
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,6 @@ import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
|
||||||
import io.element.android.libraries.core.data.toStableCharSequence
|
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
|
@ -94,15 +92,15 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
val hasFocus = remember {
|
val hasFocus = remember {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
val text: MutableState<StableCharSequence> = remember {
|
val text: MutableState<String> = rememberSaveable {
|
||||||
mutableStateOf(StableCharSequence(""))
|
mutableStateOf("")
|
||||||
}
|
}
|
||||||
|
|
||||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(messageComposerContext.composerMode) {
|
LaunchedEffect(messageComposerContext.composerMode) {
|
||||||
when (val modeValue = messageComposerContext.composerMode) {
|
when (val modeValue = messageComposerContext.composerMode) {
|
||||||
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
|
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,9 +118,9 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
|
|
||||||
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
|
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
|
||||||
|
|
||||||
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
|
is MessageComposerEvents.UpdateText -> text.value = event.text
|
||||||
MessageComposerEvents.CloseSpecialMode -> {
|
MessageComposerEvents.CloseSpecialMode -> {
|
||||||
text.value = "".toStableCharSequence()
|
text.value = ""
|
||||||
messageComposerContext.composerMode = MessageComposerMode.Normal("")
|
messageComposerContext.composerMode = MessageComposerMode.Normal("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,11 +187,11 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
private fun CoroutineScope.sendMessage(
|
private fun CoroutineScope.sendMessage(
|
||||||
text: String,
|
text: String,
|
||||||
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
||||||
textState: MutableState<StableCharSequence>
|
textState: MutableState<String>
|
||||||
) = launch {
|
) = launch {
|
||||||
val capturedMode = messageComposerContext.composerMode
|
val capturedMode = messageComposerContext.composerMode
|
||||||
// Reset composer right away
|
// Reset composer right away
|
||||||
textState.value = "".toStableCharSequence()
|
textState.value = ""
|
||||||
updateComposerMode(MessageComposerMode.Normal(""))
|
updateComposerMode(MessageComposerMode.Normal(""))
|
||||||
when (capturedMode) {
|
when (capturedMode) {
|
||||||
is MessageComposerMode.Normal -> room.sendMessage(text)
|
is MessageComposerMode.Normal -> room.sendMessage(text)
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,12 @@ package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class MessageComposerState(
|
data class MessageComposerState(
|
||||||
val text: StableCharSequence?,
|
val text: String?,
|
||||||
val isFullScreen: Boolean,
|
val isFullScreen: Boolean,
|
||||||
val hasFocus: Boolean,
|
val hasFocus: Boolean,
|
||||||
val mode: MessageComposerMode,
|
val mode: MessageComposerMode,
|
||||||
|
|
@ -32,7 +31,7 @@ data class MessageComposerState(
|
||||||
val attachmentsState: AttachmentsState,
|
val attachmentsState: AttachmentsState,
|
||||||
val eventSink: (MessageComposerEvents) -> Unit
|
val eventSink: (MessageComposerEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
|
val isSendButtonVisible: Boolean = text.isNullOrEmpty().not()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
|
||||||
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
||||||
|
|
@ -28,7 +27,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aMessageComposerState() = MessageComposerState(
|
fun aMessageComposerState() = MessageComposerState(
|
||||||
text = StableCharSequence(""),
|
text = "",
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
hasFocus = false,
|
hasFocus = false,
|
||||||
mode = MessageComposerMode.Normal(content = ""),
|
mode = MessageComposerMode.Normal(content = ""),
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ fun MessageComposerView(
|
||||||
state.eventSink(MessageComposerEvents.CloseSpecialMode)
|
state.eventSink(MessageComposerEvents.CloseSpecialMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onComposerTextChange(text: CharSequence) {
|
fun onComposerTextChange(text: String) {
|
||||||
state.eventSink(MessageComposerEvents.UpdateText(text))
|
state.eventSink(MessageComposerEvents.UpdateText(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ fun MessageComposerView(
|
||||||
onAddAttachment = ::onAddAttachment,
|
onAddAttachment = ::onAddAttachment,
|
||||||
onFocusChanged = ::onFocusChanged,
|
onFocusChanged = ::onFocusChanged,
|
||||||
composerCanSendMessage = state.isSendButtonVisible,
|
composerCanSendMessage = state.isSendButtonVisible,
|
||||||
composerText = state.text?.charSequence?.toString(),
|
composerText = state.text,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,21 +22,24 @@ import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
|
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val backPaginationEventLimit = 20
|
private const val backPaginationEventLimit = 20
|
||||||
|
|
@ -45,42 +48,52 @@ private const val backPaginationPageSize = 50
|
||||||
class TimelinePresenter @Inject constructor(
|
class TimelinePresenter @Inject constructor(
|
||||||
private val timelineItemsFactory: TimelineItemsFactory,
|
private val timelineItemsFactory: TimelineItemsFactory,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val appScope: CoroutineScope,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
|
|
||||||
private val timeline = room.timeline
|
private val timeline = room.timeline
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TimelineState {
|
override fun present(): TimelineState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localScope = rememberCoroutineScope()
|
||||||
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
|
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
|
||||||
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) }
|
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||||
|
|
||||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||||
val paginationState by timeline.paginationState.collectAsState()
|
val paginationState by timeline.paginationState.collectAsState()
|
||||||
|
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||||
|
|
||||||
|
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val hasNewItems = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
fun handleEvents(event: TimelineEvents) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards()
|
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||||
is TimelineEvents.OnScrollFinished -> {
|
is TimelineEvents.OnScrollFinished -> {
|
||||||
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
|
if (event.firstIndex == 0) {
|
||||||
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return
|
hasNewItems.value = false
|
||||||
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
|
|
||||||
lastReadMarkerIndex = event.firstIndex
|
|
||||||
lastReadMarkerId = eventId
|
|
||||||
localCoroutineScope.sendReadReceipt(eventId)
|
|
||||||
}
|
}
|
||||||
|
appScope.sendReadReceiptIfNeeded(
|
||||||
|
firstVisibleIndex = event.firstIndex,
|
||||||
|
timelineItems = timelineItems,
|
||||||
|
lastReadReceiptIndex = lastReadReceiptIndex,
|
||||||
|
lastReadReceiptId = lastReadReceiptId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(timelineItems.size) {
|
||||||
|
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
timeline
|
timeline
|
||||||
.timelineItems
|
.timelineItems
|
||||||
|
|
@ -98,10 +111,49 @@ class TimelinePresenter @Inject constructor(
|
||||||
canReply = userHasPermissionToSendMessage,
|
canReply = userHasPermissionToSendMessage,
|
||||||
paginationState = paginationState,
|
paginationState = paginationState,
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
|
hasNewItems = hasNewItems.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
|
||||||
|
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
|
||||||
|
* The state never goes back to false from this method, but need to be reset from somewhere else.
|
||||||
|
*/
|
||||||
|
private suspend fun computeHasNewItems(
|
||||||
|
timelineItems: ImmutableList<TimelineItem>,
|
||||||
|
prevMostRecentItemId: MutableState<String?>,
|
||||||
|
hasNewItemsState: MutableState<Boolean>
|
||||||
|
) = withContext(dispatchers.computation) {
|
||||||
|
val newMostRecentItem = timelineItems.firstOrNull()
|
||||||
|
val prevMostRecentItemIdValue = prevMostRecentItemId.value
|
||||||
|
val newMostRecentItemId = newMostRecentItem?.identifier()
|
||||||
|
val hasNewItems = prevMostRecentItemIdValue != null &&
|
||||||
|
newMostRecentItem is TimelineItem.Event &&
|
||||||
|
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||||
|
newMostRecentItemId != prevMostRecentItemIdValue
|
||||||
|
if (hasNewItems) {
|
||||||
|
hasNewItemsState.value = true
|
||||||
|
}
|
||||||
|
prevMostRecentItemId.value = newMostRecentItemId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.sendReadReceiptIfNeeded(
|
||||||
|
firstVisibleIndex: Int,
|
||||||
|
timelineItems: ImmutableList<TimelineItem>,
|
||||||
|
lastReadReceiptIndex: MutableState<Int>,
|
||||||
|
lastReadReceiptId: MutableState<EventId?>,
|
||||||
|
) = launch(dispatchers.computation) {
|
||||||
|
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
|
||||||
|
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
|
||||||
|
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
|
||||||
|
lastReadReceiptIndex.value = firstVisibleIndex
|
||||||
|
lastReadReceiptId.value = eventId
|
||||||
|
timeline.sendReadReceipt(eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
|
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
|
||||||
for (item in items.subList(index, items.count())) {
|
for (item in items.subList(index, items.count())) {
|
||||||
if (item is TimelineItem.Event) {
|
if (item is TimelineItem.Event) {
|
||||||
|
|
@ -114,8 +166,4 @@ class TimelinePresenter @Inject constructor(
|
||||||
private fun CoroutineScope.paginateBackwards() = launch {
|
private fun CoroutineScope.paginateBackwards() = launch {
|
||||||
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.sendReadReceipt(eventId: EventId) = launch {
|
|
||||||
timeline.sendReadReceipt(eventId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,6 @@ data class TimelineState(
|
||||||
val highlightedEventId: EventId?,
|
val highlightedEventId: EventId?,
|
||||||
val canReply: Boolean,
|
val canReply: Boolean,
|
||||||
val paginationState: MatrixTimeline.PaginationState,
|
val paginationState: MatrixTimeline.PaginationState,
|
||||||
|
val hasNewItems: Boolean,
|
||||||
val eventSink: (TimelineEvents) -> Unit
|
val eventSink: (TimelineEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ import io.element.android.libraries.matrix.api.core.TransactionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -45,7 +45,8 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
|
||||||
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
|
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
|
||||||
highlightedEventId = null,
|
highlightedEventId = null,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
eventSink = {}
|
hasNewItems = false,
|
||||||
|
eventSink = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||||
|
|
@ -127,6 +128,7 @@ internal fun aTimelineItemEvent(
|
||||||
localSendState = sendState,
|
localSendState = sendState,
|
||||||
inReplyTo = inReplyTo,
|
inReplyTo = inReplyTo,
|
||||||
debugInfo = debugInfo,
|
debugInfo = debugInfo,
|
||||||
|
origin = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,13 +155,14 @@ internal fun aTimelineItemDebugInfo(
|
||||||
model, originalJson, latestEditedJson
|
model, originalJson, latestEditedJson
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aGroupedEvents(): TimelineItem.GroupedEvents {
|
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
|
||||||
val event = aTimelineItemEvent(
|
val event = aTimelineItemEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = aTimelineItemStateEventContent(),
|
content = aTimelineItemStateEventContent(),
|
||||||
groupPosition = TimelineItemGroupPosition.None
|
groupPosition = TimelineItemGroupPosition.None
|
||||||
)
|
)
|
||||||
return TimelineItem.GroupedEvents(
|
return TimelineItem.GroupedEvents(
|
||||||
|
id = id.toString(),
|
||||||
events = listOf(
|
events = listOf(
|
||||||
event,
|
event,
|
||||||
event,
|
event,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ package io.element.android.features.messages.impl.timeline
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -48,10 +49,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.messages.impl.R
|
import io.element.android.features.messages.impl.R
|
||||||
|
|
@ -64,14 +63,13 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -100,13 +98,6 @@ fun TimelineView(
|
||||||
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
|
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an event to the presenter when the scrolling is finished, with the first visible index at the bottom.
|
|
||||||
val firstVisibleIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
|
|
||||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
|
||||||
LaunchedEffect(firstVisibleIndex, isScrollFinished) {
|
|
||||||
if (!isScrollFinished) return@LaunchedEffect
|
|
||||||
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|
@ -147,8 +138,8 @@ fun TimelineView(
|
||||||
|
|
||||||
TimelineScrollHelper(
|
TimelineScrollHelper(
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
timelineItems = state.timelineItems,
|
hasNewItems = state.hasNewItems,
|
||||||
onScrollFinishedAt = ::onScrollFinishedAt,
|
onScrollFinishedAt = ::onScrollFinishedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,63 +235,66 @@ fun TimelineItemRow(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BoxScope.TimelineScrollHelper(
|
private fun BoxScope.TimelineScrollHelper(
|
||||||
lazyListState: LazyListState,
|
lazyListState: LazyListState,
|
||||||
timelineItems: ImmutableList<TimelineItem>,
|
hasNewItems: Boolean,
|
||||||
onScrollFinishedAt: (Int) -> Unit = {},
|
onScrollFinishedAt: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
|
|
||||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||||
val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } }
|
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
|
||||||
val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
|
|
||||||
|
|
||||||
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
|
LaunchedEffect(canAutoScroll, hasNewItems) {
|
||||||
if (!isScrollFinished) return@LaunchedEffect
|
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
|
||||||
|
if (shouldAutoScroll) {
|
||||||
// Auto-scroll when new timeline items appear
|
|
||||||
if (shouldAutoScrollToBottom) {
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
lazyListState.animateScrollToItem(0)
|
lazyListState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(isScrollFinished) {
|
|
||||||
if (!isScrollFinished) return@LaunchedEffect
|
|
||||||
|
|
||||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
LaunchedEffect(isScrollFinished) {
|
||||||
onScrollFinishedAt(firstVisibleItemIndex)
|
if (isScrollFinished) {
|
||||||
|
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||||
|
onScrollFinishedAt(lazyListState.firstVisibleItemIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jump to bottom button (display also in previews)
|
JumpToBottomButton(
|
||||||
AnimatedVisibility(
|
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
||||||
|
isVisible = !canAutoScroll,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 24.dp, bottom = 12.dp),
|
.padding(end = 24.dp, bottom = 12.dp),
|
||||||
visible = showScrollToBottomButton || LocalInspectionMode.current,
|
onClick = {
|
||||||
enter = scaleIn(),
|
coroutineScope.launch {
|
||||||
exit = scaleOut(),
|
if (lazyListState.firstVisibleItemIndex > 10) {
|
||||||
|
lazyListState.scrollToItem(0)
|
||||||
|
} else {
|
||||||
|
lazyListState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun JumpToBottomButton(
|
||||||
|
isVisible: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
modifier = modifier,
|
||||||
|
visible = isVisible || LocalInspectionMode.current,
|
||||||
|
enter = scaleIn(animationSpec = tween(100)),
|
||||||
|
exit = scaleOut(animationSpec = tween(100)),
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = onClick,
|
||||||
coroutineScope.launch {
|
|
||||||
if (firstVisibleItemIndex > 10) {
|
|
||||||
lazyListState.scrollToItem(0)
|
|
||||||
} else {
|
|
||||||
lazyListState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(36.dp),
|
||||||
.shadow(
|
|
||||||
elevation = 4.dp,
|
|
||||||
shape = CircleShape,
|
|
||||||
ambientColor = ElementTheme.materialColors.primary,
|
|
||||||
spotColor = ElementTheme.materialColors.primary,
|
|
||||||
)
|
|
||||||
.size(36.dp),
|
|
||||||
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
||||||
contentColor = ElementTheme.colors.iconSecondary
|
contentColor = ElementTheme.colors.iconSecondary
|
||||||
) {
|
) {
|
||||||
|
|
@ -313,20 +307,11 @@ internal fun BoxScope.TimelineScrollHelper(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@DayNightPreviews
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineViewLightPreview(
|
fun TimelineViewPreview(
|
||||||
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
|
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
|
||||||
) = ElementPreviewLight { ContentToPreview(content) }
|
) = ElementPreview {
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun TimelineViewDarkPreview(
|
|
||||||
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
|
|
||||||
) = ElementPreviewDark { ContentToPreview(content) }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContentToPreview(content: TimelineItemEventContent) {
|
|
||||||
val timelineItems = aTimelineItemList(content)
|
val timelineItems = aTimelineItemList(content)
|
||||||
TimelineView(
|
TimelineView(
|
||||||
state = aTimelineState(timelineItems),
|
state = aTimelineState(timelineItems),
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ import io.element.android.libraries.theme.ElementTheme
|
||||||
private val BUBBLE_RADIUS = 12.dp
|
private val BUBBLE_RADIUS = 12.dp
|
||||||
private val BUBBLE_INCOMING_OFFSET = 16.dp
|
private val BUBBLE_INCOMING_OFFSET = 16.dp
|
||||||
|
|
||||||
// Design says: The maximum width of a bubble is still 3/4 of the screen width
|
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now.
|
||||||
private const val BUBBLE_WIDTH_RATIO = 0.75f
|
private const val BUBBLE_WIDTH_RATIO = 0.85f
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.constraintlayout.compose.ConstrainScope
|
import androidx.constraintlayout.compose.ConstrainScope
|
||||||
import androidx.constraintlayout.compose.ConstraintLayout
|
import androidx.constraintlayout.compose.ConstraintLayout
|
||||||
|
|
@ -85,6 +84,7 @@ import io.element.android.libraries.designsystem.text.toPx
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
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.ImageMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||||
|
|
@ -521,28 +521,29 @@ private fun ReplyToContent(
|
||||||
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
|
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
|
||||||
when (val type = inReplyTo.content.type) {
|
when (val type = inReplyTo.content.type) {
|
||||||
is ImageMessageType -> AttachmentThumbnailInfo(
|
is ImageMessageType -> AttachmentThumbnailInfo(
|
||||||
mediaSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
textContent = inReplyTo.content.body,
|
textContent = inReplyTo.content.body,
|
||||||
type = AttachmentThumbnailType.Image,
|
type = AttachmentThumbnailType.Image,
|
||||||
blurHash = type.info?.blurhash,
|
blurHash = type.info?.blurhash,
|
||||||
)
|
)
|
||||||
is VideoMessageType -> AttachmentThumbnailInfo(
|
is VideoMessageType -> AttachmentThumbnailInfo(
|
||||||
mediaSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
textContent = inReplyTo.content.body,
|
textContent = inReplyTo.content.body,
|
||||||
type = AttachmentThumbnailType.Video,
|
type = AttachmentThumbnailType.Video,
|
||||||
blurHash = type.info?.blurhash,
|
blurHash = type.info?.blurhash,
|
||||||
)
|
)
|
||||||
is FileMessageType -> AttachmentThumbnailInfo(
|
is FileMessageType -> AttachmentThumbnailInfo(
|
||||||
mediaSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
textContent = inReplyTo.content.body,
|
textContent = inReplyTo.content.body,
|
||||||
type = AttachmentThumbnailType.File,
|
type = AttachmentThumbnailType.File,
|
||||||
blurHash = null,
|
|
||||||
)
|
)
|
||||||
is LocationMessageType -> AttachmentThumbnailInfo(
|
is LocationMessageType -> AttachmentThumbnailInfo(
|
||||||
mediaSource = null,
|
|
||||||
textContent = inReplyTo.content.body,
|
textContent = inReplyTo.content.body,
|
||||||
type = AttachmentThumbnailType.Location,
|
type = AttachmentThumbnailType.Location,
|
||||||
blurHash = null,
|
)
|
||||||
|
is AudioMessageType -> AttachmentThumbnailInfo(
|
||||||
|
textContent = inReplyTo.content.body,
|
||||||
|
type = AttachmentThumbnailType.Audio,
|
||||||
)
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -32,5 +33,7 @@ fun TimelineItemVirtualRow(
|
||||||
when (virtual.model) {
|
when (virtual.model) {
|
||||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
||||||
TimelineItemReadMarkerModel -> return
|
TimelineItemReadMarkerModel -> return
|
||||||
|
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.timeline.components.event
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.GraphicEq
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
|
||||||
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineItemAudioView(
|
||||||
|
content: TimelineItemAudioContent,
|
||||||
|
extraPadding: ExtraPadding,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(ElementTheme.materialColors.background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.GraphicEq,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.materialColors.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = content.body,
|
||||||
|
color = ElementTheme.materialColors.primary,
|
||||||
|
maxLines = 2,
|
||||||
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = content.fileExtensionAndSize + extraPadding.getStr(12.sp),
|
||||||
|
color = ElementTheme.materialColors.secondary,
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DayNightPreviews
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioContentProvider::class) content: TimelineItemAudioContent) =
|
||||||
|
ElementPreview {
|
||||||
|
TimelineItemAudioView(
|
||||||
|
content,
|
||||||
|
extraPadding = noExtraPadding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
|
|
@ -80,6 +81,11 @@ fun TimelineItemEventContentView(
|
||||||
extraPadding = extraPadding,
|
extraPadding = extraPadding,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
is TimelineItemAudioContent -> TimelineItemAudioView(
|
||||||
|
content = content,
|
||||||
|
extraPadding = extraPadding,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
is TimelineItemStateContent -> TimelineItemStateView(
|
is TimelineItemStateContent -> TimelineItemStateView(
|
||||||
content = content,
|
content = content,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,13 @@ import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.location.api.StaticMapView
|
import io.element.android.features.location.api.StaticMapView
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -57,17 +56,10 @@ fun TimelineItemLocationView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@DayNightPreviews
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemLocationViewLightPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
|
internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
|
||||||
ElementPreviewLight { ContentToPreview(content) }
|
ElementPreview {
|
||||||
|
TimelineItemLocationView(content)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun TimelineItemLocationViewDarkPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
|
|
||||||
ElementPreviewDark { ContentToPreview(content) }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContentToPreview(content: TimelineItemLocationContent) {
|
|
||||||
TimelineItemLocationView(content)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.timeline.components.virtual
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.messages.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
|
||||||
|
.background(ElementTheme.colors.bgInfoSubtle)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Info,
|
||||||
|
contentDescription = "Info",
|
||||||
|
tint = ElementTheme.colors.iconInfoPrimary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.screen_room_encrypted_history_banner),
|
||||||
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
color = ElementTheme.colors.textInfoPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DayNightPreviews
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineEncryptedHistoryBannerViewPreview() {
|
||||||
|
ElementTheme {
|
||||||
|
TimelineEncryptedHistoryBannerView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,6 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
private val virtualItemFactory: TimelineItemVirtualFactory,
|
private val virtualItemFactory: TimelineItemVirtualFactory,
|
||||||
private val timelineItemGrouper: TimelineItemGrouper,
|
private val timelineItemGrouper: TimelineItemGrouper,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
||||||
private val timelineItemsCache = arrayListOf<TimelineItem?>()
|
private val timelineItemsCache = arrayListOf<TimelineItem?>()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline.factories.event
|
package io.element.android.features.messages.impl.timeline.factories.event
|
||||||
|
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
|
|
@ -30,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.util.FileExtensionExtr
|
||||||
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
|
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
|
||||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
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.EmoteMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
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.ImageMessageType
|
||||||
|
|
@ -99,6 +101,14 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is AudioMessageType -> TimelineItemAudioContent(
|
||||||
|
body = messageType.body,
|
||||||
|
audioSource = messageType.source,
|
||||||
|
duration = messageType.info?.duration?.toMillis() ?: 0L,
|
||||||
|
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||||
|
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||||
|
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||||
|
)
|
||||||
is FileMessageType -> TimelineItemFileContent(
|
is FileMessageType -> TimelineItemFileContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ class TimelineItemEventFactory @Inject constructor(
|
||||||
localSendState = currentTimelineItem.event.localSendState,
|
localSendState = currentTimelineItem.event.localSendState,
|
||||||
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
||||||
debugInfo = currentTimelineItem.event.debugInfo,
|
debugInfo = currentTimelineItem.event.debugInfo,
|
||||||
|
origin = currentTimelineItem.event.origin,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline.factories.virtual
|
package io.element.android.features.messages.impl.timeline.factories.virtual
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
|
@ -30,8 +31,13 @@ class TimelineItemVirtualFactory @Inject constructor(
|
||||||
fun create(
|
fun create(
|
||||||
virtualTimelineItem: MatrixTimelineItem.Virtual,
|
virtualTimelineItem: MatrixTimelineItem.Virtual,
|
||||||
): TimelineItem.Virtual {
|
): TimelineItem.Virtual {
|
||||||
|
val id = if (virtualTimelineItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
|
||||||
|
"encrypted_history_banner"
|
||||||
|
} else {
|
||||||
|
virtualTimelineItem.uniqueId.toString()
|
||||||
|
}
|
||||||
return TimelineItem.Virtual(
|
return TimelineItem.Virtual(
|
||||||
id = virtualTimelineItem.uniqueId.toString(),
|
id = id,
|
||||||
model = virtualTimelineItem.computeModel()
|
model = virtualTimelineItem.computeModel()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +46,7 @@ class TimelineItemVirtualFactory @Inject constructor(
|
||||||
return when (val inner = virtual) {
|
return when (val inner = virtual) {
|
||||||
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
|
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
|
||||||
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
||||||
|
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline.groups
|
package io.element.android.features.messages.impl.timeline.groups
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
|
@ -52,6 +53,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
|
||||||
is TimelineItemImageContent,
|
is TimelineItemImageContent,
|
||||||
is TimelineItemFileContent,
|
is TimelineItemFileContent,
|
||||||
is TimelineItemVideoContent,
|
is TimelineItemVideoContent,
|
||||||
|
is TimelineItemAudioContent,
|
||||||
is TimelineItemLocationContent,
|
is TimelineItemLocationContent,
|
||||||
TimelineItemRedactedContent,
|
TimelineItemRedactedContent,
|
||||||
TimelineItemUnknownContent -> false
|
TimelineItemUnknownContent -> false
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,22 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.groups
|
package io.element.android.features.messages.impl.timeline.groups
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SingleIn(RoomScope::class)
|
||||||
class TimelineItemGrouper @Inject constructor() {
|
class TimelineItemGrouper @Inject constructor() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys are identifier of items in a group, only one by group will be kept.
|
||||||
|
* Values are the actual groupIds.
|
||||||
|
*/
|
||||||
|
private val groupIds = HashMap<String, String>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
|
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
|
||||||
*/
|
*/
|
||||||
|
|
@ -34,14 +45,14 @@ class TimelineItemGrouper @Inject constructor() {
|
||||||
// timelineItem cannot be grouped
|
// timelineItem cannot be grouped
|
||||||
if (currentGroup.isNotEmpty()) {
|
if (currentGroup.isNotEmpty()) {
|
||||||
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
|
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
|
||||||
result.addGroup(currentGroup)
|
result.addGroup(groupIds, currentGroup)
|
||||||
currentGroup.clear()
|
currentGroup.clear()
|
||||||
}
|
}
|
||||||
result.add(timelineItem)
|
result.add(timelineItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentGroup.isNotEmpty()) {
|
if (currentGroup.isNotEmpty()) {
|
||||||
result.addGroup(currentGroup)
|
result.addGroup(groupIds, currentGroup)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
@ -51,16 +62,36 @@ class TimelineItemGrouper @Inject constructor() {
|
||||||
* Will add a group if there is more than 1 item, else add the item to the list.
|
* Will add a group if there is more than 1 item, else add the item to the list.
|
||||||
*/
|
*/
|
||||||
private fun MutableList<TimelineItem>.addGroup(
|
private fun MutableList<TimelineItem>.addGroup(
|
||||||
group: MutableList<TimelineItem.Event>
|
groupIds: MutableMap<String, String>,
|
||||||
|
groupOfItems: MutableList<TimelineItem.Event>
|
||||||
) {
|
) {
|
||||||
if (group.size == 1) {
|
if (groupOfItems.size == 1) {
|
||||||
// Do not create a group with just 1 item, just add the item to the result
|
// Do not create a group with just 1 item, just add the item to the result
|
||||||
add(group.first())
|
add(groupOfItems.first())
|
||||||
} else {
|
} else {
|
||||||
|
val groupId = groupIds.getOrPutGroupId(groupOfItems)
|
||||||
add(
|
add(
|
||||||
TimelineItem.GroupedEvents(
|
TimelineItem.GroupedEvents(
|
||||||
events = group.toImmutableList()
|
id = groupId,
|
||||||
|
events = groupOfItems.toImmutableList()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<TimelineItem>): String {
|
||||||
|
assert(timelineItems.isNotEmpty())
|
||||||
|
for (item in timelineItems) {
|
||||||
|
val itemIdentifier = item.identifier()
|
||||||
|
if (this.contains(itemIdentifier)) {
|
||||||
|
return this[itemIdentifier]!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val timelineItem = timelineItems.first()
|
||||||
|
return computeGroupIdWith(timelineItem).also { groupId ->
|
||||||
|
this[timelineItem.identifier()] = groupId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
@ -66,6 +67,7 @@ sealed interface TimelineItem {
|
||||||
val localSendState: LocalEventSendState?,
|
val localSendState: LocalEventSendState?,
|
||||||
val inReplyTo: InReplyTo?,
|
val inReplyTo: InReplyTo?,
|
||||||
val debugInfo: TimelineItemDebugInfo,
|
val debugInfo: TimelineItemDebugInfo,
|
||||||
|
val origin: TimelineItemEventOrigin?,
|
||||||
) : TimelineItem {
|
) : TimelineItem {
|
||||||
|
|
||||||
val showSenderInformation = groupPosition.isNew() && !isMine
|
val showSenderInformation = groupPosition.isNew() && !isMine
|
||||||
|
|
@ -81,9 +83,8 @@ sealed interface TimelineItem {
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class GroupedEvents(
|
data class GroupedEvents(
|
||||||
|
val id: String,
|
||||||
val events: ImmutableList<Event>,
|
val events: ImmutableList<Event>,
|
||||||
) : TimelineItem {
|
) : TimelineItem
|
||||||
// use last id with a suffix. Last will not change in cas of new event from backpagination.
|
|
||||||
val id = "${events.last().id}_group"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
data class TimelineItemAudioContent(
|
||||||
|
val body: String,
|
||||||
|
val duration: Long,
|
||||||
|
val audioSource: MediaSource,
|
||||||
|
val mimeType: String,
|
||||||
|
val formattedFileSize: String,
|
||||||
|
val fileExtension: String,
|
||||||
|
) : TimelineItemEventContent {
|
||||||
|
|
||||||
|
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize)
|
||||||
|
override val type: String = "TimelineItemAudioContent"
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
|
||||||
|
override val values: Sequence<TimelineItemAudioContent>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aTimelineItemAudioContent("A sound.mp3"),
|
||||||
|
aTimelineItemAudioContent("A bigger name sound.mp3"),
|
||||||
|
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent(
|
||||||
|
body = fileName,
|
||||||
|
mimeType = MimeTypes.Pdf,
|
||||||
|
formattedFileSize = "100kB",
|
||||||
|
fileExtension = "mp3",
|
||||||
|
duration = 100,
|
||||||
|
audioSource = MediaSource(""),
|
||||||
|
)
|
||||||
|
|
@ -26,7 +26,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||||
aTimelineItemEncryptedContent(),
|
aTimelineItemEncryptedContent(),
|
||||||
aTimelineItemImageContent(),
|
aTimelineItemImageContent(),
|
||||||
aTimelineItemVideoContent(),
|
aTimelineItemVideoContent(),
|
||||||
aTimelineItemFileContent("A file.pdf"),
|
aTimelineItemFileContent(),
|
||||||
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
|
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
|
||||||
aTimelineItemLocationContent(),
|
aTimelineItemLocationContent(),
|
||||||
aTimelineItemLocationContent("Location description"),
|
aTimelineItemLocationContent("Location description"),
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineItemFileContent> {
|
open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineItemFileContent> {
|
||||||
override val values: Sequence<TimelineItemFileContent>
|
override val values: Sequence<TimelineItemFileContent>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aTimelineItemFileContent("A file.pdf"),
|
aTimelineItemFileContent(),
|
||||||
aTimelineItemFileContent("A bigger name file.pdf"),
|
aTimelineItemFileContent("A bigger name file.pdf"),
|
||||||
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
|
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
|
||||||
)
|
)
|
||||||
|
|
@ -31,7 +31,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
|
||||||
|
|
||||||
fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent(
|
fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent(
|
||||||
body = fileName,
|
body = fileName,
|
||||||
thumbnailSource = MediaSource(url = ""),
|
thumbnailSource = null,
|
||||||
fileSource = MediaSource(url = ""),
|
fileSource = MediaSource(url = ""),
|
||||||
mimeType = MimeTypes.Pdf,
|
mimeType = MimeTypes.Pdf,
|
||||||
formattedFileSize = "100kB",
|
formattedFileSize = "100kB",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
|
||||||
|
|
||||||
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||||
body = "Video.mp4",
|
body = "Video.mp4",
|
||||||
thumbnailSource = MediaSource(url = ""),
|
thumbnailSource = null,
|
||||||
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
||||||
aspectRatio = 0.5f,
|
aspectRatio = 0.5f,
|
||||||
duration = 100,
|
duration = 100,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* 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.impl.timeline.model.virtual
|
||||||
|
|
||||||
|
object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
|
||||||
|
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
|
@ -50,6 +51,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
|
||||||
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
|
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
|
||||||
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
|
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
|
||||||
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
|
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
|
||||||
|
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -574,6 +574,8 @@ class MessagesPresenterTest {
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
|
dispatchers = coroutineDispatchers,
|
||||||
|
appScope = this
|
||||||
)
|
)
|
||||||
val buildMeta = aBuildMeta()
|
val buildMeta = aBuildMeta()
|
||||||
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,5 @@ internal fun aMessageEvent(
|
||||||
localSendState = sendState,
|
localSendState = sendState,
|
||||||
inReplyTo = inReplyTo,
|
inReplyTo = inReplyTo,
|
||||||
debugInfo = debugInfo,
|
debugInfo = debugInfo,
|
||||||
|
origin = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
|
@ -84,7 +83,7 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.isFullScreen).isFalse()
|
assertThat(initialState.isFullScreen).isFalse()
|
||||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
assertThat(initialState.text).isEqualTo("")
|
||||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||||
|
|
@ -117,11 +116,11 @@ class MessageComposerPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
|
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
|
||||||
val withEmptyMessageState = awaitItem()
|
val withEmptyMessageState = awaitItem()
|
||||||
assertThat(withEmptyMessageState.text).isEqualTo(StableCharSequence(""))
|
assertThat(withEmptyMessageState.text).isEqualTo("")
|
||||||
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
|
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +137,7 @@ class MessageComposerPresenterTest {
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
assertThat(state.text).isEqualTo(A_MESSAGE)
|
||||||
assertThat(state.isSendButtonVisible).isTrue()
|
assertThat(state.isSendButtonVisible).isTrue()
|
||||||
backToNormalMode(state, skipCount = 1)
|
backToNormalMode(state, skipCount = 1)
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +154,7 @@ class MessageComposerPresenterTest {
|
||||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.text).isEqualTo(StableCharSequence(""))
|
assertThat(state.text).isEqualTo("")
|
||||||
assertThat(state.isSendButtonVisible).isFalse()
|
assertThat(state.isSendButtonVisible).isFalse()
|
||||||
backToNormalMode(state)
|
backToNormalMode(state)
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +171,7 @@ class MessageComposerPresenterTest {
|
||||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.text).isEqualTo(StableCharSequence(""))
|
assertThat(state.text).isEqualTo("")
|
||||||
assertThat(state.isSendButtonVisible).isFalse()
|
assertThat(state.isSendButtonVisible).isFalse()
|
||||||
backToNormalMode(state)
|
backToNormalMode(state)
|
||||||
}
|
}
|
||||||
|
|
@ -187,11 +186,11 @@ class MessageComposerPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
|
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
assertThat(messageSentState.text).isEqualTo("")
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -207,21 +206,21 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
assertThat(initialState.text).isEqualTo("")
|
||||||
val mode = anEditMode()
|
val mode = anEditMode()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
||||||
val withEditedMessageState = awaitItem()
|
val withEditedMessageState = awaitItem()
|
||||||
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
|
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
|
||||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
assertThat(messageSentState.text).isEqualTo("")
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||||
}
|
}
|
||||||
|
|
@ -238,21 +237,21 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
assertThat(initialState.text).isEqualTo("")
|
||||||
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
||||||
val withEditedMessageState = awaitItem()
|
val withEditedMessageState = awaitItem()
|
||||||
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
|
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
|
||||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
assertThat(messageSentState.text).isEqualTo("")
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||||
}
|
}
|
||||||
|
|
@ -269,21 +268,21 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
assertThat(initialState.text).isEqualTo("")
|
||||||
val mode = aReplyMode()
|
val mode = aReplyMode()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.text).isEqualTo(StableCharSequence(""))
|
assertThat(state.text).isEqualTo("")
|
||||||
assertThat(state.isSendButtonVisible).isFalse()
|
assertThat(state.isSendButtonVisible).isFalse()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
|
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_REPLY))
|
assertThat(withMessageState.text).isEqualTo(A_REPLY)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
|
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
assertThat(messageSentState.text).isEqualTo("")
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||||
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
|
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
|
||||||
}
|
}
|
||||||
|
|
@ -326,7 +325,7 @@ class MessageComposerPresenterTest {
|
||||||
Result.success(
|
Result.success(
|
||||||
MediaUploadInfo.Image(
|
MediaUploadInfo.Image(
|
||||||
file = File("/some/path"),
|
file = File("/some/path"),
|
||||||
info = ImageInfo(
|
imageInfo = ImageInfo(
|
||||||
width = null,
|
width = null,
|
||||||
height = null,
|
height = null,
|
||||||
mimetype = null,
|
mimetype = null,
|
||||||
|
|
@ -359,7 +358,7 @@ class MessageComposerPresenterTest {
|
||||||
Result.success(
|
Result.success(
|
||||||
MediaUploadInfo.Video(
|
MediaUploadInfo.Video(
|
||||||
file = File("/some/path"),
|
file = File("/some/path"),
|
||||||
info = VideoInfo(
|
videoInfo = VideoInfo(
|
||||||
width = null,
|
width = null,
|
||||||
height = null,
|
height = null,
|
||||||
mimetype = null,
|
mimetype = null,
|
||||||
|
|
@ -486,7 +485,7 @@ class MessageComposerPresenterTest {
|
||||||
skipItems(skipCount)
|
skipItems(skipCount)
|
||||||
val normalState = awaitItem()
|
val normalState = awaitItem()
|
||||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||||
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
|
assertThat(normalState.text).isEqualTo("")
|
||||||
assertThat(normalState.isSendButtonVisible).isFalse()
|
assertThat(normalState.isSendButtonVisible).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,22 +23,25 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
|
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.aMessageContent
|
||||||
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
|
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||||
|
import io.element.android.tests.testutils.awaitWithLatch
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class TimelinePresenterTest {
|
class TimelinePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = TimelinePresenter(
|
val presenter = createTimelinePresenter()
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
|
||||||
room = FakeMatrixRoom(),
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -51,10 +54,7 @@ class TimelinePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - load more`() = runTest {
|
fun `present - load more`() = runTest {
|
||||||
val presenter = TimelinePresenter(
|
val presenter = createTimelinePresenter()
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
|
||||||
room = FakeMatrixRoom(),
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -73,10 +73,7 @@ class TimelinePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - set highlighted event`() = runTest {
|
fun `present - set highlighted event`() = runTest {
|
||||||
val presenter = TimelinePresenter(
|
val presenter = createTimelinePresenter()
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
|
||||||
room = FakeMatrixRoom(),
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -94,70 +91,112 @@ class TimelinePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline()
|
val timeline = FakeMatrixTimeline(
|
||||||
val timelineItemsFactory = aTimelineItemsFactory().apply {
|
initialTimelineItems = listOf(
|
||||||
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem())))
|
MatrixTimelineItem.Event(0, anEventTimelineItem())
|
||||||
}
|
)
|
||||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
|
||||||
val presenter = TimelinePresenter(
|
|
||||||
timelineItemsFactory = timelineItemsFactory,
|
|
||||||
room = room,
|
|
||||||
)
|
)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
// Wait for timeline items to be populated
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
skipItems(1)
|
||||||
|
awaitWithLatch { latch ->
|
||||||
|
timeline.sendReadReceiptLatch = latch
|
||||||
|
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||||
|
}
|
||||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(1)
|
assertThat(timeline.sendReadReceiptCount).isEqualTo(1)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest {
|
fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline()
|
val timeline = FakeMatrixTimeline(
|
||||||
val timelineItemsFactory = aTimelineItemsFactory().apply {
|
initialTimelineItems = listOf(
|
||||||
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem())))
|
MatrixTimelineItem.Event(0, anEventTimelineItem())
|
||||||
}
|
)
|
||||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
|
||||||
val presenter = TimelinePresenter(
|
|
||||||
timelineItemsFactory = timelineItemsFactory,
|
|
||||||
room = room,
|
|
||||||
)
|
)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
// Wait for timeline items to be populated
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
skipItems(1)
|
||||||
|
awaitWithLatch { latch ->
|
||||||
|
timeline.sendReadReceiptLatch = latch
|
||||||
|
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||||
|
}
|
||||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
|
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
|
||||||
val timeline = FakeMatrixTimeline()
|
val timeline = FakeMatrixTimeline(
|
||||||
val timelineItemsFactory = aTimelineItemsFactory().apply {
|
initialTimelineItems = listOf(
|
||||||
replaceWith(listOf(MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)))
|
MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)
|
||||||
}
|
)
|
||||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
|
||||||
val presenter = TimelinePresenter(
|
|
||||||
timelineItemsFactory = timelineItemsFactory,
|
|
||||||
room = room,
|
|
||||||
)
|
)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
// Wait for timeline items to be populated
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
skipItems(1)
|
||||||
|
awaitWithLatch { latch ->
|
||||||
|
timeline.sendReadReceiptLatch = latch
|
||||||
|
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||||
|
}
|
||||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - covers hasNewItems scenarios`() = runTest {
|
||||||
|
val timeline = FakeMatrixTimeline()
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.hasNewItems).isFalse()
|
||||||
|
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||||
|
timeline.updateTimelineItems {
|
||||||
|
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
|
||||||
|
}
|
||||||
|
skipItems(1)
|
||||||
|
assertThat(awaitItem().timelineItems.size).isEqualTo(1)
|
||||||
|
timeline.updateTimelineItems { items ->
|
||||||
|
items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent())))
|
||||||
|
}
|
||||||
|
skipItems(1)
|
||||||
|
assertThat(awaitItem().timelineItems.size).isEqualTo(2)
|
||||||
|
assertThat(awaitItem().hasNewItems).isTrue()
|
||||||
|
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||||
|
assertThat(awaitItem().hasNewItems).isFalse()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createTimelinePresenter(
|
||||||
|
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||||
|
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
|
||||||
|
): TimelinePresenter {
|
||||||
|
return TimelinePresenter(
|
||||||
|
timelineItemsFactory = timelineItemsFactory,
|
||||||
|
room = FakeMatrixRoom(matrixTimeline = timeline),
|
||||||
|
dispatchers = testCoroutineDispatchers(),
|
||||||
|
appScope = this
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,13 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.fixtures.aMessageEvent
|
import io.element.android.features.messages.fixtures.aMessageEvent
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||||
|
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
|
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -36,7 +36,7 @@ class TimelineItemGrouperTest {
|
||||||
private val sut = TimelineItemGrouper()
|
private val sut = TimelineItemGrouper()
|
||||||
|
|
||||||
private val aGroupableItem = TimelineItem.Event(
|
private val aGroupableItem = TimelineItem.Event(
|
||||||
id = AN_EVENT_ID.value,
|
id = "0",
|
||||||
senderId = A_USER_ID,
|
senderId = A_USER_ID,
|
||||||
senderAvatar = anAvatarData(),
|
senderAvatar = anAvatarData(),
|
||||||
senderDisplayName = "",
|
senderDisplayName = "",
|
||||||
|
|
@ -45,6 +45,7 @@ class TimelineItemGrouperTest {
|
||||||
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||||
inReplyTo = null,
|
inReplyTo = null,
|
||||||
debugInfo = aTimelineItemDebugInfo(),
|
debugInfo = aTimelineItemDebugInfo(),
|
||||||
|
origin = null
|
||||||
)
|
)
|
||||||
private val aNonGroupableItem = aMessageEvent()
|
private val aNonGroupableItem = aMessageEvent()
|
||||||
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
|
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
|
||||||
|
|
@ -75,16 +76,17 @@ class TimelineItemGrouperTest {
|
||||||
fun `test groupables and ensure reordering`() {
|
fun `test groupables and ensure reordering`() {
|
||||||
val result = sut.group(
|
val result = sut.group(
|
||||||
listOf(
|
listOf(
|
||||||
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
|
aGroupableItem.copy(id = "1"),
|
||||||
aGroupableItem,
|
aGroupableItem.copy(id = "0"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
TimelineItem.GroupedEvents(
|
TimelineItem.GroupedEvents(
|
||||||
|
computeGroupIdWith(aGroupableItem),
|
||||||
events = listOf(
|
events = listOf(
|
||||||
aGroupableItem,
|
aGroupableItem.copy("0"),
|
||||||
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
|
aGroupableItem.copy(id = "1"),
|
||||||
).toImmutableList()
|
).toImmutableList()
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -127,6 +129,7 @@ class TimelineItemGrouperTest {
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
TimelineItem.GroupedEvents(
|
TimelineItem.GroupedEvents(
|
||||||
|
computeGroupIdWith(aGroupableItem),
|
||||||
events = listOf(
|
events = listOf(
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
|
|
@ -134,6 +137,7 @@ class TimelineItemGrouperTest {
|
||||||
),
|
),
|
||||||
aNonGroupableItem,
|
aNonGroupableItem,
|
||||||
TimelineItem.GroupedEvents(
|
TimelineItem.GroupedEvents(
|
||||||
|
computeGroupIdWith(aGroupableItem),
|
||||||
events = listOf(
|
events = listOf(
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
aGroupableItem,
|
aGroupableItem,
|
||||||
|
|
@ -143,4 +147,20 @@ class TimelineItemGrouperTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() {
|
||||||
|
// When
|
||||||
|
val groupableItems = mutableListOf(
|
||||||
|
aGroupableItem.copy(id = "1"),
|
||||||
|
aGroupableItem.copy(id = "2")
|
||||||
|
)
|
||||||
|
val expectedGroupId = sut.group(groupableItems).first().identifier()
|
||||||
|
groupableItems.add(0, aGroupableItem.copy("3"))
|
||||||
|
groupableItems.add(2, aGroupableItem.copy("4"))
|
||||||
|
groupableItems.add(aGroupableItem.copy("5"))
|
||||||
|
val actualGroupId = sut.group(groupableItems).first().identifier()
|
||||||
|
// Then
|
||||||
|
assertThat(actualGroupId).isEqualTo(expectedGroupId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.features.onboarding.impl
|
package io.element.android.features.onboarding.impl
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.QrCode
|
import androidx.compose.material.icons.filled.QrCode
|
||||||
|
|
@ -33,17 +31,17 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||||
import androidx.compose.ui.BiasAlignment
|
import androidx.compose.ui.BiasAlignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||||
|
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.theme.aliasButtonText
|
import io.element.android.libraries.designsystem.theme.aliasButtonText
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
|
@ -85,10 +83,6 @@ fun OnBoardingView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnBoardingContent(modifier: Modifier = Modifier) {
|
private fun OnBoardingContent(modifier: Modifier = Modifier) {
|
||||||
// Note: having a night variant of R.drawable.onboarding_icon in the folder `drawable-night` is working
|
|
||||||
// at runtime, but is not in Android Studio Preview. So I prefer to handle this manually.
|
|
||||||
val isLight = ElementTheme.colors.isLight
|
|
||||||
val iconDrawableRes = if (isLight) R.drawable.onboarding_icon_light else R.drawable.onboarding_icon_dark
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -99,14 +93,9 @@ private fun OnBoardingContent(modifier: Modifier = Modifier) {
|
||||||
verticalBias = -0.4f
|
verticalBias = -0.4f
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Dark and light icon does not have the same size, add padding to the smaller one
|
ElementLogoAtom(
|
||||||
val imagePadding = if (isLight) 28.dp else 0.dp
|
size = ElementLogoAtomSize.Large,
|
||||||
Image(
|
modifier = Modifier.padding(top = ElementLogoAtomSize.Large.shadowRadius / 2)
|
||||||
modifier = Modifier
|
|
||||||
.size(278.dp)
|
|
||||||
.padding(imagePadding),
|
|
||||||
painter = painterResource(id = iconDrawableRes),
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -200,17 +189,10 @@ private fun OnBoardingButtons(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@DayNightPreviews
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OnBoardingScreenLightPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
|
internal fun OnBoardingScreenPreview(
|
||||||
ElementPreviewLight { ContentToPreview(state) }
|
@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
|
||||||
|
) = ElementPreview {
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun OnBoardingScreenDarkPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
|
|
||||||
ElementPreviewDark { ContentToPreview(state) }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContentToPreview(state: OnBoardingState) {
|
|
||||||
OnBoardingView(state)
|
OnBoardingView(state)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 84 KiB |
|
|
@ -44,6 +44,7 @@ dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.features.rageshake.api)
|
implementation(projects.features.rageshake.api)
|
||||||
implementation(projects.features.analytics.api)
|
implementation(projects.features.analytics.api)
|
||||||
|
implementation(projects.features.ftue.api)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.features.logout.api)
|
implementation(projects.features.logout.api)
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,12 @@ import android.content.Context
|
||||||
import coil.Coil
|
import coil.Coil
|
||||||
import coil.annotation.ExperimentalCoilApi
|
import coil.annotation.ExperimentalCoilApi
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.features.preferences.impl.DefaultCacheService
|
import io.element.android.features.preferences.impl.DefaultCacheService
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -44,6 +44,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val defaultCacheIndexProvider: DefaultCacheService,
|
private val defaultCacheIndexProvider: DefaultCacheService,
|
||||||
private val okHttpClient: Provider<OkHttpClient>,
|
private val okHttpClient: Provider<OkHttpClient>,
|
||||||
|
private val ftueState: FtueState,
|
||||||
) : ClearCacheUseCase {
|
) : ClearCacheUseCase {
|
||||||
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
|
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
|
||||||
// Clear Matrix cache
|
// Clear Matrix cache
|
||||||
|
|
@ -57,6 +58,8 @@ class DefaultClearCacheUseCase @Inject constructor(
|
||||||
okHttpClient.get().cache?.delete()
|
okHttpClient.get().cache?.delete()
|
||||||
// Clear app cache
|
// Clear app cache
|
||||||
context.cacheDir.deleteRecursively()
|
context.cacheDir.deleteRecursively()
|
||||||
|
// Clear some settings
|
||||||
|
ftueState.reset()
|
||||||
// Ensure the app is restarted
|
// Ensure the app is restarted
|
||||||
defaultCacheIndexProvider.onClearedCache(matrixClient.sessionId)
|
defaultCacheIndexProvider.onClearedCache(matrixClient.sessionId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
|
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
|
||||||
?: PermalinkBuilder.permalinkForRoomId(room.roomId)
|
?: PermalinkBuilder.permalinkForRoomId(room.roomId)
|
||||||
permalinkResult.onSuccess { permalink ->
|
permalinkResult.onSuccess { permalink ->
|
||||||
startSharePlainTextIntent(
|
context.startSharePlainTextIntent(
|
||||||
context = context,
|
|
||||||
activityResultLauncher = null,
|
activityResultLauncher = null,
|
||||||
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
|
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
|
||||||
text = permalink,
|
text = permalink,
|
||||||
|
|
@ -91,8 +90,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
private fun onShareMember(context: Context, member: RoomMember) {
|
private fun onShareMember(context: Context, member: RoomMember) {
|
||||||
val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId)
|
val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId)
|
||||||
permalinkResult.onSuccess { permalink ->
|
permalinkResult.onSuccess { permalink ->
|
||||||
startSharePlainTextIntent(
|
context.startSharePlainTextIntent(
|
||||||
context = context,
|
|
||||||
activityResultLauncher = null,
|
activityResultLauncher = null,
|
||||||
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
|
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
|
||||||
text = permalink,
|
text = permalink,
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
|
||||||
fun onShareUser() {
|
fun onShareUser() {
|
||||||
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
|
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
|
||||||
permalinkResult.onSuccess { permalink ->
|
permalinkResult.onSuccess { permalink ->
|
||||||
startSharePlainTextIntent(
|
context.startSharePlainTextIntent(
|
||||||
context = context,
|
|
||||||
activityResultLauncher = null,
|
activityResultLauncher = null,
|
||||||
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
|
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
|
||||||
text = permalink,
|
text = permalink,
|
||||||
|
|
|
||||||
|
|
@ -605,7 +605,7 @@ class RoomDetailsEditPresenterTest {
|
||||||
Result.success(
|
Result.success(
|
||||||
MediaUploadInfo.AnyFile(
|
MediaUploadInfo.AnyFile(
|
||||||
file = processedFile,
|
file = processedFile,
|
||||||
info = mockk(),
|
fileInfo = mockk(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
|
||||||
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
|
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
|
||||||
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||||
# https://firebase.google.com/docs/android/setup#available-libraries
|
# https://firebase.google.com/docs/android/setup#available-libraries
|
||||||
google_firebase_bom = "com.google.firebase:firebase-bom:32.1.1"
|
google_firebase_bom = "com.google.firebase:firebase-bom:32.2.0"
|
||||||
|
|
||||||
# AndroidX
|
# AndroidX
|
||||||
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
|
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||||
|
|
@ -158,6 +158,7 @@ vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"
|
||||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
||||||
statemachine = "com.freeletics.flowredux:compose:1.1.0"
|
statemachine = "com.freeletics.flowredux:compose:1.1.0"
|
||||||
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
|
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
|
||||||
|
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.0"
|
||||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.0"
|
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.0"
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
|
|
|
||||||