Merge branch 'develop' into feature/fga/improve_timeline_file_rendering
24
.github/renovate.json
vendored
|
|
@ -1,18 +1,28 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema" : "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends" : [
|
||||||
"config:base"
|
"config:base"
|
||||||
],
|
],
|
||||||
"labels": ["dependencies"],
|
"labels" : [
|
||||||
"ignoreDeps": ["string:app_name"],
|
"dependencies"
|
||||||
"packageRules": [
|
],
|
||||||
|
"ignoreDeps" : [
|
||||||
|
"string:app_name"
|
||||||
|
],
|
||||||
|
"packageRules" : [
|
||||||
{
|
{
|
||||||
"matchPackagePatterns": [
|
"matchPackagePatterns" : [
|
||||||
"^org.jetbrains.kotlin",
|
"^org.jetbrains.kotlin",
|
||||||
"^com.google.devtools.ksp",
|
"^com.google.devtools.ksp",
|
||||||
"^androidx.compose.compiler"
|
"^androidx.compose.compiler"
|
||||||
],
|
],
|
||||||
"groupName": "kotlin"
|
"groupName" : "kotlin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames" : [
|
||||||
|
"org.jetbrains.kotlinx.kover"
|
||||||
|
],
|
||||||
|
"enabled" : false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
.github/workflows/recordScreenshots.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
name: Record screenshots
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Enrich gradle.properties for CI/CD
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
record:
|
||||||
|
name: Record screenshots on branch ${{ inputs.param_branch }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: ⏬ Checkout with LFS
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
lfs: 'true'
|
||||||
|
- name: ☕️ Use JDK 17
|
||||||
|
uses: actions/setup-java@v3
|
||||||
|
with:
|
||||||
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
|
java-version: '17'
|
||||||
|
- name: Record screenshots
|
||||||
|
run: "./.github/workflows/scripts/recordScreenshots.sh"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
|
||||||
55
.github/workflows/scripts/recordScreenshots.sh
vendored
Executable file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright (c) 2023 New Vector Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
if [[ -z ${GITHUB_TOKEN} ]]; then
|
||||||
|
echo "Missing GITHUB_TOKEN variable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${GITHUB_REPOSITORY} ]]; then
|
||||||
|
echo "Missing GITHUB_REPOSITORY variable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${GITHUB_REF_NAME} ]]; then
|
||||||
|
echo "Missing GITHUB_REF_NAME variable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "ElementBot"
|
||||||
|
git config user.email "benoitm+elementbot@element.io"
|
||||||
|
|
||||||
|
echo "Git status"
|
||||||
|
git status
|
||||||
|
|
||||||
|
echo "Fetching..."
|
||||||
|
git fetch --all
|
||||||
|
|
||||||
|
echo "Checkout origin/$GITHUB_REF_NAME"
|
||||||
|
git checkout "origin/$GITHUB_REF_NAME"
|
||||||
|
|
||||||
|
echo "Record screenshots"
|
||||||
|
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
||||||
|
|
||||||
|
echo "Committing changes"
|
||||||
|
git add -A
|
||||||
|
git commit -m "Update screenshots"
|
||||||
|
|
||||||
|
echo "Pushing changes"
|
||||||
|
git push "https://$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git"
|
||||||
|
echo "Done!"
|
||||||
2
.idea/dictionaries/shared.xml
generated
|
|
@ -2,6 +2,8 @@
|
||||||
<dictionary name="shared">
|
<dictionary name="shared">
|
||||||
<words>
|
<words>
|
||||||
<w>backstack</w>
|
<w>backstack</w>
|
||||||
|
<w>kover</w>
|
||||||
|
<w>onboarding</w>
|
||||||
<w>textfields</w>
|
<w>textfields</w>
|
||||||
</words>
|
</words>
|
||||||
</dictionary>
|
</dictionary>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
appId: ${APP_ID}
|
appId: ${APP_ID}
|
||||||
---
|
---
|
||||||
- tapOn: "Get started"
|
- tapOn: "Sign in manually"
|
||||||
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
||||||
- takeScreenshot: build/maestro/100-SignIn
|
- takeScreenshot: build/maestro/100-SignIn
|
||||||
- runFlow: changeServer.yaml
|
- runFlow: changeServer.yaml
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
appId: ${APP_ID}
|
appId: ${APP_ID}
|
||||||
---
|
---
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Own your conversations."
|
visible: "Communicate and collaborate securely"
|
||||||
timeout: 10_000
|
timeout: 10_000
|
||||||
|
|
|
||||||
|
|
@ -142,15 +142,6 @@ android {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waiting for https://github.com/google/ksp/issues/37
|
|
||||||
applicationVariants.all {
|
|
||||||
kotlin.sourceSets {
|
|
||||||
getByName(name) {
|
|
||||||
kotlin.srcDir("build/generated/ksp/$name/kotlin")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,10 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
tryToRestoreLatestSession(
|
tryToRestoreLatestSession(
|
||||||
onSuccess = { switchToLoggedInFlow(it) },
|
onSuccess = { switchToLoggedInFlow(it) },
|
||||||
onFailure = { switchToLogoutFlow() }
|
onFailure = { switchToNotLoggedInFlow() }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
switchToLogoutFlow()
|
switchToNotLoggedInFlow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
|
|
@ -106,7 +106,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
|
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun switchToLogoutFlow() {
|
private fun switchToNotLoggedInFlow() {
|
||||||
matrixClientsHolder.removeAll()
|
matrixClientsHolder.removeAll()
|
||||||
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,8 @@ koverMerged {
|
||||||
includes += "*Presenter"
|
includes += "*Presenter"
|
||||||
excludes += "*Fake*Presenter"
|
excludes += "*Fake*Presenter"
|
||||||
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
|
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
|
||||||
|
// Too small presenter, cannot reach the threshold.
|
||||||
|
excludes += "io.element.android.features.onboarding.impl.OnBoardingPresenter"
|
||||||
}
|
}
|
||||||
bound {
|
bound {
|
||||||
minValue = 90
|
minValue = 90
|
||||||
|
|
|
||||||
1
changelog.d/483.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Redesign the timeline item context menu using M3 bottom sheet
|
||||||
|
|
@ -118,6 +118,8 @@ class MessagesPresenter @Inject constructor(
|
||||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||||
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
||||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
||||||
|
TimelineItemAction.Developer -> notImplementedYet()
|
||||||
|
TimelineItemAction.ReportContent -> notImplementedYet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl
|
package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
|
@ -32,27 +31,22 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
|
||||||
import androidx.compose.material.ListItem
|
|
||||||
import androidx.compose.material.ModalBottomSheetValue
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
|
||||||
import androidx.compose.material.icons.filled.Collections
|
|
||||||
import androidx.compose.material.icons.filled.PhotoCamera
|
|
||||||
import androidx.compose.material.icons.filled.Videocam
|
|
||||||
import androidx.compose.material.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -66,9 +60,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
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.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
import io.element.android.features.messages.impl.timeline.TimelineView
|
||||||
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.networkmonitor.api.ui.ConnectivityIndicatorView
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
|
|
@ -80,7 +72,6 @@ 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.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
|
@ -91,7 +82,7 @@ import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import io.element.android.libraries.ui.strings.R as StringsR
|
import io.element.android.libraries.ui.strings.R as StringsR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesView(
|
fun MessagesView(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
|
|
@ -103,26 +94,11 @@ fun MessagesView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
|
||||||
initialValue = ModalBottomSheetValue.Hidden,
|
|
||||||
)
|
|
||||||
val composerState = state.composerState
|
|
||||||
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.showAttachmentSourcePicker) {
|
|
||||||
ModalBottomSheetValue.Expanded
|
|
||||||
} else {
|
|
||||||
ModalBottomSheetValue.Hidden
|
|
||||||
}
|
|
||||||
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
||||||
|
|
||||||
BackHandler(enabled = bottomSheetState.isVisible) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
bottomSheetState.hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
|
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
|
||||||
if (snackbarMessageText != null) {
|
if (snackbarMessageText != null) {
|
||||||
|
|
@ -150,78 +126,57 @@ fun MessagesView(
|
||||||
Timber.v("OnMessageLongClicked= ${event.id}")
|
Timber.v("OnMessageLongClicked= ${event.id}")
|
||||||
localView.hideKeyboard()
|
localView.hideKeyboard()
|
||||||
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
|
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
|
||||||
coroutineScope.launch {
|
isMessageActionsBottomSheetVisible = true
|
||||||
itemActionsBottomSheetState.show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
||||||
|
isMessageActionsBottomSheetVisible = false
|
||||||
state.eventSink(MessagesEvents.HandleAction(action, event))
|
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(composerState.showAttachmentSourcePicker) {
|
fun onDismissActionListBottomSheet() {
|
||||||
if (composerState.showAttachmentSourcePicker) {
|
isMessageActionsBottomSheetVisible = false
|
||||||
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
|
||||||
localView.hideKeyboard()
|
|
||||||
bottomSheetState.show()
|
|
||||||
} else {
|
|
||||||
bottomSheetState.hide()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
|
|
||||||
LaunchedEffect(bottomSheetState.isVisible) {
|
|
||||||
if (!bottomSheetState.isVisible) {
|
|
||||||
composerState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ModalBottomSheetLayout(
|
|
||||||
sheetState = bottomSheetState,
|
|
||||||
displayHandle = true,
|
|
||||||
sheetContent = {
|
|
||||||
AttachmentSourcePickerMenu(
|
|
||||||
eventSink = composerState.eventSink
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
|
||||||
topBar = {
|
|
||||||
Column {
|
|
||||||
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
|
||||||
MessagesViewTopBar(
|
|
||||||
roomTitle = state.roomName,
|
|
||||||
roomAvatar = state.roomAvatar,
|
|
||||||
onBackPressed = onBackPressed,
|
|
||||||
onRoomDetailsClicked = onRoomDetailsClicked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
content = { padding ->
|
|
||||||
MessagesViewContent(
|
|
||||||
state = state,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(padding)
|
|
||||||
.consumeWindowInsets(padding),
|
|
||||||
onMessageClicked = ::onMessageClicked,
|
|
||||||
onMessageLongClicked = ::onMessageLongClicked,
|
|
||||||
onUserDataClicked = onUserDataClicked,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = {
|
|
||||||
SnackbarHost(
|
|
||||||
snackbarHostState,
|
|
||||||
modifier = Modifier.navigationBarsPadding()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
ActionListView(
|
Scaffold(
|
||||||
state = state.actionListState,
|
modifier = modifier,
|
||||||
modalBottomSheetState = itemActionsBottomSheetState,
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
onActionSelected = ::onActionSelected
|
topBar = {
|
||||||
)
|
Column {
|
||||||
}
|
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
||||||
|
MessagesViewTopBar(
|
||||||
|
roomTitle = state.roomName,
|
||||||
|
roomAvatar = state.roomAvatar,
|
||||||
|
onBackPressed = onBackPressed,
|
||||||
|
onRoomDetailsClicked = onRoomDetailsClicked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { padding ->
|
||||||
|
MessagesViewContent(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.consumeWindowInsets(padding),
|
||||||
|
onMessageClicked = ::onMessageClicked,
|
||||||
|
onMessageLongClicked = ::onMessageLongClicked,
|
||||||
|
onUserDataClicked = onUserDataClicked,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(
|
||||||
|
snackbarHostState,
|
||||||
|
modifier = Modifier.navigationBarsPadding()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ActionListView(
|
||||||
|
state = state.actionListState,
|
||||||
|
isVisible = isMessageActionsBottomSheetVisible,
|
||||||
|
onDismiss = ::onDismissActionListBottomSheet,
|
||||||
|
onActionSelected = ::onActionSelected
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -312,36 +267,6 @@ fun MessagesViewTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
|
||||||
@Composable
|
|
||||||
internal fun AttachmentSourcePickerMenu(
|
|
||||||
eventSink: (MessageComposerEvents) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(modifier) {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
|
||||||
icon = { Icon(Icons.Default.Collections, null) },
|
|
||||||
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
|
||||||
)
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
|
||||||
icon = { Icon(Icons.Default.AttachFile, null) },
|
|
||||||
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
|
||||||
)
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
|
||||||
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
|
||||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
|
||||||
)
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
|
||||||
icon = { Icon(Icons.Default.Videocam, null) },
|
|
||||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =
|
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,15 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
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.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
|
class ActionListPresenter @Inject constructor(
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) : Presenter<ActionListState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ActionListState {
|
override fun present(): ActionListState {
|
||||||
|
|
@ -60,21 +63,30 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
|
||||||
when (timelineItem.content) {
|
when (timelineItem.content) {
|
||||||
is TimelineItemRedactedContent,
|
is TimelineItemRedactedContent,
|
||||||
is TimelineItemStateContent -> {
|
is TimelineItemStateContent -> {
|
||||||
// TODO Add Share action (also) here, and developer options
|
buildList {
|
||||||
emptyList()
|
add(TimelineItemAction.Copy)
|
||||||
}
|
if (buildMeta.isDebuggable) {
|
||||||
else -> {
|
add(TimelineItemAction.Developer)
|
||||||
mutableListOf(
|
|
||||||
TimelineItemAction.Reply,
|
|
||||||
TimelineItemAction.Forward,
|
|
||||||
TimelineItemAction.Copy,
|
|
||||||
).also {
|
|
||||||
if (timelineItem.isMine) {
|
|
||||||
it.add(TimelineItemAction.Edit)
|
|
||||||
it.add(TimelineItemAction.Redact)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else -> buildList<TimelineItemAction> {
|
||||||
|
add(TimelineItemAction.Reply)
|
||||||
|
add(TimelineItemAction.Forward)
|
||||||
|
if (timelineItem.isMine) {
|
||||||
|
add(TimelineItemAction.Edit)
|
||||||
|
}
|
||||||
|
add(TimelineItemAction.Copy)
|
||||||
|
if (buildMeta.isDebuggable) {
|
||||||
|
add(TimelineItemAction.Developer)
|
||||||
|
}
|
||||||
|
if (!timelineItem.isMine) {
|
||||||
|
add(TimelineItemAction.ReportContent)
|
||||||
|
}
|
||||||
|
if (timelineItem.isMine) {
|
||||||
|
add(TimelineItemAction.Redact)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,52 +16,81 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.actionlist
|
package io.element.android.features.messages.impl.actionlist
|
||||||
|
|
||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.ListItem
|
import androidx.compose.material.ListItem
|
||||||
import androidx.compose.material.ModalBottomSheetState
|
|
||||||
import androidx.compose.material.ModalBottomSheetValue
|
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AddReaction
|
||||||
|
import androidx.compose.material.icons.outlined.Attachment
|
||||||
|
import androidx.compose.material.icons.outlined.VideoCameraBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
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
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
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.components.blurhash.BlurHashAsyncImage
|
||||||
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.TimelineItemEncryptedContent
|
||||||
|
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.TimelineItemProfileChangeContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
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.Divider
|
||||||
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.ModalBottomSheetLayout
|
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||||
import kotlinx.coroutines.flow.filter
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
import kotlinx.coroutines.launch
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ActionListView(
|
fun ActionListView(
|
||||||
state: ActionListState,
|
state: ActionListState,
|
||||||
modalBottomSheetState: ModalBottomSheetState,
|
isVisible: Boolean,
|
||||||
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
LaunchedEffect(isVisible) {
|
||||||
LaunchedEffect(modalBottomSheetState) {
|
if (!isVisible) {
|
||||||
snapshotFlow { modalBottomSheetState.currentValue }
|
state.eventSink(ActionListEvents.Clear)
|
||||||
.filter { it == ModalBottomSheetValue.Hidden }
|
}
|
||||||
.collect {
|
|
||||||
state.eventSink(ActionListEvents.Clear)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onItemActionClicked(
|
fun onItemActionClicked(
|
||||||
|
|
@ -69,24 +98,22 @@ fun ActionListView(
|
||||||
targetItem: TimelineItem.Event
|
targetItem: TimelineItem.Event
|
||||||
) {
|
) {
|
||||||
onActionSelected(itemAction, targetItem)
|
onActionSelected(itemAction, targetItem)
|
||||||
coroutineScope.launch {
|
|
||||||
modalBottomSheetState.hide()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ModalBottomSheetLayout(
|
if (isVisible) {
|
||||||
modifier = modifier,
|
ModalBottomSheet(
|
||||||
sheetState = modalBottomSheetState,
|
onDismissRequest = onDismiss
|
||||||
sheetContent = {
|
) {
|
||||||
SheetContent(
|
SheetContent(
|
||||||
state = state,
|
state = state,
|
||||||
onActionClicked = ::onItemActionClicked,
|
onActionClicked = ::onItemActionClicked,
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.navigationBarsPadding()
|
.padding(bottom = 32.dp)
|
||||||
.imePadding()
|
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||||
|
// .imePadding()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
|
@ -108,6 +135,19 @@ private fun SheetContent(
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
Column {
|
||||||
|
MessageSummary(event = target.event, modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp))
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
EmojiReactionsRow(Modifier.fillMaxWidth())
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
items(
|
items(
|
||||||
items = actions,
|
items = actions,
|
||||||
) { action ->
|
) { action ->
|
||||||
|
|
@ -135,6 +175,141 @@ private fun SheetContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
|
||||||
|
val content: @Composable () -> Unit
|
||||||
|
var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.SMALL)) }
|
||||||
|
val contentStyle = ElementTextStyles.Regular.bodyMD.copy(color = MaterialTheme.colorScheme.secondary)
|
||||||
|
val imageModifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(9.dp))
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContentForBody(body: String) {
|
||||||
|
Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (event.content) {
|
||||||
|
is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) }
|
||||||
|
is TimelineItemStateContent -> content = { ContentForBody(event.content.body) }
|
||||||
|
is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) }
|
||||||
|
is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) }
|
||||||
|
is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) }
|
||||||
|
is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) }
|
||||||
|
is TimelineItemImageContent -> {
|
||||||
|
icon = {
|
||||||
|
val mediaRequestData = MediaRequestData(
|
||||||
|
source = event.content.mediaSource,
|
||||||
|
kind = MediaRequestData.Kind.Thumbnail(32),
|
||||||
|
)
|
||||||
|
BlurHashAsyncImage(
|
||||||
|
model = mediaRequestData,
|
||||||
|
blurHash = event.content.blurhash,
|
||||||
|
contentDescription = stringResource(StringR.string.common_image),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = imageModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content = { ContentForBody(event.content.body) }
|
||||||
|
}
|
||||||
|
is TimelineItemVideoContent -> {
|
||||||
|
icon = {
|
||||||
|
val thumbnailSource = event.content.thumbnailSource
|
||||||
|
if (thumbnailSource != null) {
|
||||||
|
val mediaRequestData = MediaRequestData(
|
||||||
|
source = event.content.thumbnailSource,
|
||||||
|
kind = MediaRequestData.Kind.Thumbnail(32),
|
||||||
|
)
|
||||||
|
BlurHashAsyncImage(
|
||||||
|
model = mediaRequestData,
|
||||||
|
blurHash = event.content.blurHash,
|
||||||
|
contentDescription = stringResource(StringR.string.common_video),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = imageModifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.VideoCameraBack,
|
||||||
|
contentDescription = stringResource(StringR.string.common_video),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = { ContentForBody(event.content.body) }
|
||||||
|
}
|
||||||
|
is TimelineItemFileContent -> {
|
||||||
|
icon = {
|
||||||
|
Box(
|
||||||
|
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Attachment,
|
||||||
|
contentDescription = stringResource(StringR.string.common_file),
|
||||||
|
modifier = Modifier.rotate(-45f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = { ContentForBody(event.content.body) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(modifier = modifier) {
|
||||||
|
icon()
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Row {
|
||||||
|
if (event.senderDisplayName != null) {
|
||||||
|
Text(
|
||||||
|
text = event.senderDisplayName,
|
||||||
|
style = ElementTextStyles.Bold.caption1,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
event.sentTime,
|
||||||
|
style = ElementTextStyles.Regular.caption2,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EmojiReactionsRow(modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
// TODO use real emojis, have real interaction
|
||||||
|
Text("\uD83D\uDC4D", fontSize = 28.dpToSp())
|
||||||
|
Text("\uD83D\uDC4E", fontSize = 28.dpToSp())
|
||||||
|
Text("\uD83D\uDD25", fontSize = 28.dpToSp())
|
||||||
|
Text("❤\uFE0F", fontSize = 28.dpToSp())
|
||||||
|
Text("\uD83D\uDC4F", fontSize = 28.dpToSp())
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.AddReaction,
|
||||||
|
contentDescription = "Emojis",
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
|
||||||
|
return dp.toSp()
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
||||||
|
|
@ -145,14 +320,7 @@ fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) s
|
||||||
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
|
||||||
ElementPreviewDark { ContentToPreview(state) }
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: ActionListState) {
|
private fun ContentToPreview(state: ActionListState) {
|
||||||
ActionListView(
|
SheetContent(state = state)
|
||||||
state = state,
|
|
||||||
modalBottomSheetState = ModalBottomSheetState(
|
|
||||||
initialValue = ModalBottomSheetValue.Expanded
|
|
||||||
),
|
|
||||||
onActionSelected = { _, _ -> }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,11 @@ sealed class TimelineItemAction(
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int,
|
||||||
val destructive: Boolean = false
|
val destructive: Boolean = false
|
||||||
) {
|
) {
|
||||||
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward)
|
object Forward : TimelineItemAction("Forward", VectorIcons.Forward)
|
||||||
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
|
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
|
||||||
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
|
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
|
||||||
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
|
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
|
||||||
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
|
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
|
||||||
|
object Developer : TimelineItemAction("Developer", VectorIcons.DeveloperMode)
|
||||||
|
object ReportContent : TimelineItemAction("Report content", VectorIcons.ReportContent, destructive = true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
/*
|
||||||
|
* 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.messagecomposer
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
|
import androidx.compose.material.ListItem
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Collections
|
||||||
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.messages.impl.R
|
||||||
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun AttachmentsBottomSheet(
|
||||||
|
state: MessageComposerState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val localView = LocalView.current
|
||||||
|
var isVisible by rememberSaveable { mutableStateOf(state.showAttachmentSourcePicker) }
|
||||||
|
|
||||||
|
BackHandler(enabled = isVisible) {
|
||||||
|
isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.showAttachmentSourcePicker) {
|
||||||
|
if (state.showAttachmentSourcePicker) {
|
||||||
|
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
||||||
|
localView.hideKeyboard()
|
||||||
|
isVisible = true
|
||||||
|
} else {
|
||||||
|
isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
|
||||||
|
LaunchedEffect(isVisible) {
|
||||||
|
if (!isVisible) {
|
||||||
|
state.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
modifier = modifier,
|
||||||
|
onDismissRequest = { isVisible = false }
|
||||||
|
) {
|
||||||
|
AttachmentSourcePickerMenu(eventSink = state.eventSink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun AttachmentSourcePickerMenu(
|
||||||
|
eventSink: (MessageComposerEvents) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier.padding(bottom = 32.dp)
|
||||||
|
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||||
|
icon = { Icon(Icons.Default.Collections, null) },
|
||||||
|
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||||
|
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||||
|
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||||
|
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||||
|
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||||
|
icon = { Icon(Icons.Default.Videocam, null) },
|
||||||
|
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
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.Preview
|
||||||
|
|
@ -46,21 +47,25 @@ fun MessageComposerView(
|
||||||
state.eventSink(MessageComposerEvents.UpdateText(text))
|
state.eventSink(MessageComposerEvents.UpdateText(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
TextComposer(
|
Box {
|
||||||
onSendMessage = ::sendMessage,
|
AttachmentsBottomSheet(state = state)
|
||||||
fullscreen = state.isFullScreen,
|
|
||||||
onFullscreenToggle = ::onFullscreenToggle,
|
TextComposer(
|
||||||
composerMode = state.mode,
|
onSendMessage = ::sendMessage,
|
||||||
onCloseSpecialMode = ::onCloseSpecialMode,
|
fullscreen = state.isFullScreen,
|
||||||
onComposerTextChange = ::onComposerTextChange,
|
onFullscreenToggle = ::onFullscreenToggle,
|
||||||
onAddAttachment = {
|
composerMode = state.mode,
|
||||||
state.eventSink(MessageComposerEvents.AddAttachment)
|
onCloseSpecialMode = ::onCloseSpecialMode,
|
||||||
},
|
onComposerTextChange = ::onComposerTextChange,
|
||||||
composerCanSendMessage = state.isSendButtonVisible,
|
onAddAttachment = {
|
||||||
composerText = state.text?.charSequence?.toString(),
|
state.eventSink(MessageComposerEvents.AddAttachment)
|
||||||
isInDarkMode = !ElementTheme.colors.isLight,
|
},
|
||||||
modifier = modifier
|
composerCanSendMessage = state.isSendButtonVisible,
|
||||||
)
|
composerText = state.text?.charSequence?.toString(),
|
||||||
|
isInDarkMode = !ElementTheme.colors.isLight,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,13 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.filled.AttachFile
|
import androidx.compose.material.icons.outlined.Attachment
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
|
@ -58,14 +59,14 @@ fun TimelineItemFileView(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier.size(20.dp),
|
imageVector = Icons.Outlined.Attachment,
|
||||||
imageVector = Icons.Filled.AttachFile,
|
contentDescription = "OpenFile",
|
||||||
contentDescription = "OpenFile"
|
modifier = Modifier.size(16.dp).rotate(-45f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp),) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp),) {
|
||||||
Text(
|
Text(
|
||||||
text = content.name,
|
text = content.body,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is FileMessageType -> TimelineItemFileContent(
|
is FileMessageType -> TimelineItemFileContent(
|
||||||
name = messageType.body,
|
body = messageType.body,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
fileSource = messageType.source,
|
fileSource = messageType.source,
|
||||||
mimeType = messageType.info?.mimetype,
|
mimeType = messageType.info?.mimetype,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
data class TimelineItemFileContent(
|
data class TimelineItemFileContent(
|
||||||
val name: String,
|
val body: String,
|
||||||
val fileSource: MediaSource,
|
val fileSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val formattedFileSize: String?,
|
val formattedFileSize: String?,
|
||||||
|
|
@ -27,7 +27,7 @@ data class TimelineItemFileContent(
|
||||||
) : TimelineItemEventContent {
|
) : TimelineItemEventContent {
|
||||||
override val type: String = "TimelineItemFileContent"
|
override val type: String = "TimelineItemFileContent"
|
||||||
|
|
||||||
private val fileExtension = name.substringAfterLast('.', "").uppercase()
|
private val fileExtension = body.substringAfterLast('.', "").uppercase()
|
||||||
val fileExtensionAndSize = buildString {
|
val fileExtensionAndSize = buildString {
|
||||||
append(fileExtension)
|
append(fileExtension)
|
||||||
if (formattedFileSize != null) {
|
if (formattedFileSize != null) {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent(
|
fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent(
|
||||||
name = fileName,
|
body = fileName,
|
||||||
thumbnailSource = MediaSource(url = ""),
|
thumbnailSource = MediaSource(url = ""),
|
||||||
fileSource = MediaSource(url = ""),
|
fileSource = MediaSource(url = ""),
|
||||||
mimeType = MimeTypes.OctetStream,
|
mimeType = MimeTypes.OctetStream,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -132,6 +134,32 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action report content`() = runTest {
|
||||||
|
val presenter = createMessagePresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
|
||||||
|
// Still a TODO in the code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action show developer info`() = runTest {
|
||||||
|
val presenter = createMessagePresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
||||||
|
// Still a TODO in the code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createMessagePresenter(
|
private fun TestScope.createMessagePresenter(
|
||||||
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
matrixRoom: MatrixRoom = FakeMatrixRoom()
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
|
|
@ -148,7 +176,20 @@ class MessagesPresenterTest {
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
)
|
)
|
||||||
val actionListPresenter = ActionListPresenter()
|
val buildMeta = BuildMeta(
|
||||||
|
buildType = BuildType.DEBUG,
|
||||||
|
isDebuggable = true,
|
||||||
|
applicationId = "",
|
||||||
|
applicationName = "",
|
||||||
|
lowPrivacyLoggingEnabled = true,
|
||||||
|
versionName = "",
|
||||||
|
gitRevision = "",
|
||||||
|
gitBranchName = "",
|
||||||
|
gitRevisionDate = "",
|
||||||
|
flavorDescription = "",
|
||||||
|
flavorShortDescription = "",
|
||||||
|
)
|
||||||
|
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
composerPresenter = messageComposerPresenter,
|
composerPresenter = messageComposerPresenter,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
|
||||||
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.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
|
@ -42,7 +44,7 @@ import org.junit.Test
|
||||||
class ActionListPresenterTest {
|
class ActionListPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -53,7 +55,7 @@ class ActionListPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for message from me redacted`() = runTest {
|
fun `present - compute for message from me redacted`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -67,6 +69,8 @@ class ActionListPresenterTest {
|
||||||
ActionListState.Target.Success(
|
ActionListState.Target.Success(
|
||||||
messageEvent,
|
messageEvent,
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -75,9 +79,10 @@ class ActionListPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for message from others redacted`() = runTest {
|
fun `present - compute for message from others redacted`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -91,6 +96,8 @@ class ActionListPresenterTest {
|
||||||
ActionListState.Target.Success(
|
ActionListState.Target.Success(
|
||||||
messageEvent,
|
messageEvent,
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -101,7 +108,7 @@ class ActionListPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for others message`() = runTest {
|
fun `present - compute for others message`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -121,6 +128,8 @@ class ActionListPresenterTest {
|
||||||
TimelineItemAction.Reply,
|
TimelineItemAction.Reply,
|
||||||
TimelineItemAction.Forward,
|
TimelineItemAction.Forward,
|
||||||
TimelineItemAction.Copy,
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
|
TimelineItemAction.ReportContent,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -131,7 +140,7 @@ class ActionListPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for my message`() = runTest {
|
fun `present - compute for my message`() = runTest {
|
||||||
val presenter = ActionListPresenter()
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -150,8 +159,41 @@ class ActionListPresenterTest {
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
TimelineItemAction.Reply,
|
TimelineItemAction.Reply,
|
||||||
TimelineItemAction.Forward,
|
TimelineItemAction.Forward,
|
||||||
TimelineItemAction.Copy,
|
|
||||||
TimelineItemAction.Edit,
|
TimelineItemAction.Edit,
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
|
TimelineItemAction.Redact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||||
|
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute message in non-debuggable build`() = runTest {
|
||||||
|
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
|
||||||
|
// val loadingState = awaitItem()
|
||||||
|
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
messageEvent,
|
||||||
|
persistentListOf(
|
||||||
|
TimelineItemAction.Reply,
|
||||||
|
TimelineItemAction.Forward,
|
||||||
|
TimelineItemAction.Edit,
|
||||||
|
TimelineItemAction.Copy,
|
||||||
TimelineItemAction.Redact,
|
TimelineItemAction.Redact,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -162,6 +204,34 @@ class ActionListPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aBuildMeta(
|
||||||
|
buildType: BuildType = BuildType.DEBUG,
|
||||||
|
isDebuggable: Boolean = true,
|
||||||
|
applicationName: String = "",
|
||||||
|
applicationId: String = "",
|
||||||
|
lowPrivacyLoggingEnabled: Boolean = true,
|
||||||
|
versionName: String = "",
|
||||||
|
gitRevision: String = "",
|
||||||
|
gitRevisionDate: String = "",
|
||||||
|
gitBranchName: String = "",
|
||||||
|
flavorDescription: String = "",
|
||||||
|
flavorShortDescription: String = "",
|
||||||
|
) = BuildMeta(
|
||||||
|
buildType,
|
||||||
|
isDebuggable,
|
||||||
|
applicationName,
|
||||||
|
applicationId,
|
||||||
|
lowPrivacyLoggingEnabled,
|
||||||
|
versionName,
|
||||||
|
gitRevision,
|
||||||
|
gitRevisionDate,
|
||||||
|
gitBranchName,
|
||||||
|
flavorDescription,
|
||||||
|
flavorShortDescription
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
|
||||||
|
|
||||||
private fun aMessageEvent(
|
private fun aMessageEvent(
|
||||||
isMine: Boolean,
|
isMine: Boolean,
|
||||||
content: TimelineItemEventContent,
|
content: TimelineItemEventContent,
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,6 @@ dependencies {
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(libs.accompanist.pager)
|
|
||||||
implementation(libs.accompanist.pagerindicator)
|
|
||||||
api(projects.features.onboarding.api)
|
api(projects.features.onboarding.api)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.onboarding.impl
|
package io.element.android.features.onboarding.impl
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
object OnBoardingConfig {
|
||||||
import androidx.annotation.StringRes
|
const val canLoginWithQrCode = false
|
||||||
|
const val canCreateAccount = false
|
||||||
data class SplashCarouselData(
|
|
||||||
val items: List<Item>
|
|
||||||
) {
|
|
||||||
data class Item(
|
|
||||||
@StringRes val title: Int,
|
|
||||||
@StringRes val body: Int,
|
|
||||||
@DrawableRes val image: Int,
|
|
||||||
@DrawableRes val pageBackground: Int
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ import io.element.android.libraries.di.AppScope
|
||||||
class OnBoardingNode @AssistedInject constructor(
|
class OnBoardingNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: OnBoardingPresenter,
|
||||||
) : Node(
|
) : Node(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins
|
plugins = plugins
|
||||||
|
|
@ -47,10 +48,11 @@ class OnBoardingNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
OnBoardingScreen(
|
val state = presenter.present()
|
||||||
|
OnBoardingView(
|
||||||
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onSignIn = this::onSignIn,
|
onSignIn = ::onSignIn,
|
||||||
onSignUp = this::onSignUp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.onboarding.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: this Presenter is ignored regarding code coverage because it cannot reach the coverage threshold.
|
||||||
|
* When this presenter get more code in it, please remove the ignore rule in the kover configuration.
|
||||||
|
*/
|
||||||
|
class OnBoardingPresenter @Inject constructor(
|
||||||
|
) : Presenter<OnBoardingState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): OnBoardingState {
|
||||||
|
return OnBoardingState(
|
||||||
|
canLoginWithQrCode = OnBoardingConfig.canLoginWithQrCode,
|
||||||
|
canCreateAccount = OnBoardingConfig.canCreateAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,182 +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.onboarding.impl
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
|
||||||
import com.google.accompanist.pager.HorizontalPagerIndicator
|
|
||||||
import com.google.accompanist.pager.rememberPagerState
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
|
||||||
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 kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OnBoardingScreen(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onPageChanged: (Int) -> Unit = {},
|
|
||||||
onSignUp: () -> Unit = {},
|
|
||||||
onSignIn: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val carrouselData = remember { SplashCarouselDataFactory().create() }
|
|
||||||
val nbOfPages = carrouselData.items.size
|
|
||||||
var key by remember { mutableStateOf(false) }
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.systemBarsPadding()
|
|
||||||
.padding(vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
val pagerState = rememberPagerState()
|
|
||||||
LaunchedEffect(key) {
|
|
||||||
launch {
|
|
||||||
delay(3_000)
|
|
||||||
pagerState.animateScrollToPage((pagerState.currentPage + 1) % nbOfPages)
|
|
||||||
// https://stackoverflow.com/questions/73714228/accompanist-pager-animatescrolltopage-doesnt-scroll-to-next-page-correctly
|
|
||||||
key = !key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(pagerState) {
|
|
||||||
// Collect from the pager state a snapshotFlow reading the currentPage
|
|
||||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
|
||||||
onPageChanged(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalPager(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
count = nbOfPages,
|
|
||||||
state = pagerState,
|
|
||||||
) { page ->
|
|
||||||
// Our page content
|
|
||||||
OnBoardingPage(carrouselData.items[page])
|
|
||||||
}
|
|
||||||
HorizontalPagerIndicator(
|
|
||||||
pagerState = pagerState,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(CenterHorizontally)
|
|
||||||
.padding(16.dp),
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSignIn()
|
|
||||||
},
|
|
||||||
enabled = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(CenterHorizontally)
|
|
||||||
.testTag(TestTags.onBoardingSignIn)
|
|
||||||
.padding(top = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.login_splash_submit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OnBoardingPage(
|
|
||||||
item: SplashCarouselData.Item,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
/*
|
|
||||||
Image(
|
|
||||||
painterResource(id = item.pageBackground),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(vertical = 16.dp, horizontal = 32.dp)
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painterResource(id = item.image),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(CenterHorizontally)
|
|
||||||
.size(192.dp)
|
|
||||||
.padding(16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = item.title),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(CenterHorizontally)
|
|
||||||
.padding(8.dp),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontSize = 24.sp,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = item.body),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(CenterHorizontally),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun OnBoardingScreenLightPreview() =
|
|
||||||
ElementPreviewLight { ContentToPreview() }
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun OnBoardingScreenDarkPreview() =
|
|
||||||
ElementPreviewDark { ContentToPreview() }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContentToPreview() {
|
|
||||||
OnBoardingScreen()
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* 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.onboarding.impl
|
||||||
|
|
||||||
|
data class OnBoardingState(
|
||||||
|
val canLoginWithQrCode: Boolean,
|
||||||
|
val canCreateAccount: Boolean,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.onboarding.impl
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
||||||
|
override val values: Sequence<OnBoardingState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
anOnBoardingState(),
|
||||||
|
anOnBoardingState(canLoginWithQrCode = true),
|
||||||
|
anOnBoardingState(canCreateAccount = true),
|
||||||
|
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun anOnBoardingState(
|
||||||
|
canLoginWithQrCode: Boolean = false,
|
||||||
|
canCreateAccount: Boolean = false
|
||||||
|
) = OnBoardingState(
|
||||||
|
canLoginWithQrCode = canLoginWithQrCode,
|
||||||
|
canCreateAccount = canCreateAccount
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/*
|
||||||
|
* 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.onboarding.impl
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.QrCode
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||||
|
import androidx.compose.ui.BiasAlignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
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.OutlinedButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.testtags.TestTags
|
||||||
|
import io.element.android.libraries.testtags.testTag
|
||||||
|
|
||||||
|
// Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
|
||||||
|
@Composable
|
||||||
|
fun OnBoardingView(
|
||||||
|
state: OnBoardingState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onSignInWithQrCode: () -> Unit = {},
|
||||||
|
onSignIn: () -> Unit = {},
|
||||||
|
onCreateAccount: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
OnBoardingPage(
|
||||||
|
modifier = modifier,
|
||||||
|
footer = {
|
||||||
|
OnBoardingButtons(
|
||||||
|
state = state,
|
||||||
|
onSignInWithQrCode = onSignInWithQrCode,
|
||||||
|
onSignIn = onSignIn,
|
||||||
|
onCreateAccount = onCreateAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
OnBoardingContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnBoardingContent(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = BiasAlignment(
|
||||||
|
horizontalBias = 0f,
|
||||||
|
verticalBias = -0.2f
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.element_logo),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
modifier = Modifier.padding(top = 14.dp),
|
||||||
|
painter = painterResource(id = R.drawable.element),
|
||||||
|
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
text = stringResource(id = R.string.screen_onboarding_subtitle),
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnBoardingButtons(
|
||||||
|
state: OnBoardingState,
|
||||||
|
onSignInWithQrCode: () -> Unit,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
onCreateAccount: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
if (state.canLoginWithQrCode) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSignInWithQrCode()
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.QrCode, contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Text(text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSignIn()
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(TestTags.onBoardingSignIn)
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.screen_onboarding_sign_in_manually))
|
||||||
|
}
|
||||||
|
if (state.canCreateAccount) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
onCreateAccount()
|
||||||
|
},
|
||||||
|
enabled = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.screen_onboarding_sign_up))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun OnBoardingScreenLightPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun OnBoardingScreenDarkPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: OnBoardingState) {
|
||||||
|
OnBoardingView(state)
|
||||||
|
}
|
||||||
|
|
@ -1,73 +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.onboarding.impl
|
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
|
|
||||||
class SplashCarouselDataFactory {
|
|
||||||
fun create(): SplashCarouselData {
|
|
||||||
val lightTheme = true
|
|
||||||
|
|
||||||
fun background(@DrawableRes lightDrawable: Int) =
|
|
||||||
if (lightTheme) lightDrawable else R.drawable.bg_color_background
|
|
||||||
|
|
||||||
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
|
|
||||||
if (lightTheme) lightDrawable else darkDrawable
|
|
||||||
|
|
||||||
return SplashCarouselData(
|
|
||||||
listOf(
|
|
||||||
SplashCarouselData.Item(
|
|
||||||
R.string.ftue_auth_carousel_secure_title,
|
|
||||||
R.string.ftue_auth_carousel_secure_body,
|
|
||||||
hero(
|
|
||||||
R.drawable.ic_splash_conversations,
|
|
||||||
R.drawable.ic_splash_conversations_dark
|
|
||||||
),
|
|
||||||
background(R.drawable.bg_carousel_page_1)
|
|
||||||
),
|
|
||||||
SplashCarouselData.Item(
|
|
||||||
R.string.ftue_auth_carousel_control_title,
|
|
||||||
R.string.ftue_auth_carousel_control_body,
|
|
||||||
hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark),
|
|
||||||
background(R.drawable.bg_carousel_page_2)
|
|
||||||
),
|
|
||||||
SplashCarouselData.Item(
|
|
||||||
R.string.ftue_auth_carousel_encrypted_title,
|
|
||||||
R.string.ftue_auth_carousel_encrypted_body,
|
|
||||||
hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark),
|
|
||||||
background(R.drawable.bg_carousel_page_3)
|
|
||||||
),
|
|
||||||
SplashCarouselData.Item(
|
|
||||||
collaborationTitle(),
|
|
||||||
R.string.ftue_auth_carousel_workplace_body,
|
|
||||||
hero(
|
|
||||||
R.drawable.ic_splash_collaboration,
|
|
||||||
R.drawable.ic_splash_collaboration_dark
|
|
||||||
),
|
|
||||||
background(R.drawable.bg_carousel_page_4)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collaborationTitle(): Int {
|
|
||||||
return when {
|
|
||||||
true -> R.string.cut_the_slack_from_teams
|
|
||||||
else -> R.string.ftue_auth_carousel_workplace_title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:angle="@integer/rtl_mirror_flip"
|
|
||||||
android:endColor="#3372C7DA"
|
|
||||||
android:startColor="#33BBE7CF" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:angle="@integer/rtl_mirror_flip"
|
|
||||||
android:endColor="#33B972DA"
|
|
||||||
android:startColor="#3372C7DA" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:angle="@integer/rtl_mirror_flip"
|
|
||||||
android:endColor="#330DBD8B"
|
|
||||||
android:startColor="#33B972DA" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:angle="@integer/rtl_mirror_flip"
|
|
||||||
android:endColor="#33BBE7CF"
|
|
||||||
android:startColor="#330DBD8B" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<solid android:color="?android:colorBackground" />
|
|
||||||
</shape>
|
|
||||||
27
features/onboarding/impl/src/main/res/drawable/element.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="160dp"
|
||||||
|
android:height="34dp"
|
||||||
|
android:viewportWidth="160"
|
||||||
|
android:viewportHeight="34">
|
||||||
|
<path
|
||||||
|
android:pathData="M22.18,23.71H5.07C5.27,25.51 5.92,26.94 7.02,28.01C8.12,29.05 9.56,29.57 11.35,29.57C12.53,29.57 13.6,29.28 14.56,28.71C15.51,28.13 16.19,27.35 16.59,26.36H21.79C21.1,28.65 19.8,30.5 17.89,31.92C16.01,33.31 13.79,34 11.22,34C7.87,34 5.16,32.89 3.08,30.66C1.03,28.43 0,25.61 0,22.2C0,18.87 1.04,16.08 3.12,13.82C5.2,11.56 7.88,10.43 11.18,10.43C14.47,10.43 17.13,11.55 19.15,13.78C21.2,15.97 22.22,18.75 22.22,22.11L22.18,23.71ZM11.18,14.64C9.56,14.64 8.22,15.12 7.15,16.08C6.08,17.03 5.42,18.3 5.16,19.9H17.11C16.88,18.3 16.25,17.03 15.21,16.08C14.17,15.12 12.82,14.64 11.18,14.64Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M25.77,26.75V0.93H30.93V26.84C30.93,28 31.56,28.58 32.83,28.58L33.74,28.53V33.44C33.25,33.52 32.73,33.57 32.18,33.57C29.96,33.57 28.33,33 27.29,31.87C26.28,30.75 25.77,29.04 25.77,26.75Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M57.75,23.71H40.64C40.84,25.51 41.49,26.94 42.59,28.01C43.69,29.05 45.13,29.57 46.92,29.57C48.11,29.57 49.18,29.28 50.13,28.71C51.08,28.13 51.76,27.35 52.16,26.36H57.36C56.67,28.65 55.37,30.5 53.46,31.92C51.59,33.31 49.36,34 46.79,34C43.44,34 40.73,32.89 38.65,30.66C36.6,28.43 35.57,25.61 35.57,22.2C35.57,18.87 36.61,16.08 38.69,13.82C40.77,11.56 43.46,10.43 46.75,10.43C50.04,10.43 52.7,11.55 54.72,13.78C56.77,15.97 57.8,18.75 57.8,22.11L57.75,23.71ZM46.75,14.64C45.13,14.64 43.79,15.12 42.72,16.08C41.65,17.03 40.99,18.3 40.73,19.9H52.68C52.45,18.3 51.82,17.03 50.78,16.08C49.74,15.12 48.4,14.64 46.75,14.64Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M81.01,20.55V33.48H75.86V19.98C75.86,16.57 74.44,14.86 71.61,14.86C70.08,14.86 68.85,15.35 67.93,16.34C67.03,17.32 66.59,18.67 66.59,20.37V33.48H61.43V10.95H66.2V13.95C66.75,12.94 67.58,12.1 68.71,11.43C69.84,10.77 71.24,10.43 72.91,10.43C76.03,10.43 78.28,11.62 79.67,13.99C81.58,11.62 84.12,10.43 87.29,10.43C89.92,10.43 91.94,11.26 93.36,12.91C94.77,14.53 95.48,16.67 95.48,19.33V33.48H90.33V19.98C90.33,16.57 88.91,14.86 86.08,14.86C84.52,14.86 83.28,15.37 82.36,16.38C81.46,17.36 81.01,18.75 81.01,20.55Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M121.12,23.71H104.01C104.21,25.51 104.86,26.94 105.96,28.01C107.06,29.05 108.5,29.57 110.29,29.57C111.47,29.57 112.54,29.28 113.5,28.71C114.45,28.13 115.13,27.35 115.53,26.36H120.73C120.04,28.65 118.74,30.5 116.83,31.92C114.96,33.31 112.73,34 110.16,34C106.81,34 104.1,32.89 102.02,30.66C99.97,28.43 98.94,25.61 98.94,22.2C98.94,18.87 99.98,16.08 102.06,13.82C104.14,11.56 106.82,10.43 110.12,10.43C113.41,10.43 116.07,11.55 118.09,13.78C120.14,15.97 121.16,18.75 121.16,22.11L121.12,23.71ZM110.12,14.64C108.5,14.64 107.16,15.12 106.09,16.08C105.02,17.03 104.36,18.3 104.1,19.9H116.05C115.82,18.3 115.19,17.03 114.15,16.08C113.11,15.12 111.76,14.64 110.12,14.64Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M129.56,10.95V13.95C130.08,12.97 130.94,12.14 132.12,11.48C133.33,10.78 134.79,10.43 136.5,10.43C139.15,10.43 141.2,11.24 142.65,12.86C144.12,14.48 144.86,16.64 144.86,19.33V33.48H139.7V19.98C139.7,18.39 139.33,17.15 138.57,16.25C137.85,15.32 136.74,14.86 135.24,14.86C133.59,14.86 132.29,15.35 131.34,16.34C130.42,17.32 129.95,18.68 129.95,20.42V33.48H124.8V10.95H129.56Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M159.91,28.88V33.35C159.28,33.52 158.38,33.61 157.23,33.61C152.84,33.61 150.64,31.4 150.64,26.97V15.08H147.22V10.95H150.64V5.1H155.8V10.95H160V15.08H155.8V26.45C155.8,28.21 156.63,29.1 158.31,29.1L159.91,28.88Z"
|
||||||
|
android:fillColor="#1B1D22"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="50dp"
|
||||||
|
android:height="49dp"
|
||||||
|
android:viewportWidth="50"
|
||||||
|
android:viewportHeight="49">
|
||||||
|
<path
|
||||||
|
android:pathData="M24.8,48.608C38.199,48.608 49.061,37.726 49.061,24.304C49.061,10.881 38.199,0 24.8,0C11.401,0 0.54,10.881 0.54,24.304C0.54,37.726 11.401,48.608 24.8,48.608Z"
|
||||||
|
android:fillColor="#0DBD8B"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M20.365,11.324C20.365,10.343 21.16,9.548 22.142,9.548C28.793,9.548 34.185,14.938 34.185,21.587C34.185,22.568 33.389,23.363 32.408,23.363C31.426,23.363 30.631,22.568 30.631,21.587C30.631,16.9 26.83,13.1 22.142,13.1C21.16,13.1 20.365,12.305 20.365,11.324Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M37.753,19.81C38.735,19.81 39.53,20.606 39.53,21.587C39.53,28.236 34.138,33.626 27.487,33.626C26.506,33.626 25.71,32.831 25.71,31.85C25.71,30.869 26.506,30.073 27.487,30.073C32.176,30.073 35.976,26.274 35.976,21.587C35.976,20.606 36.772,19.81 37.753,19.81Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M29.264,37.283C29.264,38.264 28.468,39.06 27.487,39.06C20.836,39.06 15.444,33.669 15.444,27.02C15.444,26.039 16.24,25.244 17.221,25.244C18.202,25.244 18.998,26.039 18.998,27.02C18.998,31.708 22.799,35.507 27.487,35.507C28.468,35.507 29.264,36.302 29.264,37.283Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M11.847,28.797C10.866,28.797 10.071,28.002 10.071,27.021C10.071,20.372 15.462,14.981 22.113,14.981C23.095,14.981 23.89,15.777 23.89,16.758C23.89,17.739 23.095,18.534 22.113,18.534C17.425,18.534 13.624,22.333 13.624,27.021C13.624,28.002 12.829,28.797 11.847,28.797Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<resources>
|
|
||||||
<!-- File to remove once the screen will be updated -->
|
|
||||||
|
|
||||||
<string name="cut_the_slack_from_teams" translatable="false">Cut the slack from teams.</string>
|
|
||||||
|
|
||||||
<string name="login_splash_submit">Get started</string>
|
|
||||||
|
|
||||||
<string name="ftue_auth_carousel_secure_title">Own your conversations.</string>
|
|
||||||
<string name="ftue_auth_carousel_control_title">You\'re in control.</string>
|
|
||||||
<string name="ftue_auth_carousel_encrypted_title">Secure messaging.</string>
|
|
||||||
<string name="ftue_auth_carousel_workplace_title">Messaging for your team.</string>
|
|
||||||
|
|
||||||
<string name="ftue_auth_carousel_secure_body">Secure and independent communication that gives you the same level of privacy as a face-to-face conversation in your own home.</string>
|
|
||||||
<string name="ftue_auth_carousel_control_body">Choose where your conversations are kept, giving you control and independence. Connected via Matrix.</string>
|
|
||||||
<string name="ftue_auth_carousel_encrypted_body">End-to-end encrypted and no phone number required. No ads or datamining.</string>
|
|
||||||
|
|
||||||
<string name="ftue_auth_carousel_workplace_body">Element is also great for the workplace. It’s trusted by the world’s most secure organisations.</string>
|
|
||||||
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* 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.onboarding.impl
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class OnBoardingPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = OnBoardingPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.canLoginWithQrCode).isFalse()
|
||||||
|
assertThat(initialState.canCreateAccount).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -96,8 +96,6 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi
|
||||||
accompanist_material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" }
|
accompanist_material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" }
|
||||||
accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
|
accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
|
||||||
accompanist_placeholder = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" }
|
accompanist_placeholder = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" }
|
||||||
accompanist_pager = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist" }
|
|
||||||
accompanist_pagerindicator = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" }
|
|
||||||
accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
|
accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
|
||||||
|
|
||||||
# Libraries
|
# Libraries
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ inline fun <reified NODE : Node> Context.createNode(context: BuildContext, plugi
|
||||||
inline fun <reified NODE : Node> NodeFactoriesBindings.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE {
|
inline fun <reified NODE : Node> NodeFactoriesBindings.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE {
|
||||||
val nodeClass = NODE::class.java
|
val nodeClass = NODE::class.java
|
||||||
val nodeFactoryMap = nodeFactories()
|
val nodeFactoryMap = nodeFactories()
|
||||||
|
// Note to developers: If you got the error below, make sure to build again after
|
||||||
|
// clearing the cache (sometimes several times) to let Dagger generate the NodeFactory.
|
||||||
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.")
|
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.")
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@ package io.element.android.libraries.designsystem
|
||||||
|
|
||||||
object VectorIcons {
|
object VectorIcons {
|
||||||
val Copy = R.drawable.ic_content_copy
|
val Copy = R.drawable.ic_content_copy
|
||||||
val ArrowForward = R.drawable.ic_content_arrow_forward
|
val Forward = R.drawable.ic_forward
|
||||||
val Delete = R.drawable.ic_baseline_delete_outline_24
|
val Delete = R.drawable.ic_delete
|
||||||
val Reply = R.drawable.ic_baseline_reply_24
|
val Reply = R.drawable.ic_reply
|
||||||
val Edit = R.drawable.ic_baseline_edit_24
|
val Edit = R.drawable.ic_edit
|
||||||
val DoorOpen = R.drawable.ic_door_open_24
|
val DoorOpen = R.drawable.ic_door_open_24
|
||||||
|
val DeveloperMode = R.drawable.ic_developer_mode
|
||||||
|
val ReportContent = R.drawable.ic_report_content
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.atomic.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.libraries.designsystem.R
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page for onboarding screens, with content and optional footer.
|
||||||
|
*
|
||||||
|
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
|
||||||
|
* @param modifier Classical modifier.
|
||||||
|
* @param footer optional footer.
|
||||||
|
* @param content main content.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnBoardingPage(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
footer: @Composable () -> Unit = {},
|
||||||
|
content: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// BG
|
||||||
|
Image(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
painter = painterResource(id = R.drawable.onboarding_bg),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
// Content
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
// Footer
|
||||||
|
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
footer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun OnBoardingPageLightPreview() =
|
||||||
|
ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun OnBoardingPageDarkPreview() =
|
||||||
|
ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
OnBoardingPage(
|
||||||
|
content = {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Content",
|
||||||
|
fontSize = 40.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer = {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Footer",
|
||||||
|
fontSize = 40.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<!--
|
|
||||||
~ 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<vector android:height="24dp"
|
|
||||||
android:tint="#000000"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:width="24dp"
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7v12zM8,9h8v10L8,19L8,9zM15.5,4l-1,-1h-5l-1,1L5,4v2h14L19,4z" />
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<!--
|
|
||||||
~ 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.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<vector android:height="24dp"
|
|
||||||
android:tint="#000000"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:width="24dp"
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
|
|
||||||
</vector>
|
|
||||||
10
libraries/designsystem/src/main/res/drawable/ic_delete.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M344,664L160,480L344,296L400,354L274,480L400,606L344,664ZM200,680L280,680L280,720L680,720L680,680L760,680L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920Q247,920 223.5,896.5Q200,873 200,840L200,680ZM280,280L200,280L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,280L680,280L680,240L280,240L280,280ZM280,800L280,840Q280,840 280,840Q280,840 280,840L680,840Q680,840 680,840Q680,840 680,840L680,800L280,800ZM280,160L680,160L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,160ZM616,664L560,606L686,480L560,354L616,296L800,480L616,664ZM280,160L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,160L280,160ZM280,800L280,800L280,840Q280,840 280,840Q280,840 280,840L280,840Q280,840 280,840Q280,840 280,840L280,800Z"/>
|
||||||
|
</vector>
|
||||||
10
libraries/designsystem/src/main/res/drawable/ic_edit.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L658,133Q681,110 714.5,110Q748,110 771,133L827,189Q850,212 851,244.5Q852,277 829,300L772,357ZM714,416L290,840L120,840L120,670L544,246L714,416ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/>
|
||||||
|
</vector>
|
||||||
11
libraries/designsystem/src/main/res/drawable/ic_forward.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M120,760L120,600Q120,517 178.5,458.5Q237,400 320,400L688,400L544,256L600,200L840,440L600,680L544,624L688,480L320,480Q270,480 235,515Q200,550 200,600L200,760L120,760Z"/>
|
||||||
|
</vector>
|
||||||
11
libraries/designsystem/src/main/res/drawable/ic_reply.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L272,480L416,624L360,680L120,440L360,200L416,256L272,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640L160,640Z"/>
|
||||||
|
</vector>
|
||||||
BIN
libraries/designsystem/src/main/res/drawable/onboarding_bg.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
|
|
@ -20,15 +20,23 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
//TODO add content
|
|
||||||
data class NotificationData(
|
data class NotificationData(
|
||||||
val senderId: UserId,
|
val senderId: UserId,
|
||||||
val eventId: EventId,
|
val eventId: EventId,
|
||||||
val roomId: RoomId,
|
val roomId: RoomId,
|
||||||
val senderAvatarUrl: String? = null,
|
val senderAvatarUrl: String?,
|
||||||
val senderDisplayName: String? = null,
|
val senderDisplayName: String?,
|
||||||
val roomAvatarUrl: String? = null,
|
val roomAvatarUrl: String?,
|
||||||
|
val roomDisplayName: String?,
|
||||||
val isDirect: Boolean,
|
val isDirect: Boolean,
|
||||||
val isEncrypted: Boolean,
|
val isEncrypted: Boolean,
|
||||||
val isNoisy: Boolean,
|
val isNoisy: Boolean,
|
||||||
|
val event: NotificationEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NotificationEvent(
|
||||||
|
val timestamp: Long,
|
||||||
|
val content: String,
|
||||||
|
// For images for instance
|
||||||
|
val contentUrl: String?
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||||
import org.matrix.rustcomponents.sdk.NotificationItem
|
import org.matrix.rustcomponents.sdk.NotificationItem
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class NotificationMapper @Inject constructor() {
|
class NotificationMapper {
|
||||||
|
private val timelineEventMapper = TimelineEventMapper()
|
||||||
|
|
||||||
fun map(notificationItem: NotificationItem): NotificationData {
|
fun map(notificationItem: NotificationItem): NotificationData {
|
||||||
return notificationItem.use {
|
return notificationItem.use {
|
||||||
|
|
@ -36,9 +36,11 @@ class NotificationMapper @Inject constructor() {
|
||||||
senderAvatarUrl = it.senderAvatarUrl,
|
senderAvatarUrl = it.senderAvatarUrl,
|
||||||
senderDisplayName = it.senderDisplayName,
|
senderDisplayName = it.senderDisplayName,
|
||||||
roomAvatarUrl = it.roomAvatarUrl,
|
roomAvatarUrl = it.roomAvatarUrl,
|
||||||
|
roomDisplayName = it.roomDisplayName,
|
||||||
isDirect = it.isDirect,
|
isDirect = it.isDirect,
|
||||||
isEncrypted = it.isEncrypted.orFalse(),
|
isEncrypted = it.isEncrypted.orFalse(),
|
||||||
isNoisy = it.isNoisy
|
isNoisy = it.isNoisy,
|
||||||
|
event = it.event.use { event -> timelineEventMapper.map(event) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,13 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.notification
|
package io.element.android.libraries.matrix.impl.notification
|
||||||
|
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class RustNotificationService(
|
class RustNotificationService(
|
||||||
private val client: Client,
|
private val client: Client,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.notification
|
||||||
|
|
||||||
|
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.notification.NotificationEvent
|
||||||
|
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
|
||||||
|
import org.matrix.rustcomponents.sdk.MessageType
|
||||||
|
import org.matrix.rustcomponents.sdk.StateEventContent
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineEvent
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineEventType
|
||||||
|
import org.matrix.rustcomponents.sdk.use
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TimelineEventMapper @Inject constructor() {
|
||||||
|
|
||||||
|
fun map(timelineEvent: TimelineEvent): NotificationEvent {
|
||||||
|
return timelineEvent.use {
|
||||||
|
NotificationEvent(
|
||||||
|
timestamp = it.timestamp().toLong(),
|
||||||
|
content = it.eventType().toContent(),
|
||||||
|
contentUrl = null // TODO it.eventType().toContentUrl(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TimelineEventType.toContent(): String {
|
||||||
|
return when (this) {
|
||||||
|
is TimelineEventType.MessageLike -> content.toContent()
|
||||||
|
is TimelineEventType.State -> content.toContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StateEventContent.toContent(): String {
|
||||||
|
return when (this) {
|
||||||
|
StateEventContent.PolicyRuleRoom -> "PolicyRuleRoom"
|
||||||
|
StateEventContent.PolicyRuleServer -> "PolicyRuleServer"
|
||||||
|
StateEventContent.PolicyRuleUser -> "PolicyRuleUser"
|
||||||
|
StateEventContent.RoomAliases -> "RoomAliases"
|
||||||
|
StateEventContent.RoomAvatar -> "RoomAvatar"
|
||||||
|
StateEventContent.RoomCanonicalAlias -> "RoomCanonicalAlias"
|
||||||
|
StateEventContent.RoomCreate -> "RoomCreate"
|
||||||
|
StateEventContent.RoomEncryption -> "RoomEncryption"
|
||||||
|
StateEventContent.RoomGuestAccess -> "RoomGuestAccess"
|
||||||
|
StateEventContent.RoomHistoryVisibility -> "RoomHistoryVisibility"
|
||||||
|
StateEventContent.RoomJoinRules -> "RoomJoinRules"
|
||||||
|
is StateEventContent.RoomMemberContent -> "$userId is now $membershipState"
|
||||||
|
StateEventContent.RoomName -> "RoomName"
|
||||||
|
StateEventContent.RoomPinnedEvents -> "RoomPinnedEvents"
|
||||||
|
StateEventContent.RoomPowerLevels -> "RoomPowerLevels"
|
||||||
|
StateEventContent.RoomServerAcl -> "RoomServerAcl"
|
||||||
|
StateEventContent.RoomThirdPartyInvite -> "RoomThirdPartyInvite"
|
||||||
|
StateEventContent.RoomTombstone -> "RoomTombstone"
|
||||||
|
StateEventContent.RoomTopic -> "RoomTopic"
|
||||||
|
StateEventContent.SpaceChild -> "SpaceChild"
|
||||||
|
StateEventContent.SpaceParent -> "SpaceParent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MessageLikeEventContent.toContent(): String {
|
||||||
|
return use {
|
||||||
|
when (it) {
|
||||||
|
MessageLikeEventContent.CallAnswer -> "CallAnswer"
|
||||||
|
MessageLikeEventContent.CallCandidates -> "CallCandidates"
|
||||||
|
MessageLikeEventContent.CallHangup -> "CallHangup"
|
||||||
|
MessageLikeEventContent.CallInvite -> "CallInvite"
|
||||||
|
MessageLikeEventContent.KeyVerificationAccept -> "KeyVerificationAccept"
|
||||||
|
MessageLikeEventContent.KeyVerificationCancel -> "KeyVerificationCancel"
|
||||||
|
MessageLikeEventContent.KeyVerificationDone -> "KeyVerificationDone"
|
||||||
|
MessageLikeEventContent.KeyVerificationKey -> "KeyVerificationKey"
|
||||||
|
MessageLikeEventContent.KeyVerificationMac -> "KeyVerificationMac"
|
||||||
|
MessageLikeEventContent.KeyVerificationReady -> "KeyVerificationReady"
|
||||||
|
MessageLikeEventContent.KeyVerificationStart -> "KeyVerificationStart"
|
||||||
|
is MessageLikeEventContent.ReactionContent -> "Reacted to ${it.relatedEventId.take(8)}…"
|
||||||
|
MessageLikeEventContent.RoomEncrypted -> "RoomEncrypted"
|
||||||
|
is MessageLikeEventContent.RoomMessage -> it.messageType.toContent()
|
||||||
|
MessageLikeEventContent.RoomRedaction -> "RoomRedaction"
|
||||||
|
MessageLikeEventContent.Sticker -> "Sticker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MessageType.toContent(): String {
|
||||||
|
return when (this) {
|
||||||
|
is MessageType.Audio -> content.use { it.body }
|
||||||
|
is MessageType.Emote -> content.body
|
||||||
|
is MessageType.File -> content.use { it.body }
|
||||||
|
is MessageType.Image -> content.use { it.body }
|
||||||
|
is MessageType.Notice -> content.body
|
||||||
|
is MessageType.Text -> content.body
|
||||||
|
is MessageType.Video -> content.use { it.body }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ dependencies {
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.network.retrofit)
|
implementation(libs.network.retrofit)
|
||||||
implementation(libs.serialization.json)
|
implementation(libs.serialization.json)
|
||||||
|
implementation(libs.coil)
|
||||||
|
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
|
@ -42,6 +43,7 @@ dependencies {
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.network)
|
implementation(projects.libraries.network)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.matrixui)
|
||||||
api(projects.libraries.pushproviders.api)
|
api(projects.libraries.pushproviders.api)
|
||||||
api(projects.libraries.pushstore.api)
|
api(projects.libraries.pushstore.api)
|
||||||
api(projects.libraries.push.api)
|
api(projects.libraries.push.api)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||||
|
import io.element.android.libraries.matrix.api.notification.NotificationEvent
|
||||||
import io.element.android.libraries.push.impl.log.pushLoggerTag
|
import io.element.android.libraries.push.impl.log.pushLoggerTag
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
|
@ -44,9 +45,9 @@ class NotifiableEventResolver @Inject constructor(
|
||||||
private val stringProvider: StringProvider,
|
private val stringProvider: StringProvider,
|
||||||
// private val noticeEventFormatter: NoticeEventFormatter,
|
// private val noticeEventFormatter: NoticeEventFormatter,
|
||||||
// private val displayableEventFormatter: DisplayableEventFormatter,
|
// private val displayableEventFormatter: DisplayableEventFormatter,
|
||||||
private val clock: SystemClock,
|
|
||||||
private val matrixAuthenticationService: MatrixAuthenticationService,
|
private val matrixAuthenticationService: MatrixAuthenticationService,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val clock: SystemClock,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
|
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
|
||||||
|
|
@ -80,14 +81,14 @@ class NotifiableEventResolver @Inject constructor(
|
||||||
editedEventId = null,
|
editedEventId = null,
|
||||||
canBeReplaced = true,
|
canBeReplaced = true,
|
||||||
noisy = isNoisy,
|
noisy = isNoisy,
|
||||||
timestamp = clock.epochMillis(),
|
timestamp = event.timestamp,
|
||||||
senderName = senderDisplayName,
|
senderName = senderDisplayName,
|
||||||
senderId = senderId.value,
|
senderId = senderId.value,
|
||||||
body = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}…",
|
body = event.content,
|
||||||
imageUriString = null,
|
imageUriString = event.contentUrl,
|
||||||
threadId = null,
|
threadId = null,
|
||||||
roomName = null,
|
roomName = roomDisplayName,
|
||||||
roomIsDirect = false,
|
roomIsDirect = isDirect,
|
||||||
roomAvatarPath = roomAvatarUrl,
|
roomAvatarPath = roomAvatarUrl,
|
||||||
senderAvatarPath = senderAvatarUrl,
|
senderAvatarPath = senderAvatarUrl,
|
||||||
soundName = null,
|
soundName = null,
|
||||||
|
|
@ -97,18 +98,27 @@ class NotifiableEventResolver @Inject constructor(
|
||||||
isUpdated = false
|
isUpdated = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO This is a temporary method for EAx.
|
* TODO This is a temporary method for EAx.
|
||||||
*/
|
*/
|
||||||
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
|
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
|
||||||
return this ?: NotificationData(
|
return this ?: NotificationData(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
senderId = UserId("@user:domain"),
|
senderId = UserId("@user:domain"),
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
isNoisy = false,
|
senderAvatarUrl = null,
|
||||||
isEncrypted = false,
|
senderDisplayName = null,
|
||||||
isDirect = false
|
roomAvatarUrl = null,
|
||||||
)
|
roomDisplayName = null,
|
||||||
|
isNoisy = false,
|
||||||
|
isEncrypted = false,
|
||||||
|
isDirect = false,
|
||||||
|
event = NotificationEvent(
|
||||||
|
timestamp = clock.epochMillis(),
|
||||||
|
content = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}…",
|
||||||
|
contentUrl = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,14 @@ package io.element.android.libraries.push.impl.notifications
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import coil.imageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.transform.CircleCropTransformation
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -31,30 +36,24 @@ class NotificationBitmapLoader @Inject constructor(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get icon of a room.
|
* Get icon of a room.
|
||||||
|
* @param path mxc url
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
suspend fun getRoomBitmap(path: String?): Bitmap? {
|
||||||
fun getRoomBitmap(path: String?): Bitmap? {
|
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return loadRoomBitmap(path)
|
return loadRoomBitmap(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
private suspend fun loadRoomBitmap(path: String): Bitmap? {
|
||||||
private fun loadRoomBitmap(path: String): Bitmap? {
|
|
||||||
return try {
|
return try {
|
||||||
null
|
val imageRequest = ImageRequest.Builder(context)
|
||||||
/* TODO Notification
|
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
|
||||||
Glide.with(context)
|
.build()
|
||||||
.asBitmap()
|
val result = context.imageLoader.execute(imageRequest)
|
||||||
.load(path)
|
result.drawable?.toBitmap()
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
} catch (e: Throwable) {
|
||||||
.signature(ObjectKey("room-icon-notification"))
|
Timber.e(e, "Unable to load room bitmap")
|
||||||
.submit()
|
|
||||||
.get()
|
|
||||||
*/
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "decodeFile failed")
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,9 +61,9 @@ class NotificationBitmapLoader @Inject constructor(
|
||||||
/**
|
/**
|
||||||
* Get icon of a user.
|
* Get icon of a user.
|
||||||
* Before Android P, this does nothing because the icon won't be used
|
* Before Android P, this does nothing because the icon won't be used
|
||||||
|
* @param path mxc url
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
suspend fun getUserIcon(path: String?): IconCompat? {
|
||||||
fun getUserIcon(path: String?): IconCompat? {
|
|
||||||
if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -72,23 +71,17 @@ class NotificationBitmapLoader @Inject constructor(
|
||||||
return loadUserIcon(path)
|
return loadUserIcon(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
private suspend fun loadUserIcon(path: String): IconCompat? {
|
||||||
private fun loadUserIcon(path: String): IconCompat? {
|
|
||||||
return try {
|
return try {
|
||||||
null
|
val imageRequest = ImageRequest.Builder(context)
|
||||||
/* TODO Notification
|
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
|
||||||
val bitmap = Glide.with(context)
|
.transformations(CircleCropTransformation())
|
||||||
.asBitmap()
|
.build()
|
||||||
.load(path)
|
val result = context.imageLoader.execute(imageRequest)
|
||||||
.transform(CircleCrop())
|
val bitmap = result.drawable?.toBitmap()
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
return bitmap?.let { IconCompat.createWithBitmap(it) }
|
||||||
.signature(ObjectKey("user-icon-notification"))
|
} catch (e: Throwable) {
|
||||||
.submit()
|
Timber.e(e, "Unable to load user bitmap")
|
||||||
.get()
|
|
||||||
IconCompat.createWithBitmap(bitmap)
|
|
||||||
*/
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "decodeFile failed")
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,28 +16,28 @@
|
||||||
|
|
||||||
package io.element.android.libraries.push.impl.notifications
|
package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.HandlerThread
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import io.element.android.libraries.androidutils.throttler.FirstThrottler
|
import io.element.android.libraries.androidutils.throttler.FirstThrottler
|
||||||
import io.element.android.libraries.core.cache.CircularCache
|
import io.element.android.libraries.core.cache.CircularCache
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.push.api.store.PushDataStore
|
import io.element.android.libraries.push.api.store.PushDataStore
|
||||||
import io.element.android.libraries.push.impl.R
|
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
|
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -48,7 +48,6 @@ import javax.inject.Inject
|
||||||
*/
|
*/
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
class NotificationDrawerManager @Inject constructor(
|
class NotificationDrawerManager @Inject constructor(
|
||||||
@ApplicationContext context: Context,
|
|
||||||
private val pushDataStore: PushDataStore,
|
private val pushDataStore: PushDataStore,
|
||||||
private val notifiableEventProcessor: NotifiableEventProcessor,
|
private val notifiableEventProcessor: NotifiableEventProcessor,
|
||||||
private val notificationRenderer: NotificationRenderer,
|
private val notificationRenderer: NotificationRenderer,
|
||||||
|
|
@ -56,17 +55,14 @@ class NotificationDrawerManager @Inject constructor(
|
||||||
private val filteredEventDetector: FilteredEventDetector,
|
private val filteredEventDetector: FilteredEventDetector,
|
||||||
private val appNavigationStateService: AppNavigationStateService,
|
private val appNavigationStateService: AppNavigationStateService,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val matrixAuthenticationService: MatrixAuthenticationService,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
|
|
||||||
private var backgroundHandler: Handler
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
|
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
|
||||||
*/
|
*/
|
||||||
private val notificationState by lazy { createInitialNotificationState() }
|
private val notificationState by lazy { createInitialNotificationState() }
|
||||||
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
|
|
||||||
private var currentAppNavigationState: AppNavigationState? = null
|
private var currentAppNavigationState: AppNavigationState? = null
|
||||||
private val firstThrottler = FirstThrottler(200)
|
private val firstThrottler = FirstThrottler(200)
|
||||||
|
|
||||||
|
|
@ -74,8 +70,6 @@ class NotificationDrawerManager @Inject constructor(
|
||||||
private var useCompleteNotificationFormat = true
|
private var useCompleteNotificationFormat = true
|
||||||
|
|
||||||
init {
|
init {
|
||||||
handlerThread.start()
|
|
||||||
backgroundHandler = Handler(handlerThread.looper)
|
|
||||||
// Observe application state
|
// Observe application state
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
appNavigationStateService.appNavigationStateFlow
|
appNavigationStateService.appNavigationStateFlow
|
||||||
|
|
@ -193,30 +187,25 @@ class NotificationDrawerManager @Inject constructor(
|
||||||
notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
|
notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
|
||||||
action(queuedEvents)
|
action(queuedEvents)
|
||||||
}
|
}
|
||||||
refreshNotificationDrawer()
|
coroutineScope.refreshNotificationDrawer()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshNotificationDrawer() {
|
private fun CoroutineScope.refreshNotificationDrawer() = launch {
|
||||||
// Implement last throttler
|
// Implement last throttler
|
||||||
val canHandle = firstThrottler.canHandle()
|
val canHandle = firstThrottler.canHandle()
|
||||||
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
|
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
|
||||||
backgroundHandler.removeCallbacksAndMessages(null)
|
withContext(dispatchers.io) {
|
||||||
|
delay(canHandle.waitMillis())
|
||||||
backgroundHandler.postDelayed(
|
try {
|
||||||
{
|
refreshNotificationDrawerBg()
|
||||||
try {
|
} catch (throwable: Throwable) {
|
||||||
refreshNotificationDrawerBg()
|
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
|
||||||
} catch (throwable: Throwable) {
|
Timber.w(throwable, "refreshNotificationDrawerBg failure")
|
||||||
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
|
}
|
||||||
Timber.w(throwable, "refreshNotificationDrawerBg failure")
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
canHandle.waitMillis()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
private suspend fun refreshNotificationDrawerBg() {
|
||||||
private fun refreshNotificationDrawerBg() {
|
|
||||||
Timber.v("refreshNotificationDrawerBg()")
|
Timber.v("refreshNotificationDrawerBg()")
|
||||||
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
|
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
|
||||||
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
|
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
|
||||||
|
|
@ -239,24 +228,34 @@ class NotificationDrawerManager @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
|
private suspend fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
|
||||||
// Group by sessionId
|
// Group by sessionId
|
||||||
val eventsForSessions = eventsToRender.groupBy {
|
val eventsForSessions = eventsToRender.groupBy {
|
||||||
it.event.sessionId
|
it.event.sessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
eventsForSessions.forEach { (sessionId, notifiableEvents) ->
|
eventsForSessions.forEach { (sessionId, notifiableEvents) ->
|
||||||
// TODO EAx val user = session.getUserOrDefault(session.myUserId)
|
val currentUser = tryOrNull(
|
||||||
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
|
onError = { Timber.e(it, "Unable to retrieve info for user ${sessionId.value}") },
|
||||||
val myUserDisplayName = "Todo display name" // user.toMatrixItem().getBestName()
|
operation = {
|
||||||
// TODO EAx avatar URL
|
val client = matrixAuthenticationService.restoreSession(sessionId).getOrNull()
|
||||||
val myUserAvatarUrl = null // session.contentUrlResolver().resolveThumbnail(
|
|
||||||
// contentUrl = user.avatarUrl,
|
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
|
||||||
// width = avatarSize,
|
val myUserDisplayName = client?.loadUserDisplayName()?.getOrNull() ?: sessionId.value
|
||||||
// height = avatarSize,
|
val userAvatarUrl = client?.loadUserAvatarURLString()?.getOrNull()
|
||||||
// method = ContentUrlResolver.ThumbnailMethod.SCALE
|
MatrixUser(
|
||||||
//)
|
userId = sessionId,
|
||||||
notificationRenderer.render(sessionId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, notifiableEvents)
|
displayName = myUserDisplayName,
|
||||||
|
avatarUrl = userAvatarUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) ?: MatrixUser(
|
||||||
|
userId = sessionId,
|
||||||
|
displayName = sessionId.value,
|
||||||
|
avatarUrl = null
|
||||||
|
)
|
||||||
|
|
||||||
|
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
|
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
|
||||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
|
@ -34,10 +34,8 @@ class NotificationFactory @Inject constructor(
|
||||||
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
|
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun Map<RoomId, ProcessedMessageEvents>.toNotifications(
|
suspend fun Map<RoomId, ProcessedMessageEvents>.toNotifications(
|
||||||
sessionId: SessionId,
|
currentUser: MatrixUser,
|
||||||
myUserDisplayName: String,
|
|
||||||
myUserAvatarUrl: String?
|
|
||||||
): List<RoomNotification> {
|
): List<RoomNotification> {
|
||||||
return map { (roomId, events) ->
|
return map { (roomId, events) ->
|
||||||
when {
|
when {
|
||||||
|
|
@ -45,11 +43,9 @@ class NotificationFactory @Inject constructor(
|
||||||
else -> {
|
else -> {
|
||||||
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
|
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
|
||||||
roomGroupMessageCreator.createRoomMessage(
|
roomGroupMessageCreator.createRoomMessage(
|
||||||
sessionId = sessionId,
|
currentUser = currentUser,
|
||||||
events = messageEvents,
|
events = messageEvents,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
userDisplayName = myUserDisplayName,
|
|
||||||
userAvatarUrl = myUserAvatarUrl
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +95,7 @@ class NotificationFactory @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createSummaryNotification(
|
fun createSummaryNotification(
|
||||||
sessionId: SessionId,
|
currentUser: MatrixUser,
|
||||||
roomNotifications: List<RoomNotification>,
|
roomNotifications: List<RoomNotification>,
|
||||||
invitationNotifications: List<OneShotNotification>,
|
invitationNotifications: List<OneShotNotification>,
|
||||||
simpleNotifications: List<OneShotNotification>,
|
simpleNotifications: List<OneShotNotification>,
|
||||||
|
|
@ -112,7 +108,7 @@ class NotificationFactory @Inject constructor(
|
||||||
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
|
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
|
||||||
else -> SummaryNotification.Update(
|
else -> SummaryNotification.Update(
|
||||||
summaryGroupMessageCreator.createSummaryNotification(
|
summaryGroupMessageCreator.createSummaryNotification(
|
||||||
sessionId = sessionId,
|
currentUser = currentUser,
|
||||||
roomNotifications = roomMeta,
|
roomNotifications = roomMeta,
|
||||||
invitationNotifications = invitationMeta,
|
invitationNotifications = invitationMeta,
|
||||||
simpleNotifications = simpleMeta,
|
simpleNotifications = simpleMeta,
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.libraries.push.impl.notifications
|
package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
|
@ -32,21 +31,18 @@ class NotificationRenderer @Inject constructor(
|
||||||
private val notificationFactory: NotificationFactory,
|
private val notificationFactory: NotificationFactory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@WorkerThread
|
suspend fun render(
|
||||||
fun render(
|
currentUser: MatrixUser,
|
||||||
sessionId: SessionId,
|
|
||||||
myUserDisplayName: String,
|
|
||||||
myUserAvatarUrl: String?,
|
|
||||||
useCompleteNotificationFormat: Boolean,
|
useCompleteNotificationFormat: Boolean,
|
||||||
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
|
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
|
||||||
) {
|
) {
|
||||||
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
|
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
|
||||||
with(notificationFactory) {
|
with(notificationFactory) {
|
||||||
val roomNotifications = roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl)
|
val roomNotifications = roomEvents.toNotifications(currentUser)
|
||||||
val invitationNotifications = invitationEvents.toNotifications()
|
val invitationNotifications = invitationEvents.toNotifications()
|
||||||
val simpleNotifications = simpleEvents.toNotifications()
|
val simpleNotifications = simpleEvents.toNotifications()
|
||||||
val summaryNotification = createSummaryNotification(
|
val summaryNotification = createSummaryNotification(
|
||||||
sessionId = sessionId,
|
currentUser = currentUser,
|
||||||
roomNotifications = roomNotifications,
|
roomNotifications = roomNotifications,
|
||||||
invitationNotifications = invitationNotifications,
|
invitationNotifications = invitationNotifications,
|
||||||
simpleNotifications = simpleNotifications,
|
simpleNotifications = simpleNotifications,
|
||||||
|
|
@ -56,21 +52,27 @@ class NotificationRenderer @Inject constructor(
|
||||||
// Remove summary first to avoid briefly displaying it after dismissing the last notification
|
// Remove summary first to avoid briefly displaying it after dismissing the last notification
|
||||||
if (summaryNotification == SummaryNotification.Removed) {
|
if (summaryNotification == SummaryNotification.Removed) {
|
||||||
Timber.d("Removing summary notification")
|
Timber.d("Removing summary notification")
|
||||||
notificationDisplayer.cancelNotificationMessage(null, notificationIdProvider.getSummaryNotificationId(sessionId))
|
notificationDisplayer.cancelNotificationMessage(
|
||||||
|
tag = null,
|
||||||
|
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
roomNotifications.forEach { wrapper ->
|
roomNotifications.forEach { wrapper ->
|
||||||
when (wrapper) {
|
when (wrapper) {
|
||||||
is RoomNotification.Removed -> {
|
is RoomNotification.Removed -> {
|
||||||
Timber.d("Removing room messages notification ${wrapper.roomId}")
|
Timber.d("Removing room messages notification ${wrapper.roomId}")
|
||||||
notificationDisplayer.cancelNotificationMessage(wrapper.roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
|
notificationDisplayer.cancelNotificationMessage(
|
||||||
|
tag = wrapper.roomId.value,
|
||||||
|
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
|
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
|
||||||
Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
|
Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
|
||||||
notificationDisplayer.showNotificationMessage(
|
notificationDisplayer.showNotificationMessage(
|
||||||
wrapper.meta.roomId.value,
|
tag = wrapper.meta.roomId.value,
|
||||||
notificationIdProvider.getRoomMessagesNotificationId(sessionId),
|
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
|
||||||
wrapper.notification
|
notification = wrapper.notification
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,14 +82,17 @@ class NotificationRenderer @Inject constructor(
|
||||||
when (wrapper) {
|
when (wrapper) {
|
||||||
is OneShotNotification.Removed -> {
|
is OneShotNotification.Removed -> {
|
||||||
Timber.d("Removing invitation notification ${wrapper.key}")
|
Timber.d("Removing invitation notification ${wrapper.key}")
|
||||||
notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomInvitationNotificationId(sessionId))
|
notificationDisplayer.cancelNotificationMessage(
|
||||||
|
tag = wrapper.key,
|
||||||
|
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
|
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
|
||||||
Timber.d("Updating invitation notification ${wrapper.meta.key}")
|
Timber.d("Updating invitation notification ${wrapper.meta.key}")
|
||||||
notificationDisplayer.showNotificationMessage(
|
notificationDisplayer.showNotificationMessage(
|
||||||
wrapper.meta.key,
|
tag = wrapper.meta.key,
|
||||||
notificationIdProvider.getRoomInvitationNotificationId(sessionId),
|
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
|
||||||
wrapper.notification
|
notification = wrapper.notification
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,14 +102,17 @@ class NotificationRenderer @Inject constructor(
|
||||||
when (wrapper) {
|
when (wrapper) {
|
||||||
is OneShotNotification.Removed -> {
|
is OneShotNotification.Removed -> {
|
||||||
Timber.d("Removing simple notification ${wrapper.key}")
|
Timber.d("Removing simple notification ${wrapper.key}")
|
||||||
notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomEventNotificationId(sessionId))
|
notificationDisplayer.cancelNotificationMessage(
|
||||||
|
tag = wrapper.key,
|
||||||
|
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
|
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
|
||||||
Timber.d("Updating simple notification ${wrapper.meta.key}")
|
Timber.d("Updating simple notification ${wrapper.meta.key}")
|
||||||
notificationDisplayer.showNotificationMessage(
|
notificationDisplayer.showNotificationMessage(
|
||||||
wrapper.meta.key,
|
tag = wrapper.meta.key,
|
||||||
notificationIdProvider.getRoomEventNotificationId(sessionId),
|
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
|
||||||
wrapper.notification
|
notification = wrapper.notification
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,9 +122,9 @@ class NotificationRenderer @Inject constructor(
|
||||||
if (summaryNotification is SummaryNotification.Update) {
|
if (summaryNotification is SummaryNotification.Update) {
|
||||||
Timber.d("Updating summary notification")
|
Timber.d("Updating summary notification")
|
||||||
notificationDisplayer.showNotificationMessage(
|
notificationDisplayer.showNotificationMessage(
|
||||||
null,
|
tag = null,
|
||||||
notificationIdProvider.getSummaryNotificationId(sessionId),
|
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId),
|
||||||
summaryNotification.notification
|
notification = summaryNotification.notification
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ import android.graphics.Bitmap
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.Person
|
import androidx.core.app.Person
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.push.impl.R
|
import io.element.android.libraries.push.impl.R
|
||||||
|
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
|
||||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
|
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
|
@ -36,24 +37,22 @@ class RoomGroupMessageCreator @Inject constructor(
|
||||||
private val notificationFactory: NotificationFactory
|
private val notificationFactory: NotificationFactory
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun createRoomMessage(
|
suspend fun createRoomMessage(
|
||||||
sessionId: SessionId,
|
currentUser: MatrixUser,
|
||||||
events: List<NotifiableMessageEvent>,
|
events: List<NotifiableMessageEvent>,
|
||||||
roomId: RoomId,
|
roomId: RoomId,
|
||||||
userDisplayName: String,
|
|
||||||
userAvatarUrl: String?
|
|
||||||
): RoomNotification.Message {
|
): RoomNotification.Message {
|
||||||
val lastKnownRoomEvent = events.last()
|
val lastKnownRoomEvent = events.last()
|
||||||
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "Room name (${roomId.value.take(8)}…)"
|
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "Room name (${roomId.value.take(8)}…)"
|
||||||
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
|
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
|
||||||
val style = NotificationCompat.MessagingStyle(
|
val style = NotificationCompat.MessagingStyle(
|
||||||
Person.Builder()
|
Person.Builder()
|
||||||
.setName(userDisplayName)
|
.setName(currentUser.displayName?.annotateForDebug(50))
|
||||||
.setIcon(bitmapLoader.getUserIcon(userAvatarUrl))
|
.setIcon(bitmapLoader.getUserIcon(currentUser.avatarUrl))
|
||||||
.setKey(lastKnownRoomEvent.sessionId.value)
|
.setKey(lastKnownRoomEvent.sessionId.value)
|
||||||
.build()
|
.build()
|
||||||
).also {
|
).also {
|
||||||
it.conversationTitle = roomName.takeIf { roomIsGroup }
|
it.conversationTitle = roomName.takeIf { roomIsGroup }?.annotateForDebug(51)
|
||||||
it.isGroupConversation = roomIsGroup
|
it.isGroupConversation = roomIsGroup
|
||||||
it.addMessagesFromEvents(events)
|
it.addMessagesFromEvents(events)
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +79,7 @@ class RoomGroupMessageCreator @Inject constructor(
|
||||||
notificationFactory.createMessagesListNotification(
|
notificationFactory.createMessagesListNotification(
|
||||||
style,
|
style,
|
||||||
RoomEventGroupInfo(
|
RoomEventGroupInfo(
|
||||||
sessionId = sessionId,
|
sessionId = currentUser.userId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
roomDisplayName = roomName,
|
roomDisplayName = roomName,
|
||||||
isDirect = !roomIsGroup,
|
isDirect = !roomIsGroup,
|
||||||
|
|
@ -99,13 +98,13 @@ class RoomGroupMessageCreator @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) {
|
private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) {
|
||||||
events.forEach { event ->
|
events.forEach { event ->
|
||||||
val senderPerson = if (event.outGoingMessage) {
|
val senderPerson = if (event.outGoingMessage) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
Person.Builder()
|
Person.Builder()
|
||||||
.setName(event.senderName)
|
.setName(event.senderName?.annotateForDebug(70))
|
||||||
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath))
|
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath))
|
||||||
.setKey(event.senderId)
|
.setKey(event.senderId)
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -117,7 +116,11 @@ class RoomGroupMessageCreator @Inject constructor(
|
||||||
senderPerson
|
senderPerson
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message ->
|
val message = NotificationCompat.MessagingStyle.Message(
|
||||||
|
event.body?.annotateForDebug(71),
|
||||||
|
event.timestamp,
|
||||||
|
senderPerson
|
||||||
|
).also { message ->
|
||||||
event.imageUri?.let {
|
event.imageUri?.let {
|
||||||
message.setData("image/", it)
|
message.setData("image/", it)
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +171,7 @@ class RoomGroupMessageCreator @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
|
private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
|
||||||
// Use the last event (most recent?)
|
// Use the last event (most recent?)
|
||||||
return events.lastOrNull()
|
return events.lastOrNull()
|
||||||
?.roomAvatarPath
|
?.roomAvatarPath
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.push.impl.R
|
import io.element.android.libraries.push.impl.R
|
||||||
|
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
|
||||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
|
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
|
||||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -40,20 +41,20 @@ import javax.inject.Inject
|
||||||
*/
|
*/
|
||||||
class SummaryGroupMessageCreator @Inject constructor(
|
class SummaryGroupMessageCreator @Inject constructor(
|
||||||
private val stringProvider: StringProvider,
|
private val stringProvider: StringProvider,
|
||||||
private val notificationFactory: NotificationFactory
|
private val notificationFactory: NotificationFactory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun createSummaryNotification(
|
fun createSummaryNotification(
|
||||||
sessionId: SessionId,
|
currentUser: MatrixUser,
|
||||||
roomNotifications: List<RoomNotification.Message.Meta>,
|
roomNotifications: List<RoomNotification.Message.Meta>,
|
||||||
invitationNotifications: List<OneShotNotification.Append.Meta>,
|
invitationNotifications: List<OneShotNotification.Append.Meta>,
|
||||||
simpleNotifications: List<OneShotNotification.Append.Meta>,
|
simpleNotifications: List<OneShotNotification.Append.Meta>,
|
||||||
useCompleteNotificationFormat: Boolean
|
useCompleteNotificationFormat: Boolean
|
||||||
): Notification {
|
): Notification {
|
||||||
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
|
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
|
||||||
roomNotifications.forEach { style.addLine(it.summaryLine) }
|
roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) }
|
||||||
invitationNotifications.forEach { style.addLine(it.summaryLine) }
|
invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) }
|
||||||
simpleNotifications.forEach { style.addLine(it.summaryLine) }
|
simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
|
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
|
||||||
|
|
@ -69,12 +70,13 @@ class SummaryGroupMessageCreator @Inject constructor(
|
||||||
// FIXME roomIdToEventMap.size is not correct, this is the number of rooms
|
// FIXME roomIdToEventMap.size is not correct, this is the number of rooms
|
||||||
val nbEvents = roomNotifications.size + simpleNotifications.size
|
val nbEvents = roomNotifications.size + simpleNotifications.size
|
||||||
val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents)
|
val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents)
|
||||||
summaryInboxStyle.setBigContentTitle(sumTitle)
|
summaryInboxStyle.setBigContentTitle(sumTitle.annotateForDebug(43))
|
||||||
// TODO get latest event?
|
//.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents).annotateForDebug(44))
|
||||||
.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents))
|
// Use account name now, for multi-session
|
||||||
|
.setSummaryText(currentUser.userId.value.annotateForDebug(44))
|
||||||
return if (useCompleteNotificationFormat) {
|
return if (useCompleteNotificationFormat) {
|
||||||
notificationFactory.createSummaryListNotification(
|
notificationFactory.createSummaryListNotification(
|
||||||
sessionId,
|
currentUser,
|
||||||
summaryInboxStyle,
|
summaryInboxStyle,
|
||||||
sumTitle,
|
sumTitle,
|
||||||
noisy = summaryIsNoisy,
|
noisy = summaryIsNoisy,
|
||||||
|
|
@ -82,7 +84,7 @@ class SummaryGroupMessageCreator @Inject constructor(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
processSimpleGroupSummary(
|
processSimpleGroupSummary(
|
||||||
sessionId,
|
currentUser,
|
||||||
summaryIsNoisy,
|
summaryIsNoisy,
|
||||||
messageCount,
|
messageCount,
|
||||||
simpleNotifications.size,
|
simpleNotifications.size,
|
||||||
|
|
@ -94,7 +96,7 @@ class SummaryGroupMessageCreator @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processSimpleGroupSummary(
|
private fun processSimpleGroupSummary(
|
||||||
sessionId: SessionId,
|
currentUser: MatrixUser,
|
||||||
summaryIsNoisy: Boolean,
|
summaryIsNoisy: Boolean,
|
||||||
messageEventsCount: Int,
|
messageEventsCount: Int,
|
||||||
simpleEventsCount: Int,
|
simpleEventsCount: Int,
|
||||||
|
|
@ -167,7 +169,7 @@ class SummaryGroupMessageCreator @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return notificationFactory.createSummaryListNotification(
|
return notificationFactory.createSummaryListNotification(
|
||||||
sessionId = sessionId,
|
currentUser = currentUser,
|
||||||
style = null,
|
style = null,
|
||||||
compatSummary = privacyTitle,
|
compatSummary = privacyTitle,
|
||||||
noisy = summaryIsNoisy,
|
noisy = summaryIsNoisy,
|
||||||
|
|
|
||||||
|
|
@ -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.libraries.push.impl.notifications.debug
|
||||||
|
|
||||||
|
fun CharSequence.annotateForDebug(@Suppress("UNUSED_PARAMETER") prefix: Int): CharSequence {
|
||||||
|
return this // "$prefix-$this"
|
||||||
|
}
|
||||||
|
|
@ -26,11 +26,12 @@ import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.res.ResourcesCompat
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.push.impl.R
|
import io.element.android.libraries.push.impl.R
|
||||||
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
|
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
|
||||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||||
|
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
|
||||||
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
|
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
|
||||||
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
|
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
|
||||||
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
|
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
|
||||||
|
|
@ -84,16 +85,16 @@ class NotificationFactory @Inject constructor(
|
||||||
// ID of the corresponding shortcut, for conversation features under API 30+
|
// ID of the corresponding shortcut, for conversation features under API 30+
|
||||||
.setShortcutId(roomInfo.roomId.value)
|
.setShortcutId(roomInfo.roomId.value)
|
||||||
// Title for API < 16 devices.
|
// Title for API < 16 devices.
|
||||||
.setContentTitle(roomInfo.roomDisplayName)
|
.setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1))
|
||||||
// Content for API < 16 devices.
|
// Content for API < 16 devices.
|
||||||
.setContentText(stringProvider.getString(R.string.notification_new_messages))
|
.setContentText(stringProvider.getString(R.string.notification_new_messages).annotateForDebug(2))
|
||||||
// Number of new notifications for API <24 (M and below) devices.
|
// Number of new notifications for API <24 (M and below) devices.
|
||||||
.setSubText(
|
.setSubText(
|
||||||
stringProvider.getQuantityString(
|
stringProvider.getQuantityString(
|
||||||
R.plurals.notification_new_messages_for_room,
|
R.plurals.notification_new_messages_for_room,
|
||||||
messageStyle.messages.size,
|
messageStyle.messages.size,
|
||||||
messageStyle.messages.size
|
messageStyle.messages.size
|
||||||
)
|
).annotateForDebug(3)
|
||||||
)
|
)
|
||||||
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
|
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
|
||||||
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
|
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
|
||||||
|
|
@ -135,7 +136,7 @@ class NotificationFactory @Inject constructor(
|
||||||
}
|
}
|
||||||
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
|
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
|
||||||
}
|
}
|
||||||
.setTicker(tickerText)
|
.setTicker(tickerText.annotateForDebug(4))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,8 +148,8 @@ class NotificationFactory @Inject constructor(
|
||||||
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
|
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
|
||||||
return NotificationCompat.Builder(context, channelId)
|
return NotificationCompat.Builder(context, channelId)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName)
|
.setContentTitle((inviteNotifiableEvent.roomName ?: buildMeta.applicationName).annotateForDebug(5))
|
||||||
.setContentText(inviteNotifiableEvent.description)
|
.setContentText(inviteNotifiableEvent.description.annotateForDebug(6))
|
||||||
.setGroup(inviteNotifiableEvent.sessionId.value)
|
.setGroup(inviteNotifiableEvent.sessionId.value)
|
||||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||||
.setSmallIcon(smallIcon)
|
.setSmallIcon(smallIcon)
|
||||||
|
|
@ -196,8 +197,8 @@ class NotificationFactory @Inject constructor(
|
||||||
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
|
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
|
||||||
return NotificationCompat.Builder(context, channelId)
|
return NotificationCompat.Builder(context, channelId)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentTitle(buildMeta.applicationName)
|
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
|
||||||
.setContentText(simpleNotifiableEvent.description)
|
.setContentText(simpleNotifiableEvent.description.annotateForDebug(8))
|
||||||
.setGroup(simpleNotifiableEvent.sessionId.value)
|
.setGroup(simpleNotifiableEvent.sessionId.value)
|
||||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||||
.setSmallIcon(smallIcon)
|
.setSmallIcon(smallIcon)
|
||||||
|
|
@ -226,7 +227,7 @@ class NotificationFactory @Inject constructor(
|
||||||
* Create the summary notification.
|
* Create the summary notification.
|
||||||
*/
|
*/
|
||||||
fun createSummaryListNotification(
|
fun createSummaryListNotification(
|
||||||
sessionId: SessionId,
|
currentUser: MatrixUser,
|
||||||
style: NotificationCompat.InboxStyle?,
|
style: NotificationCompat.InboxStyle?,
|
||||||
compatSummary: String,
|
compatSummary: String,
|
||||||
noisy: Boolean,
|
noisy: Boolean,
|
||||||
|
|
@ -240,12 +241,12 @@ class NotificationFactory @Inject constructor(
|
||||||
// used in compat < N, after summary is built based on child notifications
|
// used in compat < N, after summary is built based on child notifications
|
||||||
.setWhen(lastMessageTimestamp)
|
.setWhen(lastMessageTimestamp)
|
||||||
.setStyle(style)
|
.setStyle(style)
|
||||||
.setContentTitle(sessionId.value)
|
.setContentTitle(currentUser.userId.value.annotateForDebug(9))
|
||||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||||
.setSmallIcon(smallIcon)
|
.setSmallIcon(smallIcon)
|
||||||
// set content text to support devices running API level < 24
|
// set content text to support devices running API level < 24
|
||||||
.setContentText(compatSummary)
|
.setContentText(compatSummary.annotateForDebug(10))
|
||||||
.setGroup(sessionId.value)
|
.setGroup(currentUser.userId.value)
|
||||||
// set this notification as the summary for the group
|
// set this notification as the summary for the group
|
||||||
.setGroupSummary(true)
|
.setGroupSummary(true)
|
||||||
.setColor(accentColor)
|
.setColor(accentColor)
|
||||||
|
|
@ -264,8 +265,8 @@ class NotificationFactory @Inject constructor(
|
||||||
priority = NotificationCompat.PRIORITY_LOW
|
priority = NotificationCompat.PRIORITY_LOW
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(sessionId))
|
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(currentUser.userId))
|
||||||
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(sessionId))
|
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(currentUser.userId))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||