Merge branch 'develop' into feature/bma/documentation

This commit is contained in:
ganfra
2023-01-19 18:10:44 +01:00
567 changed files with 108948 additions and 3921 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text

86
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Bug report for the Element X Android app
description: Report any issues that you have found with the Element X app. Please [check open issues](https://github.com/vector-im/element-x-android/issues) first, in case it has already been reported.
labels: [T-Defect]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Please report security issues by email to security@matrix.org
- type: textarea
id: reproduction-steps
attributes:
label: Steps to reproduce
description: Please attach screenshots, videos or logs if you can.
placeholder: Tell us what you see!
value: |
1. Where are you starting? What can you see?
2. What do you click?
3. More steps…
validations:
required: true
- type: textarea
id: result
attributes:
label: Outcome
placeholder: Tell us what went wrong
value: |
#### What did you expect?
#### What happened instead?
validations:
required: true
- type: input
id: device
attributes:
label: Your phone model
placeholder: e.g. Samsung S6
validations:
required: false
- type: input
id: os
attributes:
label: Operating system version
placeholder: e.g. Android 10.0
validations:
required: false
- type: input
id: version
attributes:
label: Application version and app store
description: You can find the version information in Settings -> Help & About.
placeholder: e.g. Element X version 1.7.34, olm version 3.2.3 from F-Droid
validations:
required: false
- type: input
id: homeserver
attributes:
label: Homeserver
description: |
Which server is your account registered on? If it is a local or non-public homeserver, please tell us what is the homeserver implementation (ex: Synapse/Dendrite/etc.) and the version.
placeholder: e.g. matrix.org or Synapse 1.50.0rc1
validations:
required: false
- type: dropdown
id: rageshake
attributes:
label: Will you send logs?
description: |
Did you know that you can shake your phone to submit logs for this issue? Trigger the defect, then shake your phone and you will see a popup asking if you would like to open the bug report screen. Click YES, and describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers.
options:
- 'Yes'
- 'No'
validations:
required: true
- type: dropdown
id: pr
attributes:
label: Are you willing to provide a PR?
description: |
Providing a PR can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :).
options:
- 'Yes'
- 'No'
validations:
required: true

47
.github/ISSUE_TEMPLATE/enhancement.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Enhancement request
description: Do you have a suggestion or feature request?
labels: [T-Enhancement]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/vector-im/element-meta/discussions/new?category=ideas).
- type: textarea
id: usecase
attributes:
label: Your use case
description: Please feel welcome to include screenshots or mock ups.
placeholder: Tell us what you would like to do!
value: |
#### What would you like to do?
#### Why would you like to do it?
#### How would you like to achieve it?
validations:
required: true
- type: textarea
id: alternative
attributes:
label: Have you considered any alternatives?
placeholder: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
placeholder: Is there anything else you'd like to add?
validations:
required: false
- type: dropdown
id: pr
attributes:
label: Are you willing to provide a PR?
description: |
Don't worry, it's still OK to answer 'No' :).
options:
- 'Yes'
- 'No'
validations:
required: true

View File

@@ -0,0 +1,57 @@
<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
## Type of change
- [ ] Feature
- [ ] Bugfix
- [ ] Technical
- [ ] Other :
## Content
<!-- Describe shortly what has been changed -->
## Motivation and context
<!-- Provide link to the corresponding issue if applicable or explain the context -->
## Screenshots / GIFs
<!-- Only if UI have been changed
You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|
-->
<!--
|Before|After|
|-|-|
|||
-->
## Tests
<!-- Explain how you tested your development -->
- Step 1
- Step 2
- Step ...
## Tested devices
- [ ] Physical
- [ ] Emulator
- OS version(s):
## Checklist
<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->
- [ ] Changes has been tested on an Android device or Android emulator with API 21
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#changelog
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR

View File

@@ -31,3 +31,7 @@ jobs:
name: elementx-debug
path: |
app/build/outputs/apk/debug/app-debug.apk
- name: Compile release sources
run: ./gradlew compileReleaseSources $CI_GRADLE_ARG_PROPERTIES
- name: Compile nighlty sources
run: ./gradlew compileNightlySources $CI_GRADLE_ARG_PROPERTIES

View File

@@ -11,7 +11,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
uses: danger/danger-js@11.2.1
uses: danger/danger-js@11.2.2
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:

View File

@@ -0,0 +1,18 @@
name: Update Gradle Wrapper
on:
schedule:
- cron: "0 0 * * *"
jobs:
update-gradle-wrapper:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1
# Skip in forks
if: github.repository == 'vector-im/element-x-android'
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
target-branch: develop

View File

@@ -0,0 +1,14 @@
name: "Validate Gradle Wrapper"
on:
pull_request: { }
push:
branches: [ main, develop ]
jobs:
validation:
name: "Validation"
runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time.
steps:
- uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1

36
.github/workflows/maestro.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Maestro
on:
pull_request: { }
push:
branches: [ main, develop ]
# 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
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
jobs:
maestro-cloud:
name: Maestro test suite
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main'
strategy:
fail-fast: false
# Allow all jobs on develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/develop' && format('maestro-develop-{0}', github.sha) || format('maestro-debug-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- name: Assemble debug APK
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
- uses: mobile-dev-inc/action-maestro-cloud@v1.1.1
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: app/build/outputs/apk/debug/app-debug.apk
env: |
USERNAME=maestroelement
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
ROOM_NAME=MyRoom
APP_ID=io.element.android.x.debug

View File

@@ -15,6 +15,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install towncrier
run: |
python3 -m pip install towncrier
- name: Prepare changelog file
run: |
mv towncrier.toml towncrier.toml.bak
sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
rm towncrier.toml.bak
yes n | towncrier build --version nightly
- name: Build and upload Nightly APK
run: |
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES

View File

@@ -37,7 +37,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
uses: danger/danger-js@11.2.1
uses: danger/danger-js@11.2.2
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:

View File

@@ -0,0 +1,25 @@
name: Sync Data From External Sources
on:
schedule:
# Every nights at 6
- cron: "0 6 * * *"
jobs:
sync-strings:
runs-on: ubuntu-latest
# Skip in forks
if: github.repository == 'vector-im/element-x-android'
# No concurrency required, runs every time on a schedule.
steps:
- uses: actions/checkout@v3
- name: Run local script
run: ./tools/strings/importStringsFromElement.sh
- name: Create Pull Request
uses: peter-evans/create-pull-request@v4
with:
commit-message: Import strings from Element Android
title: Sync strings
body: |
- Update strings from Element Android
branch: sync-strings
base: develop

View File

@@ -23,3 +23,12 @@ jobs:
- uses: actions/checkout@v3
- name: Run tests
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
- name: Archive test results on error
if: failure()
uses: actions/upload-artifact@v3
with:
name: screenshot-results
path: |
**/out/failures/
**/build/reports/tests/*UnitTest/

View File

@@ -2,7 +2,7 @@ name: Move labelled issues to correct boards and columns
on:
issues:
types: [ labeled ]
types: [labeled]
jobs:
move_element_x_issues:
@@ -10,14 +10,7 @@ jobs:
runs-on: ubuntu-latest
# Skip in forks
if: >
github.repository == 'vector-im/element-x-android' &&
(contains(github.event.issue.labels.*.name, 'Z-Setup') ||
contains(github.event.issue.labels.*.name, 'Z-BBQ-Alpha') ||
contains(github.event.issue.labels.*.name, 'Z-BBQ-Beta') ||
contains(github.event.issue.labels.*.name, 'Z-BBQ-Release') ||
contains(github.event.issue.labels.*.name, 'Z-Banquet-Alpha') ||
contains(github.event.issue.labels.*.name, 'Z-Banquet-Beta') ||
contains(github.event.issue.labels.*.name, 'Z-Banquet-Release'))
github.repository == 'vector-im/element-x-android'
steps:
- uses: octokit/graphql-action@v2.x
with:
@@ -33,5 +26,5 @@ jobs:
projectid: ${{ env.PROJECT_ID }}
contentid: ${{ github.event.issue.node_id }}
env:
PROJECT_ID: "PN_kwDOAM0swc4ABTXY"
PROJECT_ID: "PVT_kwDOAM0swc4ABTXY"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

15
.github/workflows/validate-lfs.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Validate Git LFS
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
name: Validate
steps:
- uses: actions/checkout@v3
with:
lfs: 'true'
- run: |
./tools/git/validate_lfs.sh

2
.gitignore vendored
View File

@@ -83,3 +83,5 @@ lint/outputs/
lint/tmp/
# lint/reports/
/.idea/deploymentTargetDropDown.xml
/tmp

71
.maestro/README.md Normal file
View File

@@ -0,0 +1,71 @@
# Maestro
Maestro is a framework that we are using to test navigation across the application.
To setup, please refer at [https://maestro.mobile.dev](https://maestro.mobile.dev)
<!--- TOC -->
* [Run test](#run-test)
* [Output](#output)
* [Write test](#write-test)
* [CI](#ci)
* [iOS](#ios)
* [Future](#future)
<!--- END -->
## Run test
From root dir of the project
*Note: Since ElementX does not allow account creation nor room creation, we have to use an existing account with an existing room to run maestro test suite. So to run locally, please replace `user` and `123` with your test matrix.org account credentials, and `my room` with one of a room this account has join. Note that the test will send messages to this room.*
```shell
maestro test \
-e APP_ID=io.element.android.x.debug \
-e USERNAME=user \
-e PASSWORD=123 \
-e ROOM_NAME="my room" \
.maestro/allTest.yaml
```
### Output
Test result will be printed on the console, and screenshots will be generated at `./build/maestro`
## Write test
Tests are yaml files. Generally each yaml file should leave the app in the same screen than at the beginning.
Start the ElementX app and run this command to help writing test.
```shell
maestro studio
```
Note that sometimes, this prevent running the test. So kill the `maestro studio` process to be able to run the test again.
Also, if updating the application code, do not forget to deploy again the application before running the maestro tests.
## CI
The CI is running maestro using the workflow `.github/worflow/maestro.yaml` and [maestro cloud](https://cloud.mobile.dev/). For now we are limited to 100 runs a month.
Some GitHub secrets are used to be able to do that: `MAESTRO_CLOUD_API_KEY`, for now api key from `benoitm@element.io` maestro cloud account, and `MATRIX_MAESTRO_ACCOUNT_PASSWORD` which is the password of the account `@maestroelement:matrix.org`. This account contains a room `MyRoom` to be able to run the maestro test suite.
## iOS
Need to install `idb-companion` first
```shell
brew install idb-companion
```
Also:
https://github.com/mobile-dev-inc/maestro/issues/146
https://github.com/mobile-dev-inc/maestro/issues/107
So you have to change your input keyboard to QWERTY for it to work properly.
## Future
- run on Element X iOS. This is already working but it need some change on the test to make it works. Could pass a PLATFORM parameter to have unique test and use conditional test.
- run specific test on both iOS and Android devices to make them communicate together. Could be possible to test room invite and join, verification, call, etc. To be done when Element X will be able to create account and create room. A main script would be able to detect the Android device and the iOS device, and run several maestro tests sequentially, using `--device` parameter to perform a global test.

7
.maestro/allTests.yaml Normal file
View File

@@ -0,0 +1,7 @@
appId: ${APP_ID}
---
- runFlow: tests/init.yaml
- runFlow: tests/account/login.yaml
- runFlow: tests/settings/settings.yaml
- runFlow: tests/roomList/roomList.yaml
- runFlow: tests/account/logout.yaml

View File

@@ -0,0 +1,5 @@
appId: ${APP_ID}
---
- tapOn: "Change"
- takeScreenshot: build/maestro/200-ChangeServer
- tapOn: "Continue"

View File

@@ -0,0 +1,21 @@
appId: ${APP_ID}
---
- tapOn: "Get started"
- runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml
- runFlow: ../assertions/assertLoginDisplayed.yaml
- tapOn: "Username or email"
# ios
# - tapOn:
# id: "usernameTextField"
# index: 0
- inputText: ${USERNAME}
- tapOn: "Password"
# iOS
#- tapOn:
# id: "passwordTextField"
# index: 0
- inputText: ${PASSWORD}
- tapOn: "Continue"
- runFlow: ../assertions/assertHomeDisplayed.yaml

View File

@@ -0,0 +1,12 @@
appId: ${APP_ID}
---
- tapOn: "Settings"
- tapOn: "Sign out"
- takeScreenshot: build/maestro/900-SignOutDialg
# Ensure cancel cancels
- tapOn: "Cancel"
- tapOn: "Sign out"
- tapOn:
text: "Sign out"
index: 1
- runFlow: ../assertions/assertInitDisplayed.yaml

View File

@@ -0,0 +1,5 @@
appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "All Chats"
timeout: 10_000

View File

@@ -0,0 +1,5 @@
appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "Own your conversations."
timeout: 10_000

View File

@@ -0,0 +1,5 @@
appId: ${APP_ID}
---
- extendedWaitUntil:
visible: "Welcome back!"
timeout: 10_000

8
.maestro/tests/init.yaml Normal file
View File

@@ -0,0 +1,8 @@
appId: ${APP_ID}
---
- clearState
- launchApp:
clearKeychain: true
- tapOn: "Close showkase button"
- runFlow: ./assertions/assertInitDisplayed.yaml
- takeScreenshot: build/maestro/000-FirstScreen

View File

@@ -0,0 +1,6 @@
appId: ${APP_ID}
---
- takeScreenshot: build/maestro/300-RoomList
- runFlow: searchRoomList.yaml
- runFlow: timeline/timeline.yaml

View File

@@ -0,0 +1,15 @@
appId: ${APP_ID}
---
- tapOn: "search"
- inputText: ${ROOM_NAME.substring(0, 3)}
- takeScreenshot: build/maestro/400-SearchRoom
- tapOn: ${ROOM_NAME}
# Close keyboard
- hideKeyboard
# Back from timeline
- back
# Close keyboard
- hideKeyboard
# Back from search
- back
- runFlow: ../assertions/assertHomeDisplayed.yaml

View File

@@ -0,0 +1,14 @@
appId: ${APP_ID}
---
# This is the name of one room
# TODO Create a room on a new account
- tapOn: ${ROOM_NAME}
- takeScreenshot: build/maestro/500-Timeline
- tapOn: "Message…"
- inputText: "Hello world!"
- tapOn: "Toggle full screen mode"
- tapOn: "Toggle full screen mode"
- tapOn: "Send"
- hideKeyboard
- back
- runFlow: ../../assertions/assertHomeDisplayed.yaml

View File

@@ -0,0 +1,12 @@
appId: ${APP_ID}
---
- tapOn: "Settings"
- assertVisible: "Rage shake to report bug"
- takeScreenshot: build/maestro/600-Settings
- tapOn:
text: "Report bug"
index: 1
- assertVisible: "Describe your problem here"
- back
- back
- runFlow: ../assertions/assertHomeDisplayed.yaml

0
CHANGES.md Normal file
View File

3
Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem 'danger'

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -19,15 +19,21 @@ package io.element.android.x.anvilannotations
import kotlin.reflect.KClass
/**
* Adds view model to the specified component graph.
* Equivalent to the following declaration in a dagger module:
* Adds Node to the specified component graph.
* Equivalent to the following declaration:
*
* @Binds
* @IntoMap
* @ViewModelKey(YourViewModel::class)
* public abstract fun bindYourViewModelFactory(factory: YourViewModel.Factory): AssistedViewModelFactory<*, *>
* @Module
* @ContributesTo(Scope::class)
* abstract class YourNodeModule {
* @Binds
* @IntoMap
* @NodeKey(YourNode::class)
* abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*>
*}
*/
@Target(AnnotationTarget.CLASS)
annotation class ContributesViewModel(
annotation class ContributesNode(
val scope: KClass<*>,
)

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -46,32 +46,32 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.multibindings.IntoMap
import io.element.android.x.anvilannotations.ContributesViewModel
import java.io.File
import io.element.android.x.anvilannotations.ContributesNode
import org.jetbrains.kotlin.descriptors.ModuleDescriptor
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtFile
import java.io.File
/**
* This is an anvil plugin that allows ViewModels to use [ContributesViewModel] alone and let this plugin automatically
* This is an anvil plugin that allows Node to use [ContributesNode] alone and let this plugin automatically
* handle the rest of the Dagger wiring required for constructor injection.
*/
@AutoService(CodeGenerator::class)
class ContributesViewModelCodeGenerator : CodeGenerator {
class ContributesNodeCodeGenerator : CodeGenerator {
override fun isApplicable(context: AnvilContext): Boolean = true
override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection<KtFile>): Collection<GeneratedFile> {
return projectFiles.classAndInnerClassReferences(module)
.filter { it.isAnnotatedWith(ContributesViewModel::class.fqName) }
.filter { it.isAnnotatedWith(ContributesNode::class.fqName) }
.flatMap { listOf(generateModule(it, codeGenDir, module), generateAssistedFactory(it, codeGenDir, module)) }
.toList()
}
private fun generateModule(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
val generatedPackage = vmClass.packageFqName.toString()
val moduleClassName = "${vmClass.shortName}_Module"
val scope = vmClass.annotations.single { it.fqName == ContributesViewModel::class.fqName }.scope()
private fun generateModule(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
val generatedPackage = nodeClass.packageFqName.toString()
val moduleClassName = "${nodeClass.shortName}_Module"
val scope = nodeClass.annotations.single { it.fqName == ContributesNode::class.fqName }.scope()
val content = FileSpec.buildFile(generatedPackage, moduleClassName) {
addType(
TypeSpec.classBuilder(moduleClassName)
@@ -79,17 +79,17 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
.addAnnotation(Module::class)
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build())
.addFunction(
FunSpec.builder("bind${vmClass.shortName}Factory")
FunSpec.builder("bind${nodeClass.shortName}Factory")
.addModifiers(KModifier.ABSTRACT)
.addParameter("factory", ClassName(generatedPackage, "${vmClass.shortName}_AssistedFactory"))
.returns(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(STAR, STAR))
.addParameter("factory", ClassName(generatedPackage, "${nodeClass.shortName}_AssistedFactory"))
.returns(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(STAR))
.addAnnotation(Binds::class)
.addAnnotation(IntoMap::class)
.addAnnotation(
AnnotationSpec.Companion
.builder(viewModelKeyFqName.asClassName(module))
.addMember("%T::class", vmClass.asClassName())
.build()
AnnotationSpec.Companion.builder(nodeKeyFqName.asClassName(module)).addMember(
"%T::class",
nodeClass.asClassName()
).build()
)
.build(),
)
@@ -99,35 +99,46 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content)
}
private fun generateAssistedFactory(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
val generatedPackage = vmClass.packageFqName.toString()
val assistedFactoryClassName = "${vmClass.shortName}_AssistedFactory"
val constructor = vmClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) }
val assistedParameter = constructor?.parameters?.singleOrNull { it.isAnnotatedWith(Assisted::class.fqName) }
if (constructor == null || assistedParameter == null) {
private fun generateAssistedFactory(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
val generatedPackage = nodeClass.packageFqName.toString()
val assistedFactoryClassName = "${nodeClass.shortName}_AssistedFactory"
val constructor = nodeClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) }
val assistedParameters = constructor?.parameters?.filter { it.isAnnotatedWith(Assisted::class.fqName) }.orEmpty()
if (constructor == null || assistedParameters.size != 2) {
throw AnvilCompilationException(
"${vmClass.fqName} must have an @AssistedInject constructor with @Assisted initialState: S parameter",
element = vmClass.clazz,
"${nodeClass.fqName} must have an @AssistedInject constructor with 2 @Assisted parameters",
element = nodeClass.clazz,
)
}
if (assistedParameter.name != "initialState") {
val contextAssistedParam = assistedParameters[0]
if (contextAssistedParam.name != "buildContext") {
throw AnvilCompilationException(
"${vmClass.fqName} @Assisted parameter must be named initialState",
element = assistedParameter.parameter,
"${nodeClass.fqName} @Assisted parameter must be named buildContext",
element = contextAssistedParam.parameter,
)
}
val vmClassName = vmClass.asClassName()
val stateClassName = assistedParameter.type().asTypeName()
val pluginsAssistedParam = assistedParameters[1]
if (pluginsAssistedParam.name != "plugins") {
throw AnvilCompilationException(
"${nodeClass.fqName} @Assisted parameter must be named plugins",
element = pluginsAssistedParam.parameter,
)
}
val nodeClassName = nodeClass.asClassName()
val buildContextClassName = contextAssistedParam.type().asTypeName()
val pluginsClassName = pluginsAssistedParam.type().asTypeName()
val content = FileSpec.buildFile(generatedPackage, assistedFactoryClassName) {
addType(
TypeSpec.interfaceBuilder(assistedFactoryClassName)
.addSuperinterface(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(vmClassName, stateClassName))
.addSuperinterface(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(nodeClassName))
.addAnnotation(AssistedFactory::class)
.addFunction(
FunSpec.builder("create")
.addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT)
.addParameter("initialState", stateClassName)
.returns(vmClassName)
.addParameter("buildContext", buildContextClassName)
.addParameter("plugins", pluginsClassName)
.returns(nodeClassName)
.build(),
)
.build(),
@@ -137,7 +148,7 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
}
companion object {
private val assistedViewModelFactoryFqName = FqName("io.element.android.x.core.di.AssistedViewModelFactory")
private val viewModelKeyFqName = FqName("io.element.android.x.core.di.ViewModelKey")
private val assistedNodeFactoryFqName = FqName("io.element.android.x.architecture.AssistedNodeFactory")
private val nodeKeyFqName = FqName("io.element.android.x.architecture.NodeKey")
}
}

View File

@@ -1,3 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
@@ -14,21 +16,28 @@
* limitations under the License.
*/
import extension.allFeatures
import extension.allLibraries
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-application")
alias(libs.plugins.stem)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
alias(libs.plugins.kapt)
id("com.google.firebase.appdistribution") version "3.0.2"
id("org.jetbrains.kotlinx.knit") version "0.4.0"
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.x"
testOptions { unitTests.isIncludeAndroidResources = true }
defaultConfig {
applicationId = "io.element.android.x"
targetSdk = 33 // TODO Use Versions.targetSdk
@@ -93,7 +102,8 @@ android {
firebaseAppDistribution {
artifactType = "APK"
// releaseNotesFile = TODO
// This file will be generated by the GitHub action
releaseNotesFile = "CHANGES_NIGHTLY.md"
groups = "external-testers"
// This should not be required, but if I do not add the appId, I get this error:
// "App Distribution halted because it had a problem uploading the APK: [404] Requested entity was not found."
@@ -112,7 +122,7 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.3.2"
kotlinCompilerExtensionVersion = "1.4.0"
}
packagingOptions {
resources {
@@ -120,6 +130,7 @@ android {
}
}
// Waiting for https://github.com/google/ksp/issues/37
applicationVariants.all {
kotlin.sourceSets {
getByName(name) {
@@ -151,36 +162,22 @@ knit {
}
dependencies {
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:matrix"))
implementation(project(":libraries:matrixui"))
implementation(project(":libraries:core"))
implementation(project(":features:onboarding"))
implementation(project(":features:login"))
implementation(project(":features:logout"))
implementation(project(":features:roomlist"))
implementation(project(":features:messages"))
implementation(project(":features:rageshake"))
implementation(project(":features:preferences"))
implementation(project(":libraries:di"))
allLibraries()
allFeatures()
implementation(project(":tests:uitests"))
implementation(project(":anvilannotations"))
anvil(project(":anvilcodegen"))
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.0")
implementation(libs.compose.destinations)
ksp(libs.compose.destinations.processor)
// https://developer.android.com/studio/write/java8-support#library-desugaring-versions
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.2")
implementation(libs.appyx.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup)
implementation(libs.coil)
implementation(libs.mavericks.compose)
implementation(libs.dagger)
kapt(libs.dagger.compiler)
implementation(libs.showkase)
ksp(libs.showkase.processor)
}

View File

@@ -29,11 +29,10 @@
android:supportsRtl="true"
android:theme="@style/Theme.ElementX"
tools:targetApi="33">
<!-- Note: temporary block orientation to sensorPortrait because the RichTextEditor library is crashing on configuration change -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:exported="true"
android:screenOrientation="sensorPortrait"
android:windowSoftInputMode="adjustResize"
tools:ignore="LockedOrientationActivity">
<intent-filter>
@@ -47,7 +46,7 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove"/>
tools:node="remove" />
</application>

View File

@@ -1,212 +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.
*/
@file:OptIn(
ExperimentalAnimationApi::class,
ExperimentalMaterialNavigationApi::class
)
package io.element.android.x
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.navigation.NavHostController
import com.airbnb.android.showkase.models.Showkase
import com.airbnb.mvrx.compose.mavericksActivityViewModel
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine
import com.ramcosta.composedestinations.spec.Route
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import io.element.android.x.features.rageshake.bugreport.BugReportScreen
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionScreen
import io.element.android.x.features.rageshake.detection.RageshakeDetectionScreen
import kotlinx.coroutines.runBlocking
import timber.log.Timber
private const val transitionAnimationDuration = 500
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ElementXTheme {
MainScreen(viewModel = mavericksActivityViewModel())
}
}
}
@Composable
private fun ShowkaseButton(
isVisible: Boolean,
onClick: () -> Unit,
onCloseClicked: () -> Unit
) {
if (isVisible) {
Button(
modifier = Modifier
.padding(top = 32.dp, start = 16.dp),
onClick = onClick
) {
Text(text = "Showkase Browser")
IconButton(
modifier = Modifier
.padding(start = 8.dp)
.size(16.dp),
onClick = onCloseClicked,
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "")
}
}
}
}
@Composable
private fun MainScreen(viewModel: MainViewModel) {
val startRoute = runBlocking {
if (!viewModel.isLoggedIn()) {
OnBoardingScreenNavigationDestination
} else {
viewModel.restoreSession()
NavGraphs.root.startRoute
}
}
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
var isBugReportVisible by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
MainContent(
startRoute = startRoute
)
ShowkaseButton(
isVisible = isShowkaseButtonVisible,
onCloseClicked = { isShowkaseButtonVisible = false },
onClick = { startActivity(Showkase.getBrowserIntent(this@MainActivity)) }
)
RageshakeDetectionScreen(
onOpenBugReport = {
isBugReportVisible = true
}
)
CrashDetectionScreen(
onOpenBugReport = {
isBugReportVisible = true
}
)
if (isBugReportVisible) {
// TODO Improve the navigation, when pressing back here, it closes the app.
BugReportScreen(
onDone = { isBugReportVisible = false }
)
}
}
OnLifecycleEvent { _, event ->
Timber.v("OnLifecycleEvent: $event")
}
}
@Composable
private fun MainContent(startRoute: Route) {
val engine = rememberAnimatedNavHostEngine(
rootDefaultAnimations = RootNavGraphDefaultAnimations(
enterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Left,
animationSpec = tween(transitionAnimationDuration)
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.Left,
animationSpec = tween(transitionAnimationDuration)
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Right,
animationSpec = tween(transitionAnimationDuration)
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.Right,
animationSpec = tween(transitionAnimationDuration)
)
}
)
)
val navController = engine.rememberNavController()
LogNavigation(navController)
DestinationsNavHost(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
engine = engine,
navController = navController,
navGraph = NavGraphs.root,
startRoute = startRoute,
dependenciesContainerBuilder = {
}
)
}
@Composable
private fun LogNavigation(navController: NavHostController) {
LaunchedEffect(key1 = navController) {
navController.appCurrentDestinationFlow.collect {
Timber.d("Navigating to ${it.route}")
}
}
}
@Composable
@Preview
fun MainContentPreview() {
MainContent(startRoute = OnBoardingScreenNavigationDestination)
}
}

View File

@@ -1,69 +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.
*/
package io.element.android.x
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.di.AppScope
import io.element.android.x.di.SessionComponentsOwner
import io.element.android.x.matrix.Matrix
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class MainState(val fake: Boolean = false) : MavericksState
@ContributesViewModel(AppScope::class)
class MainViewModel @AssistedInject constructor(
private val matrix: Matrix,
private val sessionComponentsOwner: SessionComponentsOwner,
@Assisted initialState: MainState
) : MavericksViewModel<MainState>(initialState) {
companion object :
MavericksViewModelFactory<MainViewModel, MainState> by daggerMavericksViewModelFactory()
suspend fun isLoggedIn(): Boolean {
return matrix.isLoggedIn().first()
}
fun startSyncIfLogged() {
viewModelScope.launch {
if (!isLoggedIn()) return@launch
}
}
fun stopSyncIfLogged() {
viewModelScope.launch {
if (!isLoggedIn()) return@launch
}
}
suspend fun restoreSession() {
val matrixClient = matrix.restoreSession()
if (matrixClient == null) {
throw IllegalStateException("Couldn't restore session...")
} else {
sessionComponentsOwner.create(matrixClient)
matrixClient.startSync()
}
}
}

View File

@@ -1,132 +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.
*/
package io.element.android.x
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.popUpTo
import io.element.android.x.core.di.bindings
import io.element.android.x.destinations.BugReportScreenNavigationDestination
import io.element.android.x.destinations.ChangeServerScreenNavigationDestination
import io.element.android.x.destinations.LoginScreenNavigationDestination
import io.element.android.x.destinations.MessagesScreenNavigationDestination
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import io.element.android.x.destinations.PreferencesScreenNavigationDestination
import io.element.android.x.destinations.RoomListScreenNavigationDestination
import io.element.android.x.di.AppBindings
import io.element.android.x.features.login.LoginScreen
import io.element.android.x.features.login.changeserver.ChangeServerScreen
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.onboarding.OnBoardingScreen
import io.element.android.x.features.preferences.PreferencesScreen
import io.element.android.x.features.rageshake.bugreport.BugReportScreen
import io.element.android.x.features.roomlist.RoomListScreen
import io.element.android.x.matrix.core.RoomId
@Destination
@Composable
fun OnBoardingScreenNavigation(navigator: DestinationsNavigator) {
OnBoardingScreen(
onSignUp = {
// TODO
},
onSignIn = {
navigator.navigate(LoginScreenNavigationDestination)
}
)
}
@Destination
@Composable
fun LoginScreenNavigation(navigator: DestinationsNavigator) {
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
LoginScreen(
onChangeServer = {
navigator.navigate(ChangeServerScreenNavigationDestination)
},
onLoginWithSuccess = {
sessionComponentsOwner.create(it)
navigator.navigate(RoomListScreenNavigationDestination) {
popUpTo(OnBoardingScreenNavigationDestination) {
inclusive = true
}
}
}
)
}
// TODO Create a subgraph in Login module
@Destination
@Composable
fun ChangeServerScreenNavigation(navigator: DestinationsNavigator) {
ChangeServerScreen(
onChangeServerSuccess = {
navigator.popBackStack()
}
)
}
@RootNavGraph(start = true)
@Destination
@Composable
fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
RoomListScreen(
onRoomClicked = { roomId: RoomId ->
navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value))
},
onOpenSettings = {
navigator.navigate(PreferencesScreenNavigationDestination())
},
)
}
@Destination
@Composable
fun MessagesScreenNavigation(roomId: String, navigator: DestinationsNavigator) {
MessagesScreen(roomId = roomId, onBackPressed = navigator::navigateUp)
}
@Destination
@Composable
fun BugReportScreenNavigation(navigator: DestinationsNavigator) {
BugReportScreen(
onDone = navigator::popBackStack
)
}
@Destination
@Composable
fun PreferencesScreenNavigation(navigator: DestinationsNavigator) {
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
PreferencesScreen(
onBackPressed = navigator::navigateUp,
onOpenRageShake = {
navigator.navigate(BugReportScreenNavigationDestination)
},
onSuccessLogout = {
sessionComponentsOwner.releaseActiveSession()
navigator.navigate(OnBoardingScreenNavigationDestination) {
popUpTo(RoomListScreenNavigationDestination) {
inclusive = true
}
}
},
)
}

View File

@@ -1,60 +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.
*/
package io.element.android.x.di
import android.content.Context
import io.element.android.x.core.di.bindings
import io.element.android.x.matrix.MatrixClient
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@SingleIn(AppScope::class)
class SessionComponentsOwner @Inject constructor(@ApplicationContext private val context: Context) {
private val sessionComponents = ConcurrentHashMap<String, SessionComponent>()
var activeSessionComponent: SessionComponent? = null
private set
fun setActive(sessionId: String) {
val sessionComponent = sessionComponents[sessionId]
if (activeSessionComponent != sessionComponent) {
activeSessionComponent = sessionComponent
}
}
fun create(matrixClient: MatrixClient) {
val sessionId = matrixClient.sessionId
val sessionComponent =
context.bindings<SessionComponent.ParentBindings>().sessionComponentBuilder()
.client(matrixClient).build()
sessionComponents[sessionId] = sessionComponent
setActive(sessionId)
}
fun releaseActiveSession() {
activeSessionComponent?.also {
release(it.matrixClient().sessionId)
}
}
fun release(sessionId: String) {
val sessionComponent = sessionComponents.remove(sessionId)
if (activeSessionComponent == sessionComponent) {
activeSessionComponent = null
}
}
}

View File

@@ -1,53 +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.
*/
package io.element.android.x.initializer
import android.content.Context
import androidx.startup.Initializer
import coil.Coil
import coil.ImageLoader
import coil.ImageLoaderFactory
import io.element.android.x.core.di.bindings
import io.element.android.x.di.AppBindings
class CoilInitializer : Initializer<Unit> {
override fun create(context: Context) {
Coil.setImageLoader(ElementImageLoaderFactory(context))
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
private class ElementImageLoaderFactory(
private val context: Context
) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader
.Builder(context)
.components {
val appBindings = context.bindings<AppBindings>()
val matrixUi = appBindings.matrixUi()
val matrixClientProvider = {
appBindings
.sessionComponentsOwner().activeSessionComponent?.matrixClient()
}
matrixUi.registerCoilComponents(this, matrixClientProvider)
}
.build()
}
}

View File

@@ -18,36 +18,27 @@ package io.element.android.x
import android.app.Application
import androidx.startup.AppInitializer
import io.element.android.x.core.di.DaggerComponentOwner
import io.element.android.x.core.di.bindings
import io.element.android.x.di.AppBindings
import io.element.android.x.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.di.SessionComponentsOwner
import io.element.android.x.initializer.CoilInitializer
import io.element.android.x.initializer.CrashInitializer
import io.element.android.x.initializer.MatrixInitializer
import io.element.android.x.initializer.MavericksInitializer
import io.element.android.x.initializer.TimberInitializer
class ElementXApplication : Application(), DaggerComponentOwner {
private lateinit var appComponent: AppComponent
private var sessionComponentsOwner: SessionComponentsOwner? = null
override val daggerComponent: Any
get() = listOfNotNull(sessionComponentsOwner?.activeSessionComponent, appComponent)
get() = appComponent
override fun onCreate() {
super.onCreate()
appComponent = DaggerAppComponent.factory().create(applicationContext)
sessionComponentsOwner = bindings<AppBindings>().sessionComponentsOwner()
AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java)
initializeComponent(TimberInitializer::class.java)
initializeComponent(MatrixInitializer::class.java)
initializeComponent(CoilInitializer::class.java)
initializeComponent(MavericksInitializer::class.java)
}
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import io.element.android.x.architecture.bindings
import io.element.android.x.di.DaggerComponentOwner
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.di.AppBindings
import io.element.android.x.node.RootFlowNode
class MainActivity : NodeComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appBindings = bindings<AppBindings>()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ElementXTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
RootFlowNode(
buildContext = it,
appComponentOwner = applicationContext as DaggerComponentOwner,
authenticationService = appBindings.authenticationService(),
rootPresenter = appBindings.rootPresenter()
)
}
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.component
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun ShowkaseButton(
isVisible: Boolean,
onClick: () -> Unit,
onCloseClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
if (isVisible) {
Button(
modifier = modifier
.padding(top = 32.dp, start = 16.dp),
onClick = onClick
) {
Text(text = "Showkase Browser")
IconButton(
modifier = Modifier
.padding(start = 8.dp)
.size(16.dp),
onClick = onCloseClicked,
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Close showkase button")
}
}
}
}

View File

@@ -17,14 +17,13 @@
package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.x.matrix.Matrix
import io.element.android.x.matrix.ui.MatrixUi
import io.element.android.x.matrix.auth.MatrixAuthenticationService
import io.element.android.x.root.RootPresenter
import kotlinx.coroutines.CoroutineScope
@ContributesTo(AppScope::class)
interface AppBindings {
fun coroutineScope(): CoroutineScope
fun matrix(): Matrix
fun matrixUi(): MatrixUi
fun sessionComponentsOwner(): SessionComponentsOwner
fun rootPresenter(): RootPresenter
fun authenticationService(): MatrixAuthenticationService
}

View File

@@ -20,11 +20,11 @@ import android.content.Context
import com.squareup.anvil.annotations.MergeComponent
import dagger.BindsInstance
import dagger.Component
import io.element.android.x.core.di.DaggerMavericksBindings
import io.element.android.x.architecture.NodeFactoriesBindings
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
interface AppComponent : DaggerMavericksBindings {
interface AppComponent : NodeFactoriesBindings {
@Component.Factory
interface Factory {

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.di
import android.content.Context
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.x.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.plus
import java.io.File
import java.util.concurrent.Executors
@Module
@ContributesTo(AppScope::class)
object AppModule {
@Provides
fun providesBaseDirectory(@ApplicationContext context: Context): File {
return File(context.filesDir, "sessions")
}
@Provides
@SingleIn(AppScope::class)
fun providesAppCoroutineScope(): CoroutineScope {
return MainScope() + CoroutineName("ElementX Scope")
}
@Provides
@SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers {
return CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.MergeSubcomponent
import dagger.BindsInstance
import dagger.Subcomponent
import io.element.android.x.architecture.NodeFactoriesBindings
import io.element.android.x.matrix.room.MatrixRoom
@SingleIn(RoomScope::class)
@MergeSubcomponent(RoomScope::class)
interface RoomComponent : NodeFactoriesBindings {
@Subcomponent.Builder
interface Builder {
@BindsInstance
fun room(room: MatrixRoom): Builder
fun build(): RoomComponent
}
@ContributesTo(SessionScope::class)
interface ParentBindings {
fun roomComponentBuilder(): Builder
}
}

View File

@@ -20,14 +20,12 @@ import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.MergeSubcomponent
import dagger.BindsInstance
import dagger.Subcomponent
import io.element.android.x.core.di.DaggerMavericksBindings
import io.element.android.x.architecture.NodeFactoriesBindings
import io.element.android.x.matrix.MatrixClient
@SingleIn(SessionScope::class)
@MergeSubcomponent(SessionScope::class)
interface SessionComponent : DaggerMavericksBindings {
fun matrixClient(): MatrixClient
interface SessionComponent : NodeFactoriesBindings, RoomComponent.ParentBindings {
@Subcomponent.Builder
interface Builder {

View File

@@ -32,5 +32,5 @@ class MatrixInitializer : Initializer<Unit> {
}
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(TimberInitializer::class.java)
}

View File

@@ -0,0 +1,129 @@
/*
* 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.x.node
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import coil.Coil
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.node.node
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.architecture.bindings
import io.element.android.x.architecture.createNode
import io.element.android.x.di.DaggerComponentOwner
import io.element.android.x.di.SessionComponent
import io.element.android.x.features.preferences.PreferencesFlowNode
import io.element.android.x.features.roomlist.RoomListNode
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.core.RoomId
import io.element.android.x.matrix.core.SessionId
import io.element.android.x.matrix.ui.di.MatrixUIBindings
import kotlinx.parcelize.Parcelize
class LoggedInFlowNode(
buildContext: BuildContext,
val sessionId: SessionId,
private val matrixClient: MatrixClient,
private val onOpenBugReport: () -> Unit,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.RoomList,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<LoggedInFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
), DaggerComponentOwner {
override val daggerComponent: Any by lazy {
parent!!.bindings<SessionComponent.ParentBindings>().sessionComponentBuilder().client(matrixClient).build()
}
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory)
matrixClient.startSync()
},
onDestroy = {
val imageLoaderFactory = bindings<MatrixUIBindings>().notLoggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory)
}
)
}
private val roomListCallback = object : RoomListNode.Callback {
override fun onRoomClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId))
}
override fun onSettingsClicked() {
backstack.push(NavTarget.Settings)
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object RoomList : NavTarget
@Parcelize
data class Room(val roomId: RoomId) : NavTarget
@Parcelize
object Settings : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.RoomList -> {
createNode<RoomListNode>(buildContext, plugins = listOf(roomListCallback))
}
is NavTarget.Room -> {
val room = matrixClient.getRoom(roomId = navTarget.roomId)
if (room == null) {
// TODO CREATE UNKNOWN ROOM NODE
node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Unknown room with id = ${navTarget.roomId}")
}
}
} else {
RoomFlowNode(buildContext, room)
}
}
NavTarget.Settings -> {
PreferencesFlowNode(buildContext, onOpenBugReport)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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.x.node
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.node.node
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace
import io.element.android.x.features.login.LoginFlowNode
import io.element.android.x.features.onboarding.OnBoardingScreen
import kotlinx.parcelize.Parcelize
import timber.log.Timber
class NotLoggedInFlowNode(
buildContext: BuildContext,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.OnBoarding,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<NotLoggedInFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
) {
init {
lifecycle.subscribe(
onCreate = { Timber.v("OnCreate") },
onDestroy = { Timber.v("OnDestroy") }
)
}
sealed interface NavTarget : Parcelable {
@Parcelize
object OnBoarding : NavTarget
@Parcelize
object LoginFlow : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.OnBoarding -> node(buildContext) {
OnBoardingScreen(
onSignIn = { backstack.replace(NavTarget.LoginFlow) }
)
}
NavTarget.LoginFlow -> LoginFlowNode(buildContext)
}
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.node
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import io.element.android.x.architecture.bindings
import io.element.android.x.architecture.createNode
import io.element.android.x.di.DaggerComponentOwner
import io.element.android.x.di.RoomComponent
import io.element.android.x.features.messages.MessagesNode
import io.element.android.x.matrix.room.MatrixRoom
import kotlinx.parcelize.Parcelize
import timber.log.Timber
class RoomFlowNode(
buildContext: BuildContext,
private val room: MatrixRoom,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.Messages,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<RoomFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
), DaggerComponentOwner {
override val daggerComponent: Any by lazy {
parent!!.bindings<RoomComponent.ParentBindings>().roomComponentBuilder().room(room).build()
}
init {
lifecycle.subscribe(
onCreate = { Timber.v("OnCreate") },
onDestroy = { Timber.v("OnDestroy") }
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Messages -> createNode<MessagesNode>(buildContext)
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Messages : NavTarget
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View File

@@ -0,0 +1,157 @@
/*
* 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.x.node
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.node.node
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.architecture.createNode
import io.element.android.x.architecture.presenterConnector
import io.element.android.x.di.DaggerComponentOwner
import io.element.android.x.features.rageshake.bugreport.BugReportNode
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.auth.MatrixAuthenticationService
import io.element.android.x.matrix.core.SessionId
import io.element.android.x.root.RootPresenter
import io.element.android.x.root.RootView
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
class RootFlowNode(
buildContext: BuildContext,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap,
),
private val appComponentOwner: DaggerComponentOwner,
private val authenticationService: MatrixAuthenticationService,
rootPresenter: RootPresenter
) :
ParentNode<RootFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext,
),
DaggerComponentOwner by appComponentOwner {
private val matrixClientsHolder = ConcurrentHashMap<SessionId, MatrixClient>()
private val presenterConnector = presenterConnector(rootPresenter)
override fun onBuilt() {
super.onBuilt()
whenChildAttached(LoggedInFlowNode::class) { _, child ->
child.lifecycle.subscribe(
onDestroy = { matrixClientsHolder.remove(child.sessionId) }
)
}
authenticationService.isLoggedIn()
.distinctUntilChanged()
.onEach { isLoggedIn ->
Timber.v("isLoggedIn=$isLoggedIn")
if (isLoggedIn) {
val matrixClient = authenticationService.restoreSession()
if (matrixClient == null) {
backstack.newRoot(NavTarget.NotLoggedInFlow)
} else {
matrixClientsHolder[matrixClient.sessionId] = matrixClient
backstack.newRoot(NavTarget.LoggedInFlow(matrixClient.sessionId))
}
} else {
backstack.newRoot(NavTarget.NotLoggedInFlow)
}
}
.launchIn(lifecycleScope)
}
private fun onOpenBugReport() {
backstack.push(NavTarget.BugReport)
}
@Composable
override fun View(modifier: Modifier) {
val state by presenterConnector.stateFlow.collectAsState()
RootView(
state = state,
onOpenBugReport = this::onOpenBugReport,
) {
Children(navModel = backstack)
}
}
private val bugReportNodeCallback = object : BugReportNode.Callback {
override fun onBugReportSent() {
backstack.pop()
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object SplashScreen : NavTarget
@Parcelize
object NotLoggedInFlow : NavTarget
@Parcelize
data class LoggedInFlow(val sessionId: SessionId) : NavTarget
@Parcelize
object BugReport : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.LoggedInFlow -> {
val matrixClient =
matrixClientsHolder[navTarget.sessionId] ?: throw IllegalStateException("Makes sure to give a matrixClient with the given sessionId")
LoggedInFlowNode(
buildContext = buildContext,
sessionId = navTarget.sessionId,
matrixClient = matrixClient,
onOpenBugReport = this::onOpenBugReport
)
}
NavTarget.NotLoggedInFlow -> NotLoggedInFlowNode(buildContext)
NavTarget.SplashScreen -> node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
NavTarget.BugReport -> createNode<BugReportNode>(buildContext, plugins = listOf(bugReportNodeCallback))
}
}
}

View File

@@ -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.x.root
sealed interface RootEvents {
object HideShowkaseButton : RootEvents
}

View File

@@ -0,0 +1,61 @@
/*
* 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.x.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.features.rageshake.bugreport.BugReportPresenter
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionPresenter
import io.element.android.x.features.rageshake.detection.RageshakeDetectionPresenter
import javax.inject.Inject
class RootPresenter @Inject constructor(
private val bugReportPresenter: BugReportPresenter,
private val crashDetectionPresenter: CrashDetectionPresenter,
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
) : Presenter<RootState> {
@Composable
override fun present(): RootState {
val isBugReportVisible = rememberSaveable {
mutableStateOf(false)
}
val isShowkaseButtonVisible = rememberSaveable {
mutableStateOf(true)
}
val rageshakeDetectionState = rageshakeDetectionPresenter.present()
val crashDetectionState = crashDetectionPresenter.present()
val bugReportState = bugReportPresenter.present()
fun handleEvent(event: RootEvents) {
when (event) {
RootEvents.HideShowkaseButton -> isShowkaseButtonVisible.value = false
}
}
return RootState(
isBugReportVisible = isBugReportVisible.value,
isShowkaseButtonVisible = isShowkaseButtonVisible.value,
rageshakeDetectionState = rageshakeDetectionState,
crashDetectionState = crashDetectionState,
bugReportState = bugReportState,
eventSink = ::handleEvent
)
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.root
import androidx.compose.runtime.Stable
import io.element.android.x.features.rageshake.bugreport.BugReportState
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionState
import io.element.android.x.features.rageshake.detection.RageshakeDetectionState
@Stable
data class RootState(
val isBugReportVisible: Boolean,
val isShowkaseButtonVisible: Boolean,
val rageshakeDetectionState: RageshakeDetectionState,
val crashDetectionState: CrashDetectionState,
val bugReportState: BugReportState,
val eventSink: (RootEvents) -> Unit
)

View File

@@ -0,0 +1,70 @@
/*
* 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.x.root
import android.app.Activity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import io.element.android.x.component.ShowkaseButton
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionEvents
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionView
import io.element.android.x.features.rageshake.detection.RageshakeDetectionEvents
import io.element.android.x.features.rageshake.detection.RageshakeDetectionView
import io.element.android.x.tests.uitests.openShowkase
@Composable
fun RootView(
state: RootState,
modifier: Modifier = Modifier,
onOpenBugReport: () -> Unit = {},
children: @Composable BoxScope.() -> Unit,
) {
Box(
modifier = modifier
.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
children()
val eventSink = state.eventSink
val context = LocalContext.current
fun onOpenBugReport() {
state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed)
state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss)
onOpenBugReport.invoke()
}
ShowkaseButton(
isVisible = state.isShowkaseButtonVisible,
onCloseClicked = { eventSink(RootEvents.HideShowkaseButton) },
onClick = { openShowkase(context as Activity) }
)
RageshakeDetectionView(
state = state.rageshakeDetectionState,
onOpenBugReport = ::onOpenBugReport,
)
CrashDetectionView(
state = state.crashDetectionState,
onOpenBugReport = ::onOpenBugReport,
)
}
}

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022 New Vector Ltd
~ 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.
@@ -13,7 +14,7 @@
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- The https://github.com/LikeTheSalad/android-stem requires a non empty strings.xml -->
<string name="ignored_placeholder" translatable="false" tools:ignore="UnusedResources">ignored</string>
</resources>

1
changelog.d/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
!.gitignore

View File

@@ -0,0 +1,45 @@
# Screenshot testing
<!--- TOC -->
* [Overview](#overview)
* [Setup](#setup)
* [Recording](#recording)
* [Verifying](#verifying)
* [Contributing](#contributing)
<!--- END -->
## Overview
- Screenshot tests are tests which record the content of a rendered screen and verify subsequent runs to check if the screen renders differently.
- ElementX uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composable. All Composable Preview will be use to make screenshot test, thanks to the usage of [Showkase](https://github.com/airbnb/Showkase).
- The screenshot verification occurs on every pull request as part of the `tests.yml` workflow.
## Setup
- Install Git LFS through your package manager of choice (`brew install git-lfs` | `yay -S git-lfs`).
- Install the Git LFS hooks into the project.
```bash
# with element-android as the current working directory
git lfs install --local
```
- If installed correctly, `git push` and `git pull` will now include LFS content.
## Recording
- `./gradlew recordPaparazziDebug`
- Paparazzi will generate images in `:tests:uitests/src/test/snapshots`, which will need to be committed to the repository using Git LFS.
## Verifying
- `./gradlew verifyPaparazziDebug`
- In the case of failure, Paparazzi will generate images in `:tests:uitests/out/failure`. The images will show the expected and actual screenshots along with a delta of the two images.
## Contributing
- Creating Previewable Composable will automatically creates new screenshot tests.
- After creating the new test, record and commit the newly rendered screens.
- `./tools/git/validate_lfs.sh` can be run to ensure everything is working correctly with Git LFS, the CI also runs this check.

View File

@@ -20,6 +20,7 @@ plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
id("kotlin-parcelize")
}
android {
@@ -35,10 +36,12 @@ dependencies {
anvil(project(":anvilcodegen"))
implementation(project(":libraries:di"))
implementation(project(":libraries:core"))
implementation(project(":libraries:architecture"))
implementation(project(":libraries:matrix"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:elementresources"))
implementation(libs.mavericks.compose)
implementation(libs.appyx.core)
implementation(project(":libraries:ui-strings"))
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
androidTestImplementation(libs.test.junitext)

View File

@@ -1,85 +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.
*/
package io.element.android.x.features.login
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Uninitialized
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.di.AppScope
import io.element.android.x.matrix.Matrix
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ContributesViewModel(AppScope::class)
class LoginViewModel @AssistedInject constructor(
private val matrix: Matrix,
@Assisted initialState: LoginViewState) :
MavericksViewModel<LoginViewState>(initialState) {
companion object : MavericksViewModelFactory<LoginViewModel, LoginViewState> by daggerMavericksViewModelFactory()
var formState = mutableStateOf(LoginFormState.Default)
private set
init {
snapshotFlow { formState.value }
.onEach {
setState { copy(formState = it) }
}.launchIn(viewModelScope)
}
fun onResume() {
val currentHomeserver = matrix.getHomeserverOrDefault()
setState {
copy(
homeserver = currentHomeserver
)
}
}
fun onSubmit() {
viewModelScope.launch {
suspend {
val state = awaitState()
// Ensure the server is provided to the Rust SDK
matrix.setHomeserver(state.homeserver)
matrix.login(state.formState.login.trim(), state.formState.password.trim()).also {
it.startSync()
}
}.execute {
copy(loggedInClient = it)
}
}
}
fun onSetPassword(password: String) {
formState.value = formState.value.copy(password = password)
setState { copy(loggedInClient = Uninitialized) }
}
fun onSetName(name: String) {
formState.value = formState.value.copy(login = name)
setState { copy(loggedInClient = Uninitialized) }
}
}

View File

@@ -1,67 +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.
*/
package io.element.android.x.features.login.changeserver
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Uninitialized
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.di.AppScope
import io.element.android.x.matrix.Matrix
import kotlinx.coroutines.launch
@ContributesViewModel(AppScope::class)
class ChangeServerViewModel @AssistedInject constructor(
private val matrix: Matrix,
@Assisted initialState: ChangeServerViewState
) :
MavericksViewModel<ChangeServerViewState>(initialState) {
companion object :
MavericksViewModelFactory<ChangeServerViewModel, ChangeServerViewState> by daggerMavericksViewModelFactory()
init {
setState {
copy(
homeserver = matrix.getHomeserverOrDefault()
)
}
}
fun setServer(server: String) {
setState {
copy(
homeserver = server,
changeServerAction = Uninitialized,
)
}
}
fun setServerSubmit() {
viewModelScope.launch {
suspend {
val state = awaitState()
matrix.setHomeserver(state.homeserver)
}.execute {
copy(changeServerAction = it)
}
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.features.login
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.architecture.createNode
import io.element.android.x.features.login.changeserver.ChangeServerNode
import io.element.android.x.features.login.root.LoginRootNode
import kotlinx.parcelize.Parcelize
class LoginFlowNode(
buildContext: BuildContext,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<LoginFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
) {
private val loginRootCallback = object : LoginRootNode.Callback {
override fun onChangeHomeServer() {
backstack.push(NavTarget.ChangeServer)
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
@Parcelize
object ChangeServer : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> createNode<LoginRootNode>(buildContext, plugins = listOf(loginRootCallback))
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
}
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View File

@@ -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.x.features.login.changeserver
sealed interface ChangeServerEvents {
data class SetServer(val server: String) : ChangeServerEvents
object Submit : ChangeServerEvents
}

View File

@@ -0,0 +1,53 @@
/*
* 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.x.features.login.changeserver
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesNode
import io.element.android.x.architecture.presenterConnector
import io.element.android.x.di.AppScope
@ContributesNode(AppScope::class)
class ChangeServerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ChangeServerPresenter,
) : Node(buildContext, plugins = plugins) {
private val presenterConnector = presenterConnector(presenter)
private fun onSuccess() {
navigateUp()
}
@Composable
override fun View(modifier: Modifier) {
val state by presenterConnector.stateFlow.collectAsState()
ChangeServerView(
state = state,
onChangeServerSuccess = this::onSuccess,
)
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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.x.features.login.changeserver
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Async
import io.element.android.x.architecture.Presenter
import io.element.android.x.architecture.execute
import io.element.android.x.matrix.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class ChangeServerPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<ChangeServerState> {
@Composable
override fun present(): ChangeServerState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(authenticationService.getHomeserverOrDefault())
}
val changeServerAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
}
fun handleEvents(event: ChangeServerEvents) {
when (event) {
is ChangeServerEvents.SetServer -> homeserver.value = event.server
ChangeServerEvents.Submit -> localCoroutineScope.submit(homeserver.value, changeServerAction)
}
}
return ChangeServerState(
homeserver = homeserver.value,
changeServerAction = changeServerAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(homeserver: String, changeServerAction: MutableState<Async<Unit>>) = launch {
suspend {
authenticationService.setHomeserver(homeserver)
}.execute(changeServerAction)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -16,14 +16,12 @@
package io.element.android.x.features.login.changeserver
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import io.element.android.x.architecture.Async
data class ChangeServerViewState(
data class ChangeServerState(
val homeserver: String = "",
val changeServerAction: Async<Unit> = Uninitialized,
) : MavericksState {
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading
val changeServerAction: Async<Unit> = Async.Uninitialized,
val eventSink: (ChangeServerEvents) -> Unit = {},
) {
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading
}

View File

@@ -42,6 +42,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@@ -51,42 +52,23 @@ 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.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.architecture.Async
import io.element.android.x.core.compose.textFieldState
import io.element.android.x.designsystem.components.VectorIcon
import io.element.android.x.features.login.R
import io.element.android.x.features.login.error.changeServerError
@Composable
fun ChangeServerScreen(
viewModel: ChangeServerViewModel = mavericksViewModel(),
onChangeServerSuccess: () -> Unit = { }
) {
val state: ChangeServerViewState by viewModel.collectAsState()
ChangeServerContent(
state = state,
onChangeServer = viewModel::setServer,
onChangeServerSubmit = viewModel::setServerSubmit,
onChangeServerSuccess = onChangeServerSuccess
)
}
@Composable
fun ChangeServerContent(
state: ChangeServerViewState,
fun ChangeServerView(
state: ChangeServerState,
modifier: Modifier = Modifier,
onChangeServer: (String) -> Unit = {},
onChangeServerSubmit: () -> Unit = {},
onChangeServerSuccess: () -> Unit = {},
) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
) {
val eventSink = state.eventSink
val scrollState = rememberScrollState()
Box(
modifier = Modifier
@@ -101,7 +83,7 @@ fun ChangeServerContent(
)
.padding(horizontal = 16.dp)
) {
val isError = state.changeServerAction is Fail
val isError = state.changeServerAction is Async.Failure
Box(
modifier = Modifier
.padding(top = 99.dp)
@@ -142,12 +124,16 @@ fun ChangeServerContent(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary
)
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
OutlinedTextField(
value = state.homeserver,
value = homeserverFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 200.dp),
onValueChange = onChangeServer,
onValueChange = {
homeserverFieldState = it
eventSink(ChangeServerEvents.SetServer(it))
},
label = {
Text(text = "Server")
},
@@ -157,10 +143,10 @@ fun ChangeServerContent(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onChangeServerSubmit() }
onDone = { eventSink(ChangeServerEvents.Submit) }
)
)
if (state.changeServerAction is Fail) {
if (state.changeServerAction is Async.Failure) {
Text(
text = changeServerError(
state.homeserver,
@@ -172,7 +158,7 @@ fun ChangeServerContent(
)
}
Button(
onClick = onChangeServerSubmit,
onClick = { eventSink(ChangeServerEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
@@ -180,11 +166,11 @@ fun ChangeServerContent(
) {
Text(text = "Continue")
}
if (state.changeServerAction is Success) {
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
}
if (state.changeServerAction is Loading) {
if (state.changeServerAction is Async.Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
@@ -196,9 +182,7 @@ fun ChangeServerContent(
@Composable
@Preview
fun ChangeServerContentPreview() {
ElementXTheme {
ChangeServerContent(
state = ChangeServerViewState(homeserver = "matrix.org"),
)
}
ChangeServerView(
state = ChangeServerState(homeserver = "matrix.org"),
)
}

View File

@@ -19,8 +19,8 @@ package io.element.android.x.features.login.error
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.x.core.uri.isValidUrl
import io.element.android.x.element.resources.R as ElementR
import io.element.android.x.features.login.LoginFormState
import io.element.android.x.features.login.root.LoginFormState
import io.element.android.x.ui.strings.R as StringR
@Composable
fun loginError(
@@ -30,7 +30,7 @@ fun loginError(
return when {
data.login.isEmpty() -> "Please enter a login"
data.password.isEmpty() -> "Please enter a password"
throwable != null -> stringResource(id = ElementR.string.auth_invalid_login_param)
throwable != null -> stringResource(id = StringR.string.auth_invalid_login_param)
else -> "No error provided"
}
}
@@ -42,7 +42,7 @@ fun changeServerError(
): String {
return when {
data.isEmpty() -> "Please enter a server URL"
!data.isValidUrl() -> stringResource(id = ElementR.string.login_error_invalid_home_server)
!data.isValidUrl() -> stringResource(id = StringR.string.login_error_invalid_home_server)
throwable != null -> "That server doesnt seem right. Please check the address."
else -> "No error provided"
}

View File

@@ -0,0 +1,24 @@
/*
* 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.x.features.login.root
sealed interface LoginRootEvents {
object RefreshHomeServer : LoginRootEvents
data class SetLogin(val login: String) : LoginRootEvents
data class SetPassword(val password: String) : LoginRootEvents
object Submit : LoginRootEvents
}

View File

@@ -0,0 +1,66 @@
/*
* 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.x.features.login.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesNode
import io.element.android.x.architecture.presenterConnector
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.di.AppScope
@ContributesNode(AppScope::class)
class LoginRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LoginRootPresenter,
) : Node(buildContext, plugins = plugins) {
private val presenterConnector = presenterConnector(presenter)
interface Callback : Plugin {
fun onChangeHomeServer()
}
private fun onChangeHomeServer() {
plugins<Callback>().forEach { it.onChangeHomeServer() }
}
@Composable
override fun View(modifier: Modifier) {
val state by presenterConnector.stateFlow.collectAsState()
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> state.eventSink(LoginRootEvents.RefreshHomeServer)
else -> Unit
}
}
LoginRootScreen(
state = state,
onChangeServer = this::onChangeHomeServer,
)
}
}

View File

@@ -0,0 +1,85 @@
/*
* 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.x.features.login.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.matrix.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
@Composable
override fun present(): LoginRootState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(authenticationService.getHomeserverOrDefault())
}
val loggedInState: MutableState<LoggedInState> = remember {
mutableStateOf(LoggedInState.NotLoggedIn)
}
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
}
fun handleEvents(event: LoginRootEvents) {
when (event) {
LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver)
is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.value, formState.value, loggedInState)
}
}
return LoginRootState(
homeserver = homeserver.value,
loggedInState = loggedInState.value,
formState = formState.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
try {
authenticationService.setHomeserver(homeserver)
val sessionId = authenticationService.login(formState.login.trim(), formState.password.trim())
loggedInState.value = LoggedInState.LoggedIn(sessionId)
} catch (failure: Throwable) {
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
formState.value = updateLambda(formState.value)
}
private fun refreshHomeServer(homeserver: MutableState<String>) {
homeserver.value = authenticationService.getHomeserverOrDefault()
}
}

View File

@@ -16,7 +16,7 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.x.features.login
package io.element.android.x.features.login.root
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -42,13 +42,13 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -58,51 +58,20 @@ 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.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.core.compose.textFieldState
import io.element.android.x.features.login.error.loginError
import io.element.android.x.matrix.MatrixClient
import timber.log.Timber
@Composable
fun LoginScreen(
viewModel: LoginViewModel = mavericksViewModel(),
onChangeServer: () -> Unit = { },
onLoginWithSuccess: (MatrixClient) -> Unit = { },
) {
val state: LoginViewState by viewModel.collectAsState()
val formState: LoginFormState by viewModel.formState
LaunchedEffect(key1 = Unit) {
Timber.d("resume")
viewModel.onResume()
}
LoginContent(
state = state,
formState = formState,
onChangeServer = onChangeServer,
onLoginChanged = viewModel::onSetName,
onPasswordChanged = viewModel::onSetPassword,
onSubmitClicked = viewModel::onSubmit,
onLoginWithSuccess = onLoginWithSuccess
)
}
import io.element.android.x.matrix.core.SessionId
import io.element.android.x.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginContent(
state: LoginViewState,
formState: LoginFormState,
fun LoginRootScreen(
state: LoginRootState,
modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {},
onLoginChanged: (String) -> Unit = {},
onPasswordChanged: (String) -> Unit = {},
onSubmitClicked: () -> Unit = {},
onLoginWithSuccess: (MatrixClient) -> Unit = {},
onLoginWithSuccess: (SessionId) -> Unit = {},
) {
val eventSink = state.eventSink
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
@@ -114,6 +83,9 @@ fun LoginContent(
.imePadding()
) {
val scrollState = rememberScrollState()
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
Column(
modifier = Modifier
.verticalScroll(
@@ -121,10 +93,10 @@ fun LoginContent(
)
.padding(horizontal = 16.dp),
) {
val isError = state.loggedInClient is Fail
val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
// Title
Text(
text = "Welcome back",
text = stringResource(id = StringR.string.ftue_auth_welcome_back_title),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 48.dp),
@@ -162,30 +134,36 @@ fun LoginContent(
)
}
OutlinedTextField(
value = formState.login,
value = loginFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp),
label = {
Text(text = "Email or username")
Text(text = stringResource(id = StringR.string.login_signin_username_hint))
},
onValueChange = {
loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it))
},
onValueChange = onLoginChanged,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
)
var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInClient is Loading) {
if (state.loggedInState is LoggedInState.LoggingIn) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
OutlinedTextField(
value = formState.password,
value = passwordFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
onValueChange = onPasswordChanged,
onValueChange = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))
},
label = {
Text(text = "Password")
},
@@ -206,12 +184,12 @@ fun LoginContent(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onSubmitClicked() }
onDone = { eventSink(LoginRootEvents.Submit) }
),
)
if (state.loggedInClient is Fail) {
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
Text(
text = loginError(state.formState, state.loggedInClient.error),
text = loginError(state.formState, state.loggedInState.failure),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
@@ -220,7 +198,7 @@ fun LoginContent(
}
// Submit
Button(
onClick = onSubmitClicked,
onClick = { eventSink(LoginRootEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
@@ -228,12 +206,12 @@ fun LoginContent(
) {
Text(text = "Continue")
}
when (val loggedInClient = state.loggedInClient) {
is Success -> onLoginWithSuccess(loggedInClient())
when (val loggedInState = state.loggedInState) {
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
else -> Unit
}
}
if (state.loggedInClient is Loading) {
if (state.loggedInState is LoggedInState.LoggingIn) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
@@ -245,12 +223,9 @@ fun LoginContent(
@Composable
@Preview
fun LoginContentPreview() {
ElementXTheme(darkTheme = false) {
LoginContent(
state = LoginViewState(
homeserver = "matrix.org",
),
formState = LoginFormState("", "")
)
}
LoginRootScreen(
state = LoginRootState(
homeserver = "matrix.org",
),
)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -14,27 +14,34 @@
* limitations under the License.
*/
package io.element.android.x.features.login
package io.element.android.x.features.login.root
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import io.element.android.x.matrix.MatrixClient
import android.os.Parcelable
import io.element.android.x.matrix.core.SessionId
import kotlinx.parcelize.Parcelize
data class LoginViewState(
data class LoginRootState(
val homeserver: String = "",
val loggedInClient: Async<MatrixClient> = Uninitialized,
val loggedInState: LoggedInState = LoggedInState.NotLoggedIn,
val formState: LoginFormState = LoginFormState.Default,
) : MavericksState {
val eventSink: (LoginRootEvents) -> Unit = {}
) {
val submitEnabled =
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInClient !is Loading
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState != LoggedInState.LoggingIn
}
sealed interface LoggedInState {
object NotLoggedIn : LoggedInState
object LoggingIn : LoggedInState
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
data class LoggedIn(val sessionId: SessionId) : LoggedInState
}
@Parcelize
data class LoginFormState(
val login: String,
val password: String
) {
) : Parcelable {
companion object {
val Default = LoginFormState("", "")

View File

@@ -34,11 +34,12 @@ dependencies {
implementation(project(":anvilannotations"))
anvil(project(":anvilcodegen"))
implementation(project(":libraries:di"))
implementation(project(":libraries:architecture"))
implementation(project(":libraries:core"))
implementation(project(":libraries:matrix"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:elementresources"))
implementation(libs.mavericks.compose)
implementation(project(":libraries:ui-strings"))
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
androidTestImplementation(libs.test.junitext)

View File

@@ -1,46 +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.
*/
package io.element.android.x.features.logout
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.di.SessionScope
import io.element.android.x.matrix.MatrixClient
import kotlinx.coroutines.launch
@ContributesViewModel(SessionScope::class)
class LogoutViewModel @AssistedInject constructor(
private val client: MatrixClient,
@Assisted initialState: LogoutViewState
) : MavericksViewModel<LogoutViewState>(initialState) {
companion object : MavericksViewModelFactory<LogoutViewModel, LogoutViewState> by daggerMavericksViewModelFactory()
fun logout() {
viewModelScope.launch {
suspend {
client.logout()
}.execute {
copy(logoutAction = it)
}
}
}
}

View File

@@ -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.x.features.logout
sealed interface LogoutPreferenceEvents {
object Logout : LogoutPreferenceEvents
}

View File

@@ -0,0 +1,58 @@
/*
* 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.x.features.logout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.x.architecture.Async
import io.element.android.x.architecture.Presenter
import io.element.android.x.architecture.execute
import io.element.android.x.matrix.MatrixClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) : Presenter<LogoutPreferenceState> {
@Composable
override fun present(): LogoutPreferenceState {
val localCoroutineScope = rememberCoroutineScope()
val logoutAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
}
fun handleEvents(event: LogoutPreferenceEvents) {
when (event) {
LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction)
}
}
return LogoutPreferenceState(
logoutAction = logoutAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch {
suspend {
matrixClient.logout()
}.execute(logoutAction)
}
}

View File

@@ -19,33 +19,30 @@ package io.element.android.x.features.logout
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Logout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.architecture.Async
import io.element.android.x.designsystem.components.ProgressDialog
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.x.designsystem.components.preferences.PreferenceCategory
import io.element.android.x.designsystem.components.preferences.PreferenceText
import io.element.android.x.element.resources.R as ElementR
import io.element.android.x.ui.strings.R as StringR
@Composable
fun LogoutPreference(
viewModel: LogoutViewModel = mavericksViewModel(),
onSuccessLogout: () -> Unit = { },
fun LogoutPreferenceView(
state: LogoutPreferenceState,
onSuccessLogout: () -> Unit = {}
) {
val state: LogoutViewState by viewModel.collectAsState()
if (state.logoutAction is Success) {
onSuccessLogout()
val eventSink = state.eventSink
if (state.logoutAction is Async.Success) {
LaunchedEffect(state.logoutAction) {
onSuccessLogout()
}
return
}
val openDialog = remember { mutableStateOf(false) }
LogoutPreferenceContent(
@@ -57,15 +54,15 @@ fun LogoutPreference(
// Log out confirmation dialog
if (openDialog.value) {
ConfirmationDialog(
title = stringResource(id = ElementR.string.action_sign_out),
content = stringResource(id = ElementR.string.action_sign_out_confirmation_simple),
submitText = stringResource(id = ElementR.string.action_sign_out),
title = stringResource(id = StringR.string.action_sign_out),
content = stringResource(id = StringR.string.action_sign_out_confirmation_simple),
submitText = stringResource(id = StringR.string.action_sign_out),
onCancelClicked = {
openDialog.value = false
},
onSubmitClicked = {
openDialog.value = false
viewModel.logout()
eventSink(LogoutPreferenceEvents.Logout)
},
onDismiss = {
openDialog.value = false
@@ -73,7 +70,7 @@ fun LogoutPreference(
)
}
if (state.logoutAction is Loading) {
if (state.logoutAction is Async.Loading) {
ProgressDialog(text = "Login out...")
}
}
@@ -82,9 +79,9 @@ fun LogoutPreference(
fun LogoutPreferenceContent(
onClick: () -> Unit = {},
) {
PreferenceCategory(title = stringResource(id = ElementR.string.settings_general_title)) {
PreferenceCategory(title = stringResource(id = StringR.string.settings_general_title)) {
PreferenceText(
title = stringResource(id = ElementR.string.action_sign_out),
title = stringResource(id = StringR.string.action_sign_out),
icon = Icons.Default.Logout,
onClick = onClick
)
@@ -94,7 +91,5 @@ fun LogoutPreferenceContent(
@Composable
@Preview
fun LogoutContentPreview() {
ElementXTheme(darkTheme = false) {
LogoutPreference()
}
LogoutPreferenceView(LogoutPreferenceState())
}

View File

@@ -16,10 +16,9 @@
package io.element.android.x.features.logout
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import io.element.android.x.architecture.Async
data class LogoutViewState(
val logoutAction: Async<Unit> = Uninitialized,
) : MavericksState
data class LogoutPreferenceState(
val logoutAction: Async<Unit> = Async.Uninitialized,
val eventSink: (LogoutPreferenceEvents) -> Unit = {},
)

View File

@@ -35,11 +35,12 @@ dependencies {
anvil(project(":anvilcodegen"))
implementation(project(":libraries:di"))
implementation(project(":libraries:core"))
implementation(project(":libraries:architecture"))
implementation(project(":libraries:matrix"))
implementation(project(":libraries:matrixui"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:textcomposer"))
implementation(libs.mavericks.compose)
implementation(libs.appyx.core)
implementation(libs.coil.compose)
implementation(libs.datetime)
implementation(libs.accompanist.flowlayout)

View File

@@ -1,689 +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.
*/
@file:OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class,
ExperimentalComposeUiApi::class
)
package io.element.android.x.features.messages
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Alignment.Companion.Start
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.compose.LogCompositions
import io.element.android.x.core.compose.PairCombinedPreviewParameter
import io.element.android.x.core.data.StableCharSequence
import io.element.android.x.designsystem.components.avatar.Avatar
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.messages.components.MessageEventBubble
import io.element.android.x.features.messages.components.MessagesReactionsView
import io.element.android.x.features.messages.components.MessagesTimelineItemEncryptedView
import io.element.android.x.features.messages.components.MessagesTimelineItemImageView
import io.element.android.x.features.messages.components.MessagesTimelineItemRedactedView
import io.element.android.x.features.messages.components.MessagesTimelineItemTextView
import io.element.android.x.features.messages.components.MessagesTimelineItemUnknownView
import io.element.android.x.features.messages.components.TimelineItemActionsScreen
import io.element.android.x.features.messages.model.AggregatedReaction
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.model.MessagesItemGroupPositionProvider
import io.element.android.x.features.messages.model.MessagesItemReactionState
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.MessagesViewState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContentProvider
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel
import io.element.android.x.features.messages.textcomposer.MessageComposerViewState
import io.element.android.x.textcomposer.MessageComposerMode
import io.element.android.x.textcomposer.TextComposer
import java.lang.Math.random
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun MessagesScreen(
roomId: String,
onBackPressed: () -> Unit,
viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId }),
composerViewModel: MessageComposerViewModel = mavericksViewModel(argsFactory = { roomId })
) {
fun onSendMessage(textMessage: String) {
viewModel.sendMessage(textMessage)
composerViewModel.updateText("")
}
LogCompositions(tag = "MessagesScreen", msg = "Root")
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val roomTitle by viewModel.collectAsState(MessagesViewState::roomName)
val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar)
val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems)
val hasMoreToLoad by viewModel.collectAsState(MessagesViewState::hasMoreToLoad)
val snackBarContent by viewModel.collectAsState(MessagesViewState::snackbarContent)
val composerMode by viewModel.collectAsState(MessagesViewState::composerMode)
val highlightedEventId by viewModel.collectAsState(MessagesViewState::highlightedEventId)
val composerFullScreen by composerViewModel.collectAsState(MessageComposerViewState::isFullScreen)
val composerCanSendMessage by composerViewModel.collectAsState(MessageComposerViewState::isSendButtonVisible)
val composerText by composerViewModel.collectAsState(MessageComposerViewState::text)
MessagesScreenContent(
roomTitle = roomTitle,
roomAvatar = roomAvatar,
timelineItems = timelineItems().orEmpty().toImmutableList(),
hasMoreToLoad = hasMoreToLoad,
onReachedLoadMore = viewModel::loadMore,
onBackPressed = onBackPressed,
onSendMessage = ::onSendMessage,
composerFullScreen = composerFullScreen,
onComposerFullScreenChange = composerViewModel::onComposerFullScreenChange,
onComposerTextChange = composerViewModel::updateText,
composerMode = composerMode,
highlightedEventId = highlightedEventId,
onCloseSpecialMode = viewModel::setNormalMode,
composerCanSendMessage = composerCanSendMessage,
composerText = composerText,
onClick = {
Timber.v("onClick on timeline item: ${it.id}")
},
onLongClick = {
focusManager.clearFocus(force = true)
viewModel.computeActionsSheetState(it)
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
},
snackbarHostState = snackbarHostState,
)
TimelineItemActionsScreen(
viewModel = viewModel,
composerViewModel = composerViewModel,
modalBottomSheetState = itemActionsBottomSheetState,
)
snackBarContent?.let {
coroutineScope.launch {
snackbarHostState.showSnackbar(it)
}
viewModel.onSnackbarShown()
}
}
@Composable
fun MessagesScreenContent(
roomTitle: String?,
roomAvatar: AvatarData?,
timelineItems: ImmutableList<MessagesTimelineItemState>,
hasMoreToLoad: Boolean,
onReachedLoadMore: () -> Unit,
onBackPressed: () -> Unit,
onSendMessage: (String) -> Unit,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
composerFullScreen: Boolean,
onComposerFullScreenChange: () -> Unit,
onComposerTextChange: (CharSequence) -> Unit,
composerMode: MessageComposerMode,
highlightedEventId: String?,
onCloseSpecialMode: () -> Unit,
composerCanSendMessage: Boolean,
composerText: StableCharSequence?,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Content")
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = {
MessagesTopAppBar(
roomTitle = roomTitle,
roomAvatar = roomAvatar,
onBackPressed = onBackPressed
)
},
content = { padding ->
MessagesContent(
modifier = Modifier.padding(padding),
timelineItems = timelineItems,
hasMoreToLoad = hasMoreToLoad,
onReachedLoadMore = onReachedLoadMore,
onSendMessage = onSendMessage,
onClick = onClick,
onLongClick = onLongClick,
highlightedEventId = highlightedEventId,
composerMode = composerMode,
onCloseSpecialMode = onCloseSpecialMode,
composerFullScreen = composerFullScreen,
onComposerFullScreenChange = onComposerFullScreenChange,
onComposerTextChange = onComposerTextChange,
composerCanSendMessage = composerCanSendMessage,
composerText = composerText
)
},
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
},
)
}
@Composable
fun MessagesContent(
timelineItems: ImmutableList<MessagesTimelineItemState>,
hasMoreToLoad: Boolean,
onReachedLoadMore: () -> Unit,
onSendMessage: (String) -> Unit,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
composerMode: MessageComposerMode,
highlightedEventId: String?,
onCloseSpecialMode: () -> Unit,
composerFullScreen: Boolean,
onComposerFullScreenChange: () -> Unit,
onComposerTextChange: (CharSequence) -> Unit,
composerCanSendMessage: Boolean,
composerText: StableCharSequence?,
modifier: Modifier = Modifier
) {
val lazyListState = rememberLazyListState()
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding()
) {
if (!composerFullScreen) {
TimelineItems(
lazyListState = lazyListState,
timelineItems = timelineItems,
highlightedEventId = highlightedEventId,
hasMoreToLoad = hasMoreToLoad,
onReachedLoadMore = onReachedLoadMore,
modifier = Modifier.weight(1f),
onClick = onClick,
onLongClick = onLongClick
)
}
TextComposer(
onSendMessage = onSendMessage,
fullscreen = composerFullScreen,
onFullscreenToggle = onComposerFullScreenChange,
composerMode = composerMode,
onCloseSpecialMode = onCloseSpecialMode,
onComposerTextChange = onComposerTextChange,
composerCanSendMessage = composerCanSendMessage,
composerText = composerText?.charSequence?.toString(),
modifier = Modifier
.fillMaxWidth()
.let {
if (composerFullScreen) {
it.weight(1f, fill = false)
} else {
it.wrapContentHeight(Alignment.Bottom)
}
},
)
}
}
@Composable
fun MessagesTopAppBar(
roomTitle: String?,
roomAvatar: AvatarData?,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (roomAvatar != null) {
Avatar(roomAvatar)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = roomTitle ?: "Unknown room",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}
@Composable
fun TimelineItems(
lazyListState: LazyListState,
timelineItems: ImmutableList<MessagesTimelineItemState>,
highlightedEventId: String?,
modifier: Modifier = Modifier,
hasMoreToLoad: Boolean = false,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit = {},
onReachedLoadMore: () -> Unit = {},
) {
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Bottom,
reverseLayout = true
) {
items(
items = timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.key() },
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
isHighlighted = timelineItem.key() == highlightedEventId,
onClick = onClick,
onLongClick = onLongClick
)
}
if (hasMoreToLoad) {
item {
MessagesLoadingMoreIndicator()
}
}
}
MessagesScrollHelper(
lazyListState = lazyListState,
timelineItems = timelineItems,
onLoadMore = onReachedLoadMore
)
}
}
private fun MessagesTimelineItemState.key(): String {
return when (this) {
is MessagesTimelineItemState.MessageEvent -> id
is MessagesTimelineItemState.Virtual -> id
}
}
private fun MessagesTimelineItemState.contentType(): Int {
return when (this) {
is MessagesTimelineItemState.MessageEvent -> 0
is MessagesTimelineItemState.Virtual -> 1
}
}
@Composable
fun TimelineItemRow(
timelineItem: MessagesTimelineItemState,
isHighlighted: Boolean,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
) {
when (timelineItem) {
is MessagesTimelineItemState.Virtual -> return
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(
messageEvent = timelineItem,
isHighlighted = isHighlighted,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }
)
}
}
@Composable
fun MessageEventRow(
messageEvent: MessagesTimelineItemState.MessageEvent,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
Pair(Alignment.CenterEnd, End)
} else {
Pair(Alignment.CenterStart, Start)
}
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = parentAlignment
) {
Row {
if (!messageEvent.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
Column(horizontalAlignment = contentAlignment) {
if (messageEvent.showSenderInformation) {
MessageSenderInformation(
messageEvent.safeSenderName,
messageEvent.senderAvatar,
Modifier.zIndex(1f)
)
}
MessageEventBubble(
groupPosition = messageEvent.groupPosition,
isMine = messageEvent.isMine,
interactionSource = interactionSource,
isHighlighted = isHighlighted,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
when (messageEvent.content) {
is MessagesTimelineItemEncryptedContent -> MessagesTimelineItemEncryptedView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemRedactedContent -> MessagesTimelineItemRedactedView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
content = messageEvent.content,
interactionSource = interactionSource,
modifier = contentModifier,
onTextClicked = onClick,
onTextLongClicked = onLongClick
)
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemImageContent -> MessagesTimelineItemImageView(
content = messageEvent.content,
modifier = contentModifier
)
}
}
MessagesReactionsView(
reactionsState = messageEvent.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp))
)
}
if (messageEvent.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
if (messageEvent.groupPosition.isNew()) {
Spacer(modifier = modifier.height(8.dp))
} else {
Spacer(modifier = modifier.height(2.dp))
}
}
@Composable
private fun MessageSenderInformation(
sender: String,
senderAvatar: AvatarData?,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (senderAvatar != null) {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = sender,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.alignBy(LastBaseline)
)
}
}
@Composable
internal fun BoxScope.MessagesScrollHelper(
lazyListState: LazyListState,
timelineItems: ImmutableList<MessagesTimelineItemState>,
onLoadMore: () -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
// Auto-scroll when new timeline items appear
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
if (!lazyListState.isScrollInProgress &&
firstVisibleItemIndex < 2
) coroutineScope.launch {
lazyListState.animateScrollToItem(0)
}
}
// Handle load more preloading
val loadMore by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - 30)
}
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
// Jump to bottom button
if (firstVisibleItemIndex > 2) {
FloatingActionButton(
onClick = {
coroutineScope.launch {
if (firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
},
shape = CircleShape,
modifier = Modifier
.align(Alignment.BottomCenter)
.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Icon(Icons.Default.ArrowDownward, "")
}
}
}
@Composable
internal fun MessagesLoadingMoreIndicator() {
Box(
Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
PairCombinedPreviewParameter<MessagesItemGroupPosition, MessagesTimelineItemContent>(
MessagesItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
)
@Suppress("PreviewPublic")
@Preview(showBackground = true)
@Composable
fun TimelineItemsPreview(
@PreviewParameter(MessagesTimelineItemContentProvider::class)
content: MessagesTimelineItemContent
) {
TimelineItems(
lazyListState = LazyListState(),
timelineItems = persistentListOf(
// 3 items (First Middle Last) with isMine = false
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.First
),
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.Middle
),
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.Last
),
// 3 items (First Middle Last) with isMine = true
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.First
),
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.Middle
),
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.Last
),
),
highlightedEventId = null,
hasMoreToLoad = true,
)
}
private fun createMessageEvent(
isMine: Boolean,
content: MessagesTimelineItemContent,
groupPosition: MessagesItemGroupPosition
): MessagesTimelineItemState {
return MessagesTimelineItemState.MessageEvent(
id = random().toString(),
senderId = "senderId",
senderAvatar = AvatarData("sender"),
content = content,
reactionsState = MessagesItemReactionState(
listOf(
AggregatedReaction("👍", "1")
)
),
isMine = isMine,
senderDisplayName = "sender",
groupPosition = groupPosition,
)
}

View File

@@ -1,228 +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.
*/
package io.element.android.x.features.messages
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Uninitialized
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.di.SessionScope
import io.element.android.x.features.messages.model.MessagesItemAction
import io.element.android.x.features.messages.model.MessagesItemActionsSheetState
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.MessagesViewState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.timeline.MatrixTimeline
import io.element.android.x.matrix.timeline.MatrixTimelineItem
import io.element.android.x.matrix.ui.MatrixItemHelper
import io.element.android.x.textcomposer.MessageComposerMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
private const val PAGINATION_COUNT = 50
@ContributesViewModel(SessionScope::class)
class MessagesViewModel @AssistedInject constructor(
private val client: MatrixClient,
@Assisted private val initialState: MessagesViewState
) :
MavericksViewModel<MessagesViewState>(initialState) {
companion object : MavericksViewModelFactory<MessagesViewModel, MessagesViewState> by daggerMavericksViewModelFactory()
private val matrixItemHelper = MatrixItemHelper(client)
private val room = client.getRoom(initialState.roomId)!!
private val messageTimelineItemStateFactory =
MessageTimelineItemStateFactory(matrixItemHelper, room, Dispatchers.Default)
private val timeline = room.timeline()
private val timelineCallback = object : MatrixTimeline.Callback {
override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) {
viewModelScope.launch {
messageTimelineItemStateFactory.pushItem(timelineItem)
}
}
}
init {
handleInit()
}
fun loadMore() {
viewModelScope.launch {
timeline.paginateBackwards(PAGINATION_COUNT)
setState { copy(hasMoreToLoad = timeline.hasMoreToLoad) }
}
}
fun sendMessage(text: String) {
viewModelScope.launch {
val state = awaitState()
// Reset composer right away
setNormalMode()
when (state.composerMode) {
is MessageComposerMode.Normal -> timeline.sendMessage(text)
is MessageComposerMode.Edit -> timeline.editMessage(
state.composerMode.eventId,
text
)
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> timeline.replyMessage(
state.composerMode.eventId,
text
)
}
}
}
suspend fun getTargetEvent(): MessagesTimelineItemState.MessageEvent? {
val currentState = awaitState()
return currentState.itemActionsSheetState.invoke()?.targetItem
}
fun handleItemAction(
action: MessagesItemAction,
targetEvent: MessagesTimelineItemState.MessageEvent
) {
viewModelScope.launch(Dispatchers.Default) {
when (action) {
MessagesItemAction.Copy -> notImplementedYet()
MessagesItemAction.Forward -> notImplementedYet()
MessagesItemAction.Redact -> handleActionRedact(targetEvent)
MessagesItemAction.Edit -> handleActionEdit(targetEvent)
MessagesItemAction.Reply -> handleActionReply(targetEvent)
}
}
}
fun setNormalMode() {
setComposerMode(MessageComposerMode.Normal(""))
}
fun onSnackbarShown() {
setSnackbarContent(null)
}
fun computeActionsSheetState(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent?) {
if (messagesTimelineItemState == null) {
setState { copy(itemActionsSheetState = Uninitialized) }
return
}
suspend {
val actions =
if (messagesTimelineItemState.content is MessagesTimelineItemRedactedContent) {
emptyList()
} else {
mutableListOf(
MessagesItemAction.Reply,
MessagesItemAction.Forward,
MessagesItemAction.Copy,
).also {
if (messagesTimelineItemState.isMine) {
it.add(MessagesItemAction.Edit)
it.add(MessagesItemAction.Redact)
}
}
}
MessagesItemActionsSheetState(
targetItem = messagesTimelineItemState,
actions = actions
)
}.execute(Dispatchers.Default) {
copy(itemActionsSheetState = it)
}
}
private fun handleInit() {
timeline.initialize()
timeline.callback = timelineCallback
room.syncUpdateFlow()
.onEach {
val avatarData =
matrixItemHelper.loadAvatarData(
room = room,
size = AvatarSize.SMALL
)
setState {
copy(
roomName = room.name, roomAvatar = avatarData,
)
}
}.launchIn(viewModelScope)
timeline
.timelineItems()
.onEach(messageTimelineItemStateFactory::replaceWith)
.launchIn(viewModelScope)
messageTimelineItemStateFactory
.flow()
.execute {
copy(timelineItems = it)
}
}
private fun setSnackbarContent(message: String?) {
setState { copy(snackbarContent = message) }
}
private fun handleActionRedact(event: MessagesTimelineItemState.MessageEvent) {
viewModelScope.launch {
room.redactEvent(event.id)
}
}
private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent) {
setComposerMode(
MessageComposerMode.Edit(
targetEvent.id,
(targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
)
)
}
private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent) {
setComposerMode(MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, ""))
}
private fun setComposerMode(mode: MessageComposerMode) {
setState {
copy(
composerMode = mode,
highlightedEventId = mode.relatedEventId
)
}
}
private fun notImplementedYet() {
setSnackbarContent("Not implemented yet!")
}
override fun onCleared() {
super.onCleared()
timeline.callback = null
timeline.dispose()
}
}

View File

@@ -1,145 +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.
*/
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.x.features.messages.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.airbnb.mvrx.compose.collectAsState
import io.element.android.x.designsystem.components.VectorIcon
import io.element.android.x.features.messages.MessagesViewModel
import io.element.android.x.features.messages.model.MessagesItemAction
import io.element.android.x.features.messages.model.MessagesItemActionsSheetState
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.MessagesViewState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@Composable
fun TimelineItemActionsScreen(
viewModel: MessagesViewModel,
composerViewModel: MessageComposerViewModel,
modalBottomSheetState: ModalBottomSheetState,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(modalBottomSheetState) {
snapshotFlow { modalBottomSheetState.currentValue }
.filter { it == ModalBottomSheetValue.Hidden }
.collect {
viewModel.computeActionsSheetState(null)
}
}
val itemActionsSheetState by viewModel.collectAsState(MessagesViewState::itemActionsSheetState)
fun onItemActionClicked(
itemAction: MessagesItemAction,
targetItem: MessagesTimelineItemState.MessageEvent
) {
viewModel.handleItemAction(itemAction, targetItem)
coroutineScope.launch {
val targetEvent = viewModel.getTargetEvent()
when (itemAction) {
is MessagesItemAction.Edit -> {
// Entering Edit mode, update the text in the composer.
val newComposerText =
(targetEvent?.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
composerViewModel.updateText(newComposerText)
}
else -> Unit
}
modalBottomSheetState.hide()
}
}
ModalBottomSheetLayout(
modifier = modifier,
sheetState = modalBottomSheetState,
sheetContent = {
SheetContent(
actionsSheetState = itemActionsSheetState(),
onActionClicked = ::onItemActionClicked,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
)
}
) {}
}
@Composable
private fun SheetContent(
actionsSheetState: MessagesItemActionsSheetState?,
modifier: Modifier = Modifier,
onActionClicked: (MessagesItemAction, MessagesTimelineItemState.MessageEvent) -> Unit = { _, _ -> },
) {
if (actionsSheetState == null || actionsSheetState.actions.isEmpty()) {
// Crashes if sheetContent size is zero
Box(modifier = modifier.size(1.dp))
} else {
LazyColumn(
modifier = modifier
.fillMaxWidth()
) {
items(actionsSheetState.actions) {
ListItem(
modifier = Modifier.clickable {
onActionClicked(it, actionsSheetState.targetItem)
},
text = {
Text(
text = it.title,
color = if (it.destructive) MaterialTheme.colors.error else Color.Unspecified,
)
},
icon = {
VectorIcon(
resourceId = it.icon,
tint = if (it.destructive) MaterialTheme.colors.error else LocalContentColor.current,
)
}
)
}
}
}
}

View File

@@ -1,45 +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.
*/
package io.element.android.x.features.messages.model
import androidx.compose.runtime.Stable
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.textcomposer.MessageComposerMode
@Stable
data class MessagesViewState(
val roomId: String,
val roomName: String? = null,
val roomAvatar: AvatarData? = null,
val timelineItems: Async<List<MessagesTimelineItemState>> = Uninitialized,
val hasMoreToLoad: Boolean = true,
val itemActionsSheetState: Async<MessagesItemActionsSheetState> = Uninitialized,
val snackbarContent: String? = null,
val highlightedEventId: String? = null,
val composerMode: MessageComposerMode = MessageComposerMode.Normal(""),
) : MavericksState {
@Suppress("unused")
constructor(roomId: String) : this(
roomId = roomId,
roomName = null,
roomAvatar = null
)
}

View File

@@ -1,54 +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.
*/
package io.element.android.x.features.messages.textcomposer
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.core.data.StableCharSequence
import io.element.android.x.core.di.daggerMavericksViewModelFactory
import io.element.android.x.di.SessionScope
import io.element.android.x.matrix.MatrixClient
@ContributesViewModel(SessionScope::class)
class MessageComposerViewModel @AssistedInject constructor(
private val client: MatrixClient,
@Assisted private val initialState: MessageComposerViewState
) : MavericksViewModel<MessageComposerViewState>(initialState) {
companion object :
MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> by daggerMavericksViewModelFactory()
fun onComposerFullScreenChange() {
setState {
copy(
isFullScreen = !isFullScreen
)
}
}
fun updateText(newText: CharSequence) {
setState {
copy(
text = StableCharSequence(newText),
isSendButtonVisible = newText.isNotEmpty(),
)
}
}
}

View File

@@ -1,35 +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.
*/
package io.element.android.x.features.messages.textcomposer
import androidx.compose.runtime.Stable
import com.airbnb.mvrx.MavericksState
import io.element.android.x.core.data.StableCharSequence
@Stable
data class MessageComposerViewState(
// val roomId: String,
// val canSendMessage: CanSendStatus = CanSendStatus.Allowed,
val isSendButtonVisible: Boolean = false,
val rootThreadEventId: String? = null,
val startsThread: Boolean = false,
// val sendMode: SendMode = SendMode.Regular("", false),
// val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle,
// val voiceBroadcastState: VoiceBroadcastState? = null,
val text: StableCharSequence? = null,
val isFullScreen: Boolean = false,
) : MavericksState

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -14,17 +14,11 @@
* limitations under the License.
*/
package io.element.android.x.initializer
package io.element.android.x.features.messages
import android.content.Context
import androidx.startup.Initializer
import com.airbnb.mvrx.Mavericks
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.timeline.model.TimelineItem
class MavericksInitializer : Initializer<Unit> {
override fun create(context: Context) {
Mavericks.initialize(context)
}
override fun dependencies(): List<Class<out Initializer<*>>> = listOf()
sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val messageEvent: TimelineItem.MessageEvent) : MessagesEvents
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.features.messages
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesNode
import io.element.android.x.architecture.presenterConnector
import io.element.android.x.di.RoomScope
@ContributesNode(RoomScope::class)
class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenter: MessagesPresenter,
) : Node(buildContext, plugins = plugins) {
private val connector = presenterConnector(presenter)
@Composable
override fun View(modifier: Modifier) {
val state by connector.stateFlow.collectAsState()
MessagesView(
state = state,
onBackPressed = this::navigateUp,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,137 @@
/*
* 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.x.features.messages
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.actionlist.ActionListPresenter
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.textcomposer.MessageComposerEvents
import io.element.android.x.features.messages.textcomposer.MessageComposerPresenter
import io.element.android.x.features.messages.textcomposer.MessageComposerState
import io.element.android.x.features.messages.timeline.TimelineEvents
import io.element.android.x.features.messages.timeline.TimelinePresenter
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.ui.MatrixItemHelper
import io.element.android.x.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class MessagesPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val room: MatrixRoom,
private val composerPresenter: MessageComposerPresenter,
private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter,
) : Presenter<MessagesState> {
private val matrixItemHelper = MatrixItemHelper(matrixClient)
@Composable
override fun present(): MessagesState {
val localCoroutineScope = rememberCoroutineScope()
val composerState = composerPresenter.present()
val timelineState = timelinePresenter.present()
val actionListState = actionListPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L)
val roomName: MutableState<String?> = rememberSaveable {
mutableStateOf(null)
}
val roomAvatar: MutableState<AvatarData?> = remember {
mutableStateOf(null)
}
LaunchedEffect(syncUpdateFlow) {
roomAvatar.value =
matrixItemHelper.loadAvatarData(
room = room,
size = AvatarSize.SMALL
)
roomName.value = room.name
}
LaunchedEffect(composerState.mode.relatedEventId) {
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
}
fun handleEvents(event: MessagesEvents) {
when (event) {
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState)
}
}
return MessagesState(
roomId = room.roomId,
roomName = roomName.value,
roomAvatar = roomAvatar.value,
composerState = composerState,
timelineState = timelineState,
actionListState = actionListState,
eventSink = ::handleEvents
)
}
fun CoroutineScope.handleTimelineAction(
action: TimelineItemAction,
targetEvent: TimelineItem.MessageEvent,
composerState: MessageComposerState,
) = launch {
when (action) {
TimelineItemAction.Copy -> notImplementedYet()
TimelineItemAction.Forward -> notImplementedYet()
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
}
}
private fun notImplementedYet() {
Timber.v("NotImplementedYet")
}
private suspend fun handleActionRedact(event: TimelineItem.MessageEvent) {
room.redactEvent(event.id)
}
private fun handleActionEdit(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Edit(
targetEvent.id,
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
private fun handleActionReply(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "")
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
}

View File

@@ -0,0 +1,35 @@
/*
* 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.x.features.messages
import androidx.compose.runtime.Immutable
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.messages.actionlist.ActionListState
import io.element.android.x.features.messages.textcomposer.MessageComposerState
import io.element.android.x.features.messages.timeline.TimelineState
import io.element.android.x.matrix.core.RoomId
@Immutable
data class MessagesState(
val roomId: RoomId,
val roomName: String?,
val roomAvatar: AvatarData?,
val composerState: MessageComposerState,
val timelineState: TimelineState,
val actionListState: ActionListState,
val eventSink: (MessagesEvents) -> Unit
)

View File

@@ -0,0 +1,202 @@
/*
* 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.
*/
@file:OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class,
)
package io.element.android.x.features.messages
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.x.core.compose.LogCompositions
import io.element.android.x.designsystem.components.avatar.Avatar
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.messages.actionlist.ActionListEvents
import io.element.android.x.features.messages.actionlist.ActionListView
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.features.messages.textcomposer.MessageComposerView
import io.element.android.x.features.messages.timeline.TimelineView
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun MessagesView(
state: MessagesState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
LogCompositions(tag = "MessagesScreen", msg = "Content")
fun onMessageClicked(messageEvent: TimelineItem.MessageEvent) {
Timber.v("OnMessageClicked= ${messageEvent.id}")
}
fun onMessageLongClicked(messageEvent: TimelineItem.MessageEvent) {
Timber.v("OnMessageLongClicked= ${messageEvent.id}")
focusManager.clearFocus(force = true)
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(messageEvent))
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
}
fun onActionSelected(action: TimelineItemAction, messageEvent: TimelineItem.MessageEvent) {
state.eventSink(MessagesEvents.HandleAction(action, messageEvent))
}
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = {
MessagesViewTopBar(
roomTitle = state.roomName,
roomAvatar = state.roomAvatar,
onBackPressed = onBackPressed
)
},
content = { padding ->
MessagesViewContent(
state = state,
modifier = Modifier.padding(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked
)
},
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
},
)
ActionListView(
state = state.actionListState,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = ::onActionSelected
)
}
@Composable
fun MessagesViewContent(
state: MessagesState,
modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {},
onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding()
) {
// Hide timeline if composer is full screen
if (!state.composerState.isFullScreen) {
TimelineView(
state = state.timelineState,
modifier = Modifier.weight(1f),
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked
)
}
MessageComposerView(
state = state.composerState,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
)
}
}
@Composable
fun MessagesViewTopBar(
roomTitle: String?,
roomAvatar: AvatarData?,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (roomAvatar != null) {
Avatar(roomAvatar)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = roomTitle ?: "Unknown room",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}

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