Merge branch 'develop' into feature/crypto

This commit is contained in:
ganfra
2019-06-07 18:53:24 +02:00
404 changed files with 12812 additions and 2612 deletions

View File

@ -71,10 +71,21 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
debug {
keyAlias 'androiddebugkey'
keyPassword 'android'
storeFile file('./signature/debug.keystore')
storePassword 'android'
}
}
buildTypes {
debug {
resValue "bool", "debug_mode", "true"
buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false"
signingConfig signingConfigs.debug
}
release {
@ -121,7 +132,7 @@ dependencies {
def epoxy_version = "3.3.0"
def arrow_version = "0.8.2"
def coroutines_version = "1.0.1"
def markwon_version = '3.0.0-SNAPSHOT'
def markwon_version = '3.0.0'
def big_image_viewer_version = '1.5.6'
def glide_version = '4.9.0'
def moshi_version = '1.8.0'
@ -156,7 +167,7 @@ dependencies {
implementation("com.airbnb.android:epoxy:$epoxy_version")
kapt "com.airbnb.android:epoxy-processor:$epoxy_version"
implementation 'com.airbnb.android:mvrx:0.7.0'
implementation 'com.airbnb.android:mvrx:1.0.1'
// Work
implementation "android.arch.work:work-runtime-ktx:1.0.0"
@ -173,6 +184,7 @@ dependencies {
implementation 'me.gujun.android:span:1.7'
implementation "ru.noties.markwon:core:$markwon_version"
implementation "ru.noties.markwon:html:$markwon_version"
implementation 'me.saket:better-link-movement-method:2.2.0'
// Passphrase strength helper
implementation 'com.nulab-inc:zxcvbn:1.2.5'

View File

@ -0,0 +1,52 @@
{
"data": [
{
"displayName": "Long display name useful to test layout with a long display name",
"mxid": "@longmatrixidbecausesometimesuserschooselongmxid:matrix.org",
"message": "William Shakespeare (bapt. 26 April 1564 23 April 1616) was an English poet, playwright and actor, widely regarded as the greatest writer in the English language and the world's greatest dramatist. He is often called England's national poet and the \"Bard of Avon\". His extant works, including collaborations, consist of approximately 39 plays, 154 sonnets, two long narrative poems, and a few other verses, some of uncertain authorship. His plays have been translated into every major living language and are performed more often than those of any other playwright.\n\nShakespeare was born and raised in Stratford-upon-Avon, Warwickshire. At the age of 18, he married Anne Hathaway, with whom he had three children: Susanna and twins Hamnet and Judith. Sometime between 1585 and 1592, he began a successful career in London as an actor, writer, and part-owner of a playing company called the Lord Chamberlain's Men, later known as the King's Men. At age 49 (around 1613), he appears to have retired to Stratford, where he died three years later. Few records of Shakespeare's private life survive; this has stimulated considerable speculation about such matters as his physical appearance, his sexuality, his religious beliefs, and whether the works attributed to him were written by others. Such theories are often criticised for failing to adequately note that few records survive of most commoners of the period.\n\nShakespeare produced most of his known works between 1589 and 1613. His early plays were primarily comedies and histories and are regarded as some of the best work produced in these genres. Until about 1608, he wrote mainly tragedies, among them Hamlet, Othello, King Lear, and Macbeth, all considered to be among the finest works in the English language. In the last phase of his life, he wrote tragicomedies (also known as romances) and collaborated with other playwrights.\n\nMany of Shakespeare's plays were published in editions of varying quality and accuracy in his lifetime. However, in 1623, two fellow actors and friends of Shakespeare's, John Heminges and Henry Condell, published a more definitive text known as the First Folio, a posthumous collected edition of Shakespeare's dramatic works that included all but two of his plays. The volume was prefaced with a poem by Ben Jonson, in which Jonson presciently hails Shakespeare in a now-famous quote as \"not of an age, but for all time\".\n\nThroughout the 20th and 21st centuries, Shakespeare's works have been continually adapted and rediscovered by new movements in scholarship and performance. His plays remain popular and are studied, performed, and reinterpreted through various cultural and political contexts around the world.",
"roomName": "Matrix HQ",
"roomAlias": "#matrix:matrix.org",
"roomTopic": "Welcome to Matrix HQ! Here is the rest of the room topic…"
},
{
"displayName": "benoit",
"mxid": "@benoit:matrix.org",
"message": "Hello!",
"roomName": "Room name very loooooooong with some details",
"roomAlias": "#matrix:matrix.org",
"roomTopic": "Room topic very loooooooong with some details"
},
{
"displayName": "ganfra",
"mxid": "@ganfra:matrix.org",
"message": "How are you?",
"roomName": "Room name very loooooooong with some details",
"roomAlias": "#matrix:matrix.org",
"roomTopic": "Room topic very loooooooong with some details"
},
{
"displayName": "Manu",
"mxid": "@manu:matrix.org",
"message": "Great weather today!",
"roomName": "Room name very loooooooong with some details",
"roomAlias": "#matrix:matrix.org",
"roomTopic": "Room topic very loooooooong with some details"
},
{
"displayName": "Giom",
"mxid": "@giom:matrix.org",
"message": "Let's do a picnic",
"roomName": "Room name very loooooooong with some details",
"roomAlias": "#matrix:matrix.org",
"roomTopic": "Room topic very loooooooong with some details"
},
{
"displayName": "Nad",
"mxid": "@nadonomy:matrix.org",
"message": "Yes, great idea",
"roomName": "Room name very loooooooong with some details",
"roomAlias": "#matrix:matrix.org",
"roomTopic": "Room topic very loooooooong with some details"
}
]
}

View File

@ -0,0 +1,12 @@
## Debug signature
Buildkite CI tool uses docker images to build the Android application, and it looks like the debug signature is changed at each build.
So it's not possible for user to upgrade the application with the last build from buildkite without uninstalling the application.
This folder contains a debug signature, and the debug build will uses this signature to build the APK.
The validity of the signature is 30 years. So it has to be replaced before June 2049 :).
More info about the debug signature: https://developer.android.com/studio/publish/app-signing#debug-mode

Binary file not shown.

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="im.vector.riotredesign.features.debug.TestLinkifyActivity" />
<activity
android:name="im.vector.riotredesign.features.debug.DebugMaterialThemeLightActivity"
android:theme="@style/VectorMaterialThemeDebugLight" />
<activity
android:name="im.vector.riotredesign.features.debug.DebugMaterialThemeDarkActivity"
android:theme="@style/VectorMaterialThemeDebugDark" />
</application>
</manifest>

View File

@ -0,0 +1,65 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.debug
import android.os.Bundle
import android.view.Menu
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.snackbar.Snackbar
import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.toast
import kotlinx.android.synthetic.debug.activity_test_material_theme.*
// Rendering is not the same with VectorBaseActivity
abstract class DebugMaterialThemeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_test_material_theme)
debugShowSnackbar.setOnClickListener {
Snackbar.make(debugMaterialCoordinator, "Snackbar!", Snackbar.LENGTH_SHORT)
.setAction("Action") { }
.show()
}
debugShowToast.setOnClickListener {
toast("Toast")
}
debugShowDialog.setOnClickListener {
AlertDialog.Builder(this)
.setMessage("Dialog content")
.setIcon(R.drawable.ic_settings_x)
.setPositiveButton("Positive", null)
.setNegativeButton("Negative", null)
.setNeutralButton("Neutral", null)
.show()
}
debugShowBottomSheet.setOnClickListener {
BottomSheetDialogFragment().show(supportFragmentManager, "TAG")
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.vector_home, menu)
return true
}
}

View File

@ -0,0 +1,19 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.debug
class DebugMaterialThemeDarkActivity : DebugMaterialThemeActivity()

View File

@ -0,0 +1,19 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.debug
class DebugMaterialThemeLightActivity : DebugMaterialThemeActivity()

View File

@ -0,0 +1,137 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.debug
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import butterknife.OnClick
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.VectorBaseActivity
class DebugMenuActivity : VectorBaseActivity() {
override fun getLayoutRes() = R.layout.activity_debug_menu
@OnClick(R.id.debug_test_text_view_link)
fun testTextViewLink() {
startActivity(Intent(this, TestLinkifyActivity::class.java))
}
@OnClick(R.id.debug_test_notification)
fun testNotification() {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create channel first
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
"CHAN",
"Channel name",
NotificationManager.IMPORTANCE_DEFAULT
)
channel.description = "Channel description"
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(channel)
val channel2 =
NotificationChannel(
"CHAN2",
"Channel name 2",
NotificationManager.IMPORTANCE_DEFAULT
)
channel2.description = "Channel description 2"
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(channel2)
}
val builder = NotificationCompat.Builder(this, "CHAN")
.setWhen(System.currentTimeMillis())
.setContentTitle("Title")
.setContentText("Content")
// No effect because it's a group summary notif
.setNumber(33)
.setSmallIcon(R.drawable.logo_transparent)
// This provocate the badge issue: no badge for group notification
.setGroup("GroupKey")
.setGroupSummary(true)
val messagingStyle1 = NotificationCompat.MessagingStyle(
Person.Builder()
.setName("User name")
.build()
)
.addMessage("Message 1 - 1", System.currentTimeMillis(), Person.Builder().setName("user 1-1").build())
.addMessage("Message 1 - 2", System.currentTimeMillis(), Person.Builder().setName("user 1-2").build())
val messagingStyle2 = NotificationCompat.MessagingStyle(
Person.Builder()
.setName("User name 2")
.build()
)
.addMessage("Message 2 - 1", System.currentTimeMillis(), Person.Builder().setName("user 1-1").build())
.addMessage("Message 2 - 2", System.currentTimeMillis(), Person.Builder().setName("user 1-2").build())
notificationManager.notify(10, builder.build())
notificationManager.notify(
11,
NotificationCompat.Builder(this, "CHAN")
.setChannelId("CHAN")
.setWhen(System.currentTimeMillis())
.setContentTitle("Title 1")
.setContentText("Content 1")
// For shortcut on long press on launcher icon
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
.setStyle(messagingStyle1)
.setSmallIcon(R.drawable.logo_transparent)
.setGroup("GroupKey")
.build()
)
notificationManager.notify(
12,
NotificationCompat.Builder(this, "CHAN2")
.setWhen(System.currentTimeMillis())
.setContentTitle("Title 2")
.setContentText("Content 2")
.setStyle(messagingStyle2)
.setSmallIcon(R.drawable.logo_transparent)
.setGroup("GroupKey")
.build()
)
}
@OnClick(R.id.debug_test_material_theme_light)
fun testMaterialThemeLight() {
startActivity(Intent(this, DebugMaterialThemeLightActivity::class.java))
}
@OnClick(R.id.debug_test_material_theme_dark)
fun testMaterialThemeDark() {
startActivity(Intent(this, DebugMaterialThemeDarkActivity::class.java))
}
}

View File

@ -0,0 +1,133 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.debug
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.coordinatorlayout.widget.CoordinatorLayout
import butterknife.BindView
import butterknife.ButterKnife
import im.vector.riotredesign.R
class TestLinkifyActivity : AppCompatActivity() {
@BindView(R.id.test_linkify_content_view)
lateinit var scrollContent: LinearLayout
@BindView(R.id.test_linkify_coordinator)
lateinit var coordinatorLayout: CoordinatorLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_test_linkify)
ButterKnife.bind(this)
scrollContent.removeAllViews()
listOf(
"https://www.html5rocks.com/en/tutorials/webrtc/basics/ |",
"https://www.html5rocks.com/en/tutorials/webrtc/basics/",
"mailto mailto:test@toto.com test@toto.com",
"Here is the link.www.test.com/foo/?23=35 you got it?",
"www.lemonde.fr",
" /www.lemonde.fr",
"://www.lemonde.fr",
"file:///dev/null ",
" ansible/xoxys.matrix#2c0b65eb",
"foo.ansible/xoxys.matrix#2c0b65eb",
"foo.ansible.fpo/xoxys.matrix#2c0b65eb",
"https://foo.ansible.fpo/xoxys.matrix#2c0b65eb",
"@vf:matrix.org",
"+44 207 123 1234",
"+33141437940",
"1234",
"3456.34,089",
"ksks9808",
"For example: geo:48.85828,2.29449?z=16 should be clickable",
"geo:37.786971,-122.399677;u=35",
"37.786971,-122.399677;u=35",
"48.107864,-1.712153",
"synchrone peut tenir la route la",
"that.is.some.sexy.link",
"test overlap 48.107864,0673728392 geo + pn?",
"test overlap 0673728392,48.107864 geo + pn?",
"If I add a link in brackets like (help for Riot: https://about.riot.im/help), the link is usable on Riot for Desktop",
"(help for Riot: https://about.riot.im/help)",
"http://example.com/test(1).html",
"http://example.com/test(1)",
"https://about.riot.im/help)",
"(http://example.com/test(1))",
"http://example.com/test1)",
"http://example.com/test1/, et ca",
"www.example.com/, et ca",
"foo.ansible.toplevel/xoxys.matrix#2c0b65eb",
"foo.ansible.ninja/xoxys.matrix#2c0b65eb",
"in brackets like (help for Riot: https://www.exemple/com/find(1)) , the link is usable ",
"""
In brackets like (help for Riot: https://about.riot.im/help) , the link is usable,
But you can call +44 207 123 1234 and come to 37.786971,-122.399677;u=35 then
see if this mail jhon@riot.im is active but this should not 12345
""".trimIndent()
)
.forEach { textContent ->
val item = LayoutInflater.from(this)
.inflate(R.layout.item_test_linkify, scrollContent, false)
item.findViewById<TextView>(R.id.test_linkify_auto_text)
?.apply {
text = textContent
/* TODO Use BetterLinkMovementMethod when the other PR is merged
movementMethod = MatrixLinkMovementMethod(object : MockMessageAdapterActionListener() {
override fun onURLClick(uri: Uri?) {
Snackbar.make(coordinatorLayout, "URI Clicked: $uri", Snackbar.LENGTH_LONG)
.setAction("open") {
openUrlInExternalBrowser(this@TestLinkifyActivity, uri)
}
.show()
}
})
*/
}
item.findViewById<TextView>(R.id.test_linkify_custom_text)
?.apply {
text = textContent
/* TODO Use BetterLinkMovementMethod when the other PR is merged
movementMethod = MatrixLinkMovementMethod(object : MockMessageAdapterActionListener() {
override fun onURLClick(uri: Uri?) {
Snackbar.make(coordinatorLayout, "URI Clicked: $uri", Snackbar.LENGTH_LONG)
.setAction("open") {
openUrlInExternalBrowser(this@TestLinkifyActivity, uri)
}
.show()
}
})
*/
// TODO Call VectorLinkify.addLinks(text)
}
scrollContent.addView(item, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
}
}
}

View File

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size
android:width="8dp"
android:height="8dp" />
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/grey_lynch" />
</shape>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="im.vector.riotredesign.features.debug.DebugMenuActivity"
tools:ignore="HardcodedText">
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="@drawable/linear_divider"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="@dimen/layout_horizontal_margin"
android:showDividers="middle">
<com.google.android.material.button.MaterialButton
android:id="@+id/debug_test_text_view_link"
style="@style/VectorButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Test linkification" />
<com.google.android.material.button.MaterialButton
android:id="@+id/debug_test_notification"
style="@style/VectorButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Test Notification" />
<com.google.android.material.button.MaterialButton
android:id="@+id/debug_test_material_theme_light"
style="@style/VectorButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Test Material theme Light" />
<com.google.android.material.button.MaterialButton
android:id="@+id/debug_test_material_theme_dark"
style="@style/VectorButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Test Material theme Dark" />
</LinearLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/test_linkify_coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/riot_secondary_text_color_status"
tools:context="im.vector.riotredesign.features.debug.TestLinkifyActivity">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/test_linkify_content_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Will be removed at runtime -->
<include layout="@layout/item_test_linkify" />
<include layout="@layout/item_test_linkify" />
<include layout="@layout/item_test_linkify" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,168 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/debugMaterialCoordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".features.debug.DebugMaterialThemeActivity"
tools:ignore="HardcodedText">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="@drawable/linear_divider"
android:orientation="vertical"
android:padding="16dp"
android:showDividers="middle">
<androidx.appcompat.widget.Toolbar
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:subtitle="Toolbar Subtitle"
app:title="Toolbar Title" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="OutlinedBox.Dense">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="FilledBox">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="FilledBox.Dense">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Material Button Classic"
app:icon="@drawable/ic_settings_x" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Material Button OutlinedButton"
app:icon="@drawable/ic_settings_x" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Material Button TextButton"
app:icon="@drawable/ic_settings_x" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button.UnelevatedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Material Button UnelevatedButton"
app:icon="@drawable/ic_settings_x" />
<com.google.android.material.button.MaterialButton
android:id="@+id/debugShowSnackbar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Show Snackbar" />
<com.google.android.material.button.MaterialButton
android:id="@+id/debugShowToast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Show Toast" />
<com.google.android.material.button.MaterialButton
android:id="@+id/debugShowDialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Show Dialog" />
<com.google.android.material.button.MaterialButton
android:id="@+id/debugShowBottomSheet"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Show Bottom Sheet" />
<com.google.android.material.checkbox.MaterialCheckBox
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Material CheckBox" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Material Switch" />
<com.google.android.material.card.MaterialCardView
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="48dp"
android:text="TextView in MaterialCardView" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_settings_x" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/store_title" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/store_whats_new" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/store_short_description" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/store_full_description" />
</LinearLayout>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The aim of this file is to test the different themes of Riot
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:ignore="HardcodedText">
<TextView
android:layout_width="match_parent"
android:layout_height="20dp"
android:background="?colorPrimaryDark"
android:gravity="center_vertical"
android:text="Status Bar" />
<TextView
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="?colorPrimary"
android:gravity="center_vertical"
android:text="This is a simple text" />
</LinearLayout>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The aim of this file is to test the different themes of Riot
Unfortunately, this does not work in the preview.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:theme="@style/AppTheme.Light">
<include layout="@layout/demo_theme_sample" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:theme="@style/AppTheme.Dark">
<include layout="@layout/demo_theme_sample" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:theme="@style/AppTheme.Black">
<include layout="@layout/demo_theme_sample" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:theme="@style/AppTheme.Status">
<include layout="@layout/demo_theme_sample" />
</FrameLayout>
</LinearLayout>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
tools:ignore="HardcodedText">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
style="@style/ListHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="AutoLink" />
<TextView
android:id="@+id/test_linkify_auto_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:autoLink="all"
tools:text="www.example.org" />
<TextView
style="@style/ListHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Custom Link" />
<TextView
android:id="@+id/test_linkify_custom_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
tools:text="www.example.org" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="VectorDebug">
<item name="android:visibility">visible</item>
</style>
<style name="VectorMaterialThemeDebugLight" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="colorPrimary">#7F7F00</item>
<item name="colorPrimaryVariant">#00FF00</item>
<item name="colorOnPrimary">#0000FF</item>
<item name="colorSecondary">#FF00FF</item>
<item name="colorSecondaryVariant">#00FFFF</item>
<item name="colorOnSecondary">#FFF000</item>
<item name="colorError">#FF0000</item>
<item name="colorOnError">#330033</item>
<item name="colorSurface">#003333</item>
<item name="colorOnSurface">#777777</item>
<item name="android:colorBackground">#FF7777</item>
<item name="colorOnBackground">#077700</item>
<!-- TODO is it still required? -->
<item name="colorAccent">#03b381</item>
<item name="android:statusBarColor">#1188FF</item>
<item name="android:navigationBarColor">#FF8811</item>
</style>
<style name="VectorMaterialThemeDebugDark" parent="Theme.MaterialComponents.NoActionBar">
<item name="colorPrimary">#7F7F00</item>
<item name="colorPrimaryVariant">#00FF00</item>
<item name="colorOnPrimary">#0000FF</item>
<item name="colorSecondary">#FF00FF</item>
<item name="colorSecondaryVariant">#00FFFF</item>
<item name="colorOnSecondary">#FFF000</item>
<item name="colorError">#FF0000</item>
<item name="colorOnError">#330033</item>
<item name="colorSurface">#003333</item>
<item name="colorOnSurface">#777777</item>
<item name="android:colorBackground">#FF7777</item>
<item name="colorOnBackground">#077700</item>
<!-- TODO is it still required? -->
<item name="colorAccent">#03b381</item>
<item name="android:statusBarColor">#1188FF</item>
<item name="android:navigationBarColor">#FF8811</item>
</style>
</resources>

View File

@ -55,6 +55,11 @@
android:name="im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity"
android:label="@string/title_activity_emoji_reaction_picker" />
<activity android:name=".features.roomdirectory.RoomDirectoryActivity" />
<activity android:name=".features.roomdirectory.roompreview.RoomPreviewActivity" />
<activity android:name=".features.home.room.detail.RoomDetailActivity" />
<activity android:name=".features.debug.DebugMenuActivity" />
<service
android:name=".core.services.CallService"
android:exported="false" />

View File

@ -18,6 +18,7 @@ package im.vector.riotredesign
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import androidx.multidex.MultiDex
import com.airbnb.epoxy.EpoxyAsyncUtil
import com.airbnb.epoxy.EpoxyController
@ -27,11 +28,13 @@ import com.github.piasy.biv.loader.glide.GlideImageLoader
import com.jakewharton.threetenabp.AndroidThreeTen
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.core.di.AppModule
import im.vector.riotredesign.features.configuration.VectorConfiguration
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.lifecycle.VectorActivityLifecycleCallbacks
import im.vector.riotredesign.features.rageshake.VectorFileLogger
import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler
import org.koin.android.logger.AndroidLogger
import im.vector.riotredesign.features.roomdirectory.RoomDirectoryModule
import org.koin.android.ext.android.inject
import org.koin.log.EmptyLogger
import org.koin.standalone.StandAloneContext.startKoin
import timber.log.Timber
@ -40,10 +43,10 @@ import timber.log.Timber
class VectorApplication : Application() {
lateinit var appContext: Context
val vectorConfiguration: VectorConfiguration by inject()
override fun onCreate() {
super.onCreate()
appContext = this
VectorUncaughtExceptionHandler.activate(this)
@ -62,13 +65,11 @@ class VectorApplication : Application() {
EpoxyController.defaultModelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
val appModule = AppModule(applicationContext).definition
val homeModule = HomeModule().definition
startKoin(
list = listOf(appModule, homeModule),
logger = if (BuildConfig.DEBUG) AndroidLogger() else EmptyLogger())
val roomDirectoryModule = RoomDirectoryModule().definition
startKoin(listOf(appModule, homeModule, roomDirectoryModule), logger = EmptyLogger())
Matrix.getInstance().setApplicationFlavor(BuildConfig.FLAVOR_DESCRIPTION)
registerActivityLifecycleCallbacks(VectorActivityLifecycleCallbacks())
vectorConfiguration.initConfiguration()
}
override fun attachBaseContext(base: Context) {
@ -76,4 +77,9 @@ class VectorApplication : Application() {
MultiDex.install(this)
}
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
vectorConfiguration.onConfigurationChanged(newConfig)
}
}

View File

@ -0,0 +1,19 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.animations
const val ANIMATION_DURATION_SHORT = 200L

View File

@ -0,0 +1,37 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.animations
import android.animation.Animator
open class SimpleAnimatorListener : Animator.AnimatorListener {
override fun onAnimationRepeat(animation: Animator?) {
// No op
}
override fun onAnimationEnd(animation: Animator?) {
// No op
}
override fun onAnimationCancel(animation: Animator?) {
// No op
}
override fun onAnimationStart(animation: Animator?) {
// No op
}
}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.animations
import androidx.transition.Transition
open class SimpleTransitionListener : Transition.TransitionListener {
override fun onTransitionEnd(transition: Transition) {
// No op
}
override fun onTransitionResume(transition: Transition) {
// No op
}
override fun onTransitionPause(transition: Transition) {
// No op
}
override fun onTransitionCancel(transition: Transition) {
// No op
}
override fun onTransitionStart(transition: Transition) {
// No op
}
}

View File

@ -0,0 +1,44 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.animations
import android.content.Context
import android.util.AttributeSet
import androidx.transition.ChangeBounds
import androidx.transition.ChangeTransform
import androidx.transition.Fade
import androidx.transition.TransitionSet
class VectorFullTransitionSet : TransitionSet {
constructor() {
init()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
init()
}
private fun init() {
ordering = ORDERING_TOGETHER
addTransition(Fade(Fade.OUT))
.addTransition(ChangeBounds())
.addTransition(ChangeTransform())
.addTransition(Fade(Fade.IN))
}
}

View File

@ -18,15 +18,20 @@ package im.vector.riotredesign.core.di
import android.content.Context
import android.content.Context.MODE_PRIVATE
import androidx.fragment.app.Fragment
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.core.error.ErrorFormatter
import im.vector.riotredesign.core.resources.LocaleProvider
import im.vector.riotredesign.core.resources.StringArrayProvider
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.configuration.VectorConfiguration
import im.vector.riotredesign.features.crypto.verification.IncomingVerificationRequestHandler
import im.vector.riotredesign.features.home.HomeRoomListObservableStore
import im.vector.riotredesign.features.home.group.SelectedGroupStore
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import im.vector.riotredesign.features.home.room.list.RoomSelectionRepository
import im.vector.riotredesign.features.home.room.list.RoomSummaryComparator
import im.vector.riotredesign.features.home.room.list.AlphabeticalRoomComparator
import im.vector.riotredesign.features.home.room.list.ChronologicalRoomComparator
import im.vector.riotredesign.features.navigation.DefaultNavigator
import im.vector.riotredesign.features.navigation.Navigator
import im.vector.riotredesign.features.notifications.NotificationDrawerManager
import org.koin.dsl.module.module
@ -34,6 +39,10 @@ class AppModule(private val context: Context) {
val definition = module {
single {
VectorConfiguration(context)
}
single {
LocaleProvider(context.resources)
}
@ -43,27 +52,31 @@ class AppModule(private val context: Context) {
}
single {
ColorProvider(context)
StringArrayProvider(context.resources)
}
single {
context.getSharedPreferences("im.vector.riot", MODE_PRIVATE)
}
single {
RoomSelectionRepository(get())
}
single {
SelectedGroupStore()
}
single {
VisibleRoomStore()
HomeRoomListObservableStore()
}
single {
RoomSummaryComparator()
ChronologicalRoomComparator()
}
single {
AlphabeticalRoomComparator()
}
single {
ErrorFormatter(get())
}
single {
@ -78,6 +91,8 @@ class AppModule(private val context: Context) {
IncomingVerificationRequestHandler(context, get())
}
factory { (fragment: Fragment) ->
DefaultNavigator(fragment) as Navigator
}
}
}

View File

@ -0,0 +1,46 @@
/*
*
* * Copyright 2019 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 im.vector.riotredesign.core.epoxy
import android.widget.Button
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
@EpoxyModelClass(layout = R.layout.item_error_retry)
abstract class ErrorWithRetryItem : VectorEpoxyModel<ErrorWithRetryItem.Holder>() {
@EpoxyAttribute
var text: String? = null
@EpoxyAttribute
var listener: (() -> Unit)? = null
override fun bind(holder: Holder) {
holder.textView.text = text
holder.buttonView.setOnClickListener { listener?.invoke() }
}
class Holder : VectorEpoxyHolder() {
val textView by bind<TextView>(R.id.itemErrorRetryText)
val buttonView by bind<Button>(R.id.itemErrorRetryButton)
}
}

View File

@ -16,9 +16,11 @@
package im.vector.riotredesign.core.epoxy
import android.content.Context
import android.widget.ProgressBar
import com.airbnb.epoxy.ModelView
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class LoadingItem(context: Context) : ProgressBar(context)
@EpoxyModelClass(layout = R.layout.item_loading)
abstract class LoadingItem : VectorEpoxyModel<LoadingItem.Holder>() {
class Holder : VectorEpoxyHolder()
}

View File

@ -0,0 +1,40 @@
/*
*
* * Copyright 2019 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 im.vector.riotredesign.core.epoxy
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
@EpoxyModelClass(layout = R.layout.item_no_result)
abstract class NoResultItem : VectorEpoxyModel<NoResultItem.Holder>() {
@EpoxyAttribute
var text: String? = null
override fun bind(holder: Holder) {
holder.textView.text = text
}
class Holder : VectorEpoxyHolder() {
val textView by bind<TextView>(R.id.itemNoResultText)
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.error
import im.vector.matrix.android.api.failure.Failure
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
class ErrorFormatter(val stringProvider: StringProvider) {
fun toHumanReadable(failure: Failure): String {
// Default
return failure.localizedMessage
}
fun toHumanReadable(throwable: Throwable?): String {
return when (throwable) {
null -> ""
is Failure.NetworkConnection -> stringProvider.getString(R.string.error_no_network)
else -> throwable.localizedMessage
}
}
}

View File

@ -18,26 +18,26 @@ package im.vector.riotredesign.core.extensions
import androidx.fragment.app.Fragment
fun androidx.fragment.app.Fragment.addFragment(fragment: Fragment, frameId: Int) {
fun Fragment.addFragment(fragment: Fragment, frameId: Int) {
fragmentManager?.inTransaction { add(frameId, fragment) }
}
fun androidx.fragment.app.Fragment.replaceFragment(fragment: Fragment, frameId: Int) {
fun Fragment.replaceFragment(fragment: Fragment, frameId: Int) {
fragmentManager?.inTransaction { replace(frameId, fragment) }
}
fun androidx.fragment.app.Fragment.addFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
fun Fragment.addFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
fragmentManager?.inTransaction { replace(frameId, fragment).addToBackStack(tag) }
}
fun androidx.fragment.app.Fragment.addChildFragment(fragment: Fragment, frameId: Int) {
fun Fragment.addChildFragment(fragment: Fragment, frameId: Int) {
childFragmentManager.inTransaction { add(frameId, fragment) }
}
fun androidx.fragment.app.Fragment.replaceChildFragment(fragment: Fragment, frameId: Int) {
fun Fragment.replaceChildFragment(fragment: Fragment, frameId: Int) {
childFragmentManager.inTransaction { replace(frameId, fragment) }
}
fun androidx.fragment.app.Fragment.addChildFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
fun Fragment.addChildFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
childFragmentManager.inTransaction { replace(frameId, fragment).addToBackStack(tag) }
}

View File

@ -0,0 +1,33 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.extensions
import android.widget.TextView
import androidx.core.view.isVisible
/**
* Set a text in the TextView, or set visibility to GONE it if the text is null
*/
fun TextView.setTextOrHide(newText: String?, hideWhenBlank: Boolean = true) {
if (newText == null
|| (newText.isBlank() && hideWhenBlank)) {
isVisible = false
} else {
this.text = newText
isVisible = true
}
}

View File

@ -0,0 +1,97 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.platform
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import im.vector.riotredesign.R
import kotlinx.android.synthetic.main.view_button_state.view.*
class ButtonStateView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0)
: FrameLayout(context, attrs, defStyle) {
sealed class State {
object Button : State()
object Loading : State()
object Loaded : State()
object Error : State()
}
var callback: Callback? = null
interface Callback {
fun onButtonClicked()
fun onRetryClicked()
}
// Big or Flat button
var button: Button
init {
View.inflate(context, R.layout.view_button_state, this)
layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
buttonStateRetry.setOnClickListener {
callback?.onRetryClicked()
}
// Read attributes
context.theme.obtainStyledAttributes(
attrs,
R.styleable.ButtonStateView,
0, 0)
.apply {
try {
if (getBoolean(R.styleable.ButtonStateView_bsv_use_flat_button, true)) {
button = buttonStateButtonFlat
buttonStateButtonBig.isVisible = false
} else {
button = buttonStateButtonBig
buttonStateButtonFlat.isVisible = false
}
button.text = getString(R.styleable.ButtonStateView_bsv_button_text)
buttonStateLoaded.setImageDrawable(getDrawable(R.styleable.ButtonStateView_bsv_loaded_image_src))
} finally {
recycle()
}
}
button.setOnClickListener {
callback?.onButtonClicked()
}
}
fun render(newState: State) {
if (newState == State.Button) {
button.isVisible = true
} else {
// We use isInvisible because we want to keep button space in the layout
button.isInvisible = true
}
buttonStateLoading.isVisible = newState == State.Loading
buttonStateLoaded.isVisible = newState == State.Loaded
buttonStateRetry.isVisible = newState == State.Error
}
}

View File

@ -0,0 +1,61 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.platform
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.Checkable
import androidx.constraintlayout.widget.ConstraintLayout
class CheckableConstraintLayout : ConstraintLayout, Checkable {
private var mChecked = false
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun isChecked(): Boolean {
return mChecked
}
override fun setChecked(b: Boolean) {
if (b != mChecked) {
mChecked = b
refreshDrawableState()
}
}
override fun toggle() {
isChecked = !mChecked
}
public override fun onCreateDrawableState(extraSpace: Int): IntArray {
val drawableState = super.onCreateDrawableState(extraSpace + 1)
if (isChecked) {
View.mergeDrawableStates(drawableState, CHECKED_STATE_SET)
}
return drawableState
}
companion object {
private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked)
}
}

View File

@ -0,0 +1,55 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.platform
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import im.vector.riotredesign.core.utils.LiveEvent
import im.vector.riotredesign.features.configuration.VectorConfiguration
import org.koin.standalone.KoinComponent
import org.koin.standalone.inject
import timber.log.Timber
class ConfigurationViewModel : ViewModel(), KoinComponent {
private val vectorConfiguration: VectorConfiguration by inject()
private var currentConfigurationValue: String? = null
private val _activityRestarter = MutableLiveData<LiveEvent<Unit>>()
val activityRestarter: LiveData<LiveEvent<Unit>>
get() = _activityRestarter
fun onActivityResumed() {
if (currentConfigurationValue == null) {
currentConfigurationValue = vectorConfiguration.getHash()
Timber.v("Configuration: init to $currentConfigurationValue")
} else {
val newHash = vectorConfiguration.getHash()
Timber.v("Configuration: newHash $newHash")
if (newHash != currentConfigurationValue) {
Timber.v("Configuration: recreate the Activity")
currentConfigurationValue = newHash
_activityRestarter.postValue(LiveEvent(Unit))
}
}
}
}

View File

@ -24,10 +24,11 @@ import butterknife.BindView
import im.vector.matrix.android.api.session.Session
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.hideKeyboard
import kotlinx.android.synthetic.main.activity.*
import org.koin.android.ext.android.get
/**
* Simple activity with a toolbar, a waiting overlay, and a fragment container and a mxSession.
* Simple activity with a toolbar, a waiting overlay, and a fragment container and a session.
*/
abstract class SimpleFragmentActivity : VectorBaseActivity() {
@ -42,10 +43,10 @@ abstract class SimpleFragmentActivity : VectorBaseActivity() {
@BindView(R.id.waiting_view_status_horizontal_progress)
lateinit var waitingHorizontalProgress: ProgressBar
protected val mSession = get<Session>()
protected val session = get<Session>()
override fun initUiAndData() {
configureToolbar()
configureToolbar(toolbar)
waitingView = findViewById(R.id.waiting_view)
}

View File

@ -17,9 +17,9 @@
package im.vector.riotredesign.core.platform
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import im.vector.riotredesign.R
import kotlinx.android.synthetic.main.view_state.view.*
@ -30,7 +30,7 @@ class StateView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
sealed class State {
object Content : State()
object Loading : State()
data class Empty(val message: CharSequence? = null) : State()
data class Empty(val title: CharSequence? = null, val image: Drawable? = null, val message: CharSequence? = null) : State()
data class Error(val message: CharSequence? = null) : State()
}
@ -52,7 +52,7 @@ class StateView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
init {
View.inflate(context, R.layout.view_state, this)
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
layoutParams = LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
errorRetryView.setOnClickListener {
eventCallback?.onRetryClicked()
}
@ -62,35 +62,33 @@ class StateView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
private fun update(newState: State) {
when (newState) {
is StateView.State.Content -> {
is State.Content -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.INVISIBLE
contentView?.visibility = View.VISIBLE
}
is StateView.State.Loading -> {
is State.Loading -> {
progressBar.visibility = View.VISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.INVISIBLE
contentView?.visibility = View.INVISIBLE
}
is StateView.State.Empty -> {
is State.Empty -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.VISIBLE
emptyImageView.setImageDrawable(newState.image)
emptyMessageView.text = newState.message
if (contentView != null) {
contentView!!.visibility = View.INVISIBLE
}
emptyTitleView.text = newState.title
contentView?.visibility = View.INVISIBLE
}
is StateView.State.Error -> {
is State.Error -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.VISIBLE
emptyView.visibility = View.INVISIBLE
errorMessageView.text = newState.message
if (contentView != null) {
contentView!!.visibility = View.INVISIBLE
}
contentView?.visibility = View.INVISIBLE
}
}
}

View File

@ -16,6 +16,7 @@
package im.vector.riotredesign.core.platform
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.view.Menu
@ -25,6 +26,8 @@ import androidx.annotation.*
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import butterknife.BindView
import butterknife.ButterKnife
import butterknife.Unbinder
@ -34,14 +37,16 @@ import com.google.android.material.snackbar.Snackbar
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.toast
import im.vector.riotredesign.features.configuration.VectorConfiguration
import im.vector.riotredesign.features.rageshake.BugReportActivity
import im.vector.riotredesign.features.rageshake.BugReporter
import im.vector.riotredesign.features.rageshake.RageShake
import im.vector.riotredesign.features.themes.ActivityOtherThemes
import im.vector.riotredesign.features.themes.ThemeUtils
import im.vector.riotredesign.receivers.DebugReceiver
import im.vector.ui.themes.ActivityOtherThemes
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import org.koin.android.ext.android.inject
import timber.log.Timber
@ -50,11 +55,6 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
* UI
* ========================================================================================== */
@Nullable
@JvmField
@BindView(R.id.toolbar)
var toolbar: Toolbar? = null
@Nullable
@JvmField
@BindView(R.id.vector_coordinator_layout)
@ -64,6 +64,10 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
* DATA
* ========================================================================================== */
private val vectorConfiguration: VectorConfiguration by inject()
private lateinit var configurationViewModel: ConfigurationViewModel
private var unBinder: Unbinder? = null
private var savedInstanceState: Bundle? = null
@ -76,6 +80,10 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
private var rageShake: RageShake? = null
override fun attachBaseContext(base: Context) {
super.attachBaseContext(vectorConfiguration.getLocalisedContext(base))
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
restorables.forEach { it.onSaveInstanceState(outState) }
@ -101,6 +109,16 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
configurationViewModel = ViewModelProviders.of(this).get(ConfigurationViewModel::class.java)
configurationViewModel.activityRestarter.observe(this, Observer {
if (!it.hasBeenHandled) {
// Recreate the Activity because configuration has changed
startActivity(intent)
finish()
}
})
// Shake detector
rageShake = RageShake(this)
@ -133,6 +151,8 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
unBinder?.unbind()
unBinder = null
uiDisposables.dispose()
}
override fun onResume() {
@ -140,6 +160,8 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
Timber.v("onResume Activity ${this.javaClass.simpleName}")
configurationViewModel.onActivityResumed()
if (this !is BugReportActivity) {
rageShake?.start()
}
@ -244,14 +266,16 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
protected fun isFirstCreation() = savedInstanceState == null
/**
* Configure the Toolbar. It MUST be present in your layout with id "toolbar"
* Configure the Toolbar, with default back button.
*/
protected fun configureToolbar() {
protected fun configureToolbar(toolbar: Toolbar, displayBack: Boolean = true) {
setSupportActionBar(toolbar)
supportActionBar?.let {
it.setDisplayShowHomeEnabled(true)
it.setDisplayHomeAsUpEnabled(true)
if (displayBack) {
supportActionBar?.let {
it.setDisplayShowHomeEnabled(true)
it.setDisplayHomeAsUpEnabled(true)
}
}
}
@ -329,8 +353,12 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
* Temporary method
* ========================================================================================== */
fun notImplemented() {
toast(getString(R.string.not_implemented))
fun notImplemented(message: String = "") {
if (message.isNotBlank()) {
toast(getString(R.string.not_implemented) + ": $message")
} else {
toast(getString(R.string.not_implemented))
}
}
}

View File

@ -22,11 +22,17 @@ import android.view.*
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.annotation.MainThread
import androidx.appcompat.widget.Toolbar
import butterknife.ButterKnife
import butterknife.Unbinder
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRx
import com.bumptech.glide.util.Util.assertMainThread
import im.vector.riotredesign.features.navigation.Navigator
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import org.koin.android.ext.android.inject
import org.koin.core.parameter.parametersOf
import timber.log.Timber
abstract class VectorBaseFragment : BaseMvRxFragment(), OnBackPressed {
@ -38,6 +44,12 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), OnBackPressed {
activity as VectorBaseActivity
}
/* ==========================================================================================
* Navigator
* ========================================================================================== */
protected val navigator: Navigator by inject { parametersOf(this) }
/* ==========================================================================================
* Life cycle
* ========================================================================================== */
@ -78,6 +90,12 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), OnBackPressed {
mUnBinder = null
}
override fun onDestroy() {
super.onDestroy()
uiDisposables.dispose()
}
/* ==========================================================================================
* Restorable
* ========================================================================================== */
@ -100,6 +118,7 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), OnBackPressed {
override fun invalidate() {
//no-ops by default
Timber.w("invalidate() method has not been implemented")
}
protected fun setArguments(args: Parcelable? = null) {
@ -113,6 +132,30 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), OnBackPressed {
return this
}
/* ==========================================================================================
* Toolbar
* ========================================================================================== */
/**
* Configure the Toolbar.
*/
protected fun setupToolbar(toolbar: Toolbar) {
val parentActivity = vectorBaseActivity
if (parentActivity is ToolbarConfigurable) {
parentActivity.configure(toolbar)
}
}
/* ==========================================================================================
* Disposable
* ========================================================================================== */
private val uiDisposables = CompositeDisposable()
protected fun Disposable.disposeOnDestroy(): Disposable {
uiDisposables.add(this)
return this
}
/* ==========================================================================================
* MENU MANAGEMENT

View File

@ -39,7 +39,7 @@ class RoomAvatarPreference : UserAvatarPreference {
override fun refreshAvatar() {
if (null != mAvatarView && null != mRoom) {
// TODO
// VectorUtils.loadRoomAvatar(context, mSession, mAvatarView, mRoom)
// VectorUtils.loadRoomAvatar(context, session, mAvatarView, mRoom)
}
}

View File

@ -53,8 +53,8 @@ open class UserAvatarPreference : Preference {
open fun refreshAvatar() {
if (null != mAvatarView && null != mSession) {
// TODO
// val myUser = mSession!!.myUser
// VectorUtils.loadUserAvatar(context, mSession, mAvatarView, myUser.avatarUrl, myUser.user_id, myUser.displayname)
// val myUser = session!!.myUser
// VectorUtils.loadUserAvatar(context, session, mAvatarView, myUser.avatarUrl, myUser.user_id, myUser.displayname)
}
}

View File

@ -99,7 +99,7 @@ class VectorGroupPreference : SwitchPreference {
private fun refreshAvatar() {
if (null != mAvatarView && null != mSession && null != mGroup) {
// TODO
// VectorUtils.loadGroupAvatar(context, mSession, mAvatarView, mGroup)
// VectorUtils.loadGroupAvatar(context, session, mAvatarView, mGroup)
}
}
}

View File

@ -19,8 +19,11 @@
package im.vector.riotredesign.core.resources
import android.content.Context
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import im.vector.riotredesign.features.themes.ThemeUtils
class ColorProvider(private val context: Context) {
@ -28,4 +31,16 @@ class ColorProvider(private val context: Context) {
return ContextCompat.getColor(context, colorRes)
}
/**
* Translates color attributes to colors
*
* @param c Context
* @param colorAttribute Color Attribute
* @return Requested Color
*/
@ColorInt
fun getColorFromAttribute(@AttrRes colorAttribute: Int): Int {
return ThemeUtils.getColor(context, colorAttribute)
}
}

View File

@ -29,4 +29,9 @@ object DateProvider {
return LocalDateTime.ofInstant(instant, zoneId)
}
fun currentLocalDateTime(): LocalDateTime {
val instant = Instant.now()
return LocalDateTime.ofInstant(instant, zoneId)
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2019 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 im.vector.riotredesign.core.resources
import android.content.res.Resources
import androidx.annotation.ArrayRes
import androidx.annotation.NonNull
class StringArrayProvider(private val resources: Resources) {
/**
* Returns a localized string array from the application's package's
* default string array table.
*
* @param resId Resource id for the string array
* @return The string array associated with the resource, stripped of styled
* text information.
*/
@NonNull
fun getStringArray(@ArrayRes resId: Int): Array<String> {
return resources.getStringArray(resId)
}
}

View File

@ -106,7 +106,7 @@ class EventStreamServiceX : VectorService() {
// do not suspend the application if there is some active calls
if (ServiceState.CATCHUP == serviceState) {
val hasActiveCalls = mSession?.mCallsManager?.hasActiveCalls() == true
val hasActiveCalls = session?.mCallsManager?.hasActiveCalls() == true
// if there are some active calls, the catchup should not be stopped.
// because an user could answer to a call from another device.
@ -351,12 +351,12 @@ class EventStreamServiceX : VectorService() {
Timber.i("## stop(): the service is stopped")
/* TODO
if (null != mSession && mSession!!.isAlive) {
mSession!!.stopEventStream()
mSession!!.dataHandler.removeListener(mEventsListener)
CallsManager.getSharedInstance().removeSession(mSession)
if (null != session && session!!.isAlive) {
session!!.stopEventStream()
session!!.dataHandler.removeListener(mEventsListener)
CallsManager.getSharedInstance().removeSession(session)
}
mSession = null
session = null
*/
// Stop the service
@ -389,7 +389,7 @@ class EventStreamServiceX : VectorService() {
if (canCatchup) {
if (mSession != null) {
// TODO mSession!!.catchupEventStream()
// TODO session!!.catchupEventStream()
} else {
Timber.i("catchup no session")
}

View File

@ -20,11 +20,13 @@ import android.text.Spannable
import com.otaliastudios.autocomplete.AutocompletePolicy
class CommandAutocompletePolicy : AutocompletePolicy {
var enabled: Boolean = true
override fun getQuery(text: Spannable): CharSequence {
if (text.length > 0) {
return text.substring(1, text.length)
}
// Should not happen
return ""
}
@ -34,7 +36,7 @@ class CommandAutocompletePolicy : AutocompletePolicy {
// Only if text which starts with '/' and without space
override fun shouldShowPopup(text: Spannable?, cursorPos: Int): Boolean {
return text?.startsWith("/") == true
return enabled && text?.startsWith("/") == true
&& !text.contains(" ")
}

View File

@ -0,0 +1,145 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.configuration
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import im.vector.riotredesign.features.settings.FontScale
import im.vector.riotredesign.features.settings.VectorLocale
import im.vector.riotredesign.features.themes.ThemeUtils
import timber.log.Timber
import java.util.*
/**
* Handle locale configuration change, such as theme, font size and locale chosen by the user
*/
class VectorConfiguration(private val context: Context) {
// TODO Import mLanguageReceiver From Riot?
fun onConfigurationChanged(newConfig: Configuration?) {
if (Locale.getDefault().toString() != VectorLocale.applicationLocale.toString()) {
Timber.v("## onConfigurationChanged() : the locale has been updated to " + Locale.getDefault().toString()
+ ", restore the expected value " + VectorLocale.applicationLocale.toString())
updateApplicationSettings(VectorLocale.applicationLocale,
FontScale.getFontScalePrefValue(context),
ThemeUtils.getApplicationTheme(context))
}
}
private fun updateApplicationSettings(locale: Locale, textSize: String, theme: String) {
VectorLocale.saveApplicationLocale(context, locale)
FontScale.saveFontScale(context, textSize)
Locale.setDefault(locale)
val config = Configuration(context.resources.configuration)
config.locale = locale
config.fontScale = FontScale.getFontScale(context)
context.resources.updateConfiguration(config, context.resources.displayMetrics)
ThemeUtils.setApplicationTheme(context, theme)
// TODO PhoneNumberUtils.onLocaleUpdate()
}
/**
* Update the application theme
*
* @param theme the new theme
*/
fun updateApplicationTheme(theme: String) {
ThemeUtils.setApplicationTheme(context, theme)
updateApplicationSettings(VectorLocale.applicationLocale,
FontScale.getFontScalePrefValue(context),
theme)
}
/**
* Init the configuration from the saved one
*/
fun initConfiguration() {
VectorLocale.init(context)
val locale = VectorLocale.applicationLocale
val fontScale = FontScale.getFontScale(context)
val theme = ThemeUtils.getApplicationTheme(context)
Locale.setDefault(locale)
val config = Configuration(context.resources.configuration)
config.locale = locale
config.fontScale = fontScale
context.resources.updateConfiguration(config, context.resources.displayMetrics)
// init the theme
ThemeUtils.setApplicationTheme(context, theme)
}
/**
* Update the application locale
*
* @param locale
*/
// TODO Call from LanguagePickerActivity
fun updateApplicationLocale(locale: Locale) {
updateApplicationSettings(locale, FontScale.getFontScalePrefValue(context), ThemeUtils.getApplicationTheme(context))
}
/**
* Compute a localised context
*
* @param context the context
* @return the localised context
*/
@SuppressLint("NewApi")
fun getLocalisedContext(context: Context): Context {
try {
val resources = context.resources
val locale = VectorLocale.applicationLocale
val configuration = resources.configuration
configuration.fontScale = FontScale.getFontScale(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
configuration.setLocale(locale)
configuration.setLayoutDirection(locale)
return context.createConfigurationContext(configuration)
} else {
configuration.locale = locale
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
configuration.setLayoutDirection(locale)
}
resources.updateConfiguration(configuration, resources.displayMetrics)
return context
}
} catch (e: Exception) {
Timber.e(e, "## getLocalisedContext() failed")
}
return context
}
/**
* Compute the locale status value
* @param activity the activity
* @return the local status value
*/
// TODO Create data class for this
fun getHash(): String {
return (VectorLocale.applicationLocale.toString()
+ "_" + FontScale.getFontScalePrefValue(context)
+ "_" + ThemeUtils.getApplicationTheme(context))
}
}

View File

@ -43,7 +43,7 @@ class KeysBackupRestoreActivity : SimpleFragmentActivity() {
override fun initUiAndData() {
super.initUiAndData()
viewModel = ViewModelProviders.of(this).get(KeysBackupRestoreSharedViewModel::class.java)
viewModel.initSession(mSession)
viewModel.initSession(session)
viewModel.keyVersionResult.observe(this, Observer { keyVersion ->

View File

@ -44,7 +44,7 @@ class KeysBackupManageActivity : SimpleFragmentActivity() {
override fun initUiAndData() {
super.initUiAndData()
viewModel = ViewModelProviders.of(this).get(KeysBackupSettingsViewModel::class.java)
viewModel.initSession(mSession)
viewModel.initSession(session)
if (supportFragmentManager.fragments.isEmpty()) {
@ -52,7 +52,7 @@ class KeysBackupManageActivity : SimpleFragmentActivity() {
.replace(R.id.container, KeysBackupSettingsFragment.newInstance())
.commitNow()
mSession.getKeysBackupService()
session.getKeysBackupService()
.forceUsingLastVersion(object : MatrixCallback<Boolean> {})
}

View File

@ -43,7 +43,7 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() {
viewModel = ViewModelProviders.of(this).get(KeysBackupSetupSharedViewModel::class.java)
viewModel.showManualExport.value = intent.getBooleanExtra(EXTRA_SHOW_MANUAL_EXPORT, false)
viewModel.initSession(mSession)
viewModel.initSession(session)
viewModel.isCreatingBackupVersion.observe(this, Observer {
@ -124,7 +124,7 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() {
/*
showWaitingView()
CommonActivityUtils.exportKeys(mSession, passphrase, object : SimpleApiCallback<String>(this@KeysBackupSetupActivity) {
CommonActivityUtils.exportKeys(session, passphrase, object : SimpleApiCallback<String>(this@KeysBackupSetupActivity) {
override fun onSuccess(filename: String) {
hideWaitingView()

View File

@ -90,9 +90,9 @@ class SASVerificationActivity : SimpleFragmentActivity() {
val isIncoming = intent.getBooleanExtra(EXTRA_IS_INCOMING, false)
if (isIncoming) {
//incoming always have a transaction id
viewModel.initIncoming(mSession, intent.getStringExtra(EXTRA_OTHER_USER_ID), transactionID)
viewModel.initIncoming(session, intent.getStringExtra(EXTRA_OTHER_USER_ID), transactionID)
} else {
viewModel.initOutgoing(mSession, intent.getStringExtra(EXTRA_OTHER_USER_ID), intent.getStringExtra(EXTRA_OTHER_DEVICE_ID))
viewModel.initOutgoing(session, intent.getStringExtra(EXTRA_OTHER_USER_ID), intent.getStringExtra(EXTRA_OTHER_DEVICE_ID))
}
if (isIncoming) {

View File

@ -46,9 +46,9 @@ object AvatarRenderer {
private const val THUMBNAIL_SIZE = 250
private val AVATAR_COLOR_LIST = listOf(
R.color.avatar_color_1,
R.color.avatar_color_2,
R.color.avatar_color_3
R.color.riotx_avatar_fill_1,
R.color.riotx_avatar_fill_2,
R.color.riotx_avatar_fill_3
)
@UiThread
@ -73,10 +73,12 @@ object AvatarRenderer {
identifier: String,
name: String?,
target: Target<Drawable>) {
if (name.isNullOrEmpty()) {
return
val displayName = if (name.isNullOrBlank()) {
identifier
} else {
name
}
val placeholder = getPlaceholderDrawable(context, identifier, name)
val placeholder = getPlaceholderDrawable(context, identifier, displayName)
buildGlideRequest(glideRequest, avatarUrl)
.placeholder(placeholder)
.into(target)
@ -122,33 +124,4 @@ object AvatarRenderer {
.load(resolvedUrl)
.apply(RequestOptions.circleCropTransform())
}
//Based on riot-web implementation
@ColorRes
fun getColorFromUserId(sender: String): Int {
var hash = 0
var i = 0
var chr: Char
if (sender.isEmpty()) {
return R.color.username_1
}
while (i < sender.length) {
chr = sender[i]
hash = (hash shl 5) - hash + chr.toInt()
hash = hash or 0
i++
}
val cI = Math.abs(hash) % 8 + 1
return when (cI) {
1 -> R.color.username_1
2 -> R.color.username_2
3 -> R.color.username_3
4 -> R.color.username_4
5 -> R.color.username_5
6 -> R.color.username_6
7 -> R.color.username_7
else -> R.color.username_8
}
}
}

View File

@ -21,7 +21,6 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.core.view.GravityCompat
@ -32,16 +31,13 @@ import com.airbnb.mvrx.viewModel
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.hideKeyboard
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.extensions.replaceFragment
import im.vector.riotredesign.core.platform.OnBackPressed
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.core.platform.VectorBaseActivity
import im.vector.riotredesign.features.crypto.verification.IncomingVerificationRequestHandler
import im.vector.riotredesign.features.home.room.detail.LoadingRoomDetailFragment
import im.vector.riotredesign.features.rageshake.BugReporter
import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler
import im.vector.riotredesign.features.settings.VectorSettingsActivity
import im.vector.riotredesign.features.workers.signout.SignOutUiWorker
import kotlinx.android.synthetic.main.activity_home.*
import org.koin.android.ext.android.inject
@ -72,15 +68,13 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
bindScope(getOrCreateScope(HomeModule.HOME_SCOPE))
homeNavigator.activity = this
drawerLayout.addDrawerListener(drawerListener)
if (savedInstanceState == null) {
if (isFirstCreation()) {
val homeDrawerFragment = HomeDrawerFragment.newInstance()
val loadingDetail = LoadingRoomDetailFragment.newInstance()
val loadingDetail = LoadingFragment.newInstance()
replaceFragment(loadingDetail, R.id.homeDetailFragmentContainer)
replaceFragment(homeDrawerFragment, R.id.homeDrawerFragmentContainer)
}
homeActivityViewModel.openRoomLiveData.observeEvent(this) {
homeNavigator.openRoomDetail(it, null)
}
homeActivityViewModel.isLoading.observe(this, Observer<Boolean> {
// TODO better UI
if (it) {
@ -118,12 +112,7 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
}
override fun configure(toolbar: Toolbar) {
setSupportActionBar(toolbar)
supportActionBar?.setHomeButtonEnabled(true)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val drawerToggle = ActionBarDrawerToggle(this, drawerLayout, toolbar, 0, 0)
drawerLayout.addDrawerListener(drawerToggle)
drawerToggle.syncState()
configureToolbar(toolbar, false)
}
override fun getMenuRes() = R.menu.home
@ -134,19 +123,10 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
drawerLayout.openDrawer(GravityCompat.START)
return true
}
R.id.sliding_menu_settings -> {
startActivity(VectorSettingsActivity.getIntent(this, "TODO"))
return true
}
R.id.sliding_menu_sign_out -> {
SignOutUiWorker(this).perform(Matrix.getInstance().currentSession!!)
return true
}
// TODO Temporary code here to create a room
R.id.tmp_menu_create_room -> {
homeActivityViewModel.createRoom()
return true
}
}
return true
@ -164,8 +144,9 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
}
private fun recursivelyDispatchOnBackPressed(fm: FragmentManager): Boolean {
if (fm.backStackEntryCount == 0)
return false
// if (fm.backStackEntryCount == 0)
// return false
val reverseOrder = fm.fragments.filter { it is OnBackPressed }.reversed()
for (f in reverseOrder) {
val handledByChildFragments = recursivelyDispatchOnBackPressed(f.childFragmentManager)

View File

@ -18,26 +18,31 @@ package im.vector.riotredesign.features.home
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import arrow.core.Option
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.sync.FilterService
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.VectorViewModel
import im.vector.riotredesign.core.utils.LiveEvent
import im.vector.riotredesign.features.home.room.list.RoomSelectionRepository
import io.reactivex.rxkotlin.subscribeBy
import im.vector.riotredesign.features.home.group.ALL_COMMUNITIES_GROUP_ID
import im.vector.riotredesign.features.home.group.SelectedGroupStore
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import org.koin.android.ext.android.get
import java.util.concurrent.TimeUnit
data class EmptyState(val isEmpty: Boolean = true) : MvRxState
class HomeActivityViewModel(state: EmptyState,
private val session: Session,
roomSelectionRepository: RoomSelectionRepository
private val selectedGroupStore: SelectedGroupStore,
private val homeRoomListStore: HomeRoomListObservableStore
) : VectorViewModel<EmptyState>(state), Session.Listener {
companion object : MvRxViewModelFactory<HomeActivityViewModel, EmptyState> {
@ -45,8 +50,9 @@ class HomeActivityViewModel(state: EmptyState,
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: EmptyState): HomeActivityViewModel? {
val session = Matrix.getInstance().currentSession!!
val roomSelectionRepository = viewModelContext.activity.get<RoomSelectionRepository>()
return HomeActivityViewModel(state, session, roomSelectionRepository)
val selectedGroupStore = viewModelContext.activity.get<SelectedGroupStore>()
val homeRoomListObservableSource = viewModelContext.activity.get<HomeRoomListObservableStore>()
return HomeActivityViewModel(state, session, selectedGroupStore, homeRoomListObservableSource)
}
}
@ -54,29 +60,41 @@ class HomeActivityViewModel(state: EmptyState,
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _openRoomLiveData = MutableLiveData<LiveEvent<String>>()
val openRoomLiveData: LiveData<LiveEvent<String>>
get() = _openRoomLiveData
init {
session.addListener(this)
val lastSelectedRoomId = roomSelectionRepository.lastSelectedRoom()
if (lastSelectedRoomId == null || session.getRoom(lastSelectedRoomId) == null) {
getTheFirstRoomWhenAvailable()
} else {
_openRoomLiveData.postValue(LiveEvent(lastSelectedRoomId))
}
observeRoomAndGroup()
}
private fun getTheFirstRoomWhenAvailable() {
session.rx().liveRoomSummaries()
.filter { it.isNotEmpty() }
.first(emptyList())
.subscribeBy {
val firstRoom = it.firstOrNull()
if (firstRoom != null) {
_openRoomLiveData.postValue(LiveEvent(firstRoom.roomId))
}
private fun observeRoomAndGroup() {
Observable
.combineLatest<List<RoomSummary>, Option<GroupSummary>, List<RoomSummary>>(
session.rx().liveRoomSummaries().throttleLast(300, TimeUnit.MILLISECONDS),
selectedGroupStore.observe(),
BiFunction { rooms, selectedGroupOption ->
val selectedGroup = selectedGroupOption.orNull()
val filteredDirectRooms = rooms
.filter { it.isDirect }
.filter {
if (selectedGroup == null || selectedGroup.groupId == ALL_COMMUNITIES_GROUP_ID) {
true
} else {
it.otherMemberIds
.intersect(selectedGroup.userIds)
.isNotEmpty()
}
}
val filteredGroupRooms = rooms
.filter { !it.isDirect }
.filter {
selectedGroup?.groupId == ALL_COMMUNITIES_GROUP_ID
|| selectedGroup?.roomIds?.contains(it.roomId) ?: true
}
filteredDirectRooms + filteredGroupRooms
}
)
.subscribe {
homeRoomListStore.post(it)
}
.disposeOnClear()
}
@ -87,8 +105,6 @@ class HomeActivityViewModel(state: EmptyState,
session.createRoom(createRoomParams, object : MatrixCallback<String> {
override fun onSuccess(data: String) {
_isLoading.value = false
// Open room id
_openRoomLiveData.postValue(LiveEvent(data))
}
override fun onFailure(failure: Throwable) {

View File

@ -0,0 +1,154 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import androidx.core.view.forEachIndexed
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.bottomnavigation.BottomNavigationItemView
import com.google.android.material.bottomnavigation.BottomNavigationMenuView
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.core.platform.VectorBaseFragment
import im.vector.riotredesign.features.home.room.list.RoomListFragment
import im.vector.riotredesign.features.home.room.list.RoomListParams
import im.vector.riotredesign.features.home.room.list.UnreadCounterBadgeView
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_home_detail.*
@Parcelize
data class HomeDetailParams(
val groupId: String,
val groupName: String,
val groupAvatar: String
) : Parcelable
private const val CURRENT_DISPLAY_MODE = "CURRENT_DISPLAY_MODE"
private const val INDEX_CATCHUP = 0
private const val INDEX_PEOPLE = 1
private const val INDEX_ROOMS = 2
class HomeDetailFragment : VectorBaseFragment() {
private val params: HomeDetailParams by args()
private val unreadCounterBadgeViews = arrayListOf<UnreadCounterBadgeView>()
private lateinit var currentDisplayMode: RoomListFragment.DisplayMode
private val viewModel: HomeDetailViewModel by fragmentViewModel()
override fun getLayoutResId(): Int {
return R.layout.fragment_home_detail
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
currentDisplayMode = savedInstanceState?.getSerializable(CURRENT_DISPLAY_MODE) as? RoomListFragment.DisplayMode
?: RoomListFragment.DisplayMode.HOME
switchDisplayMode(currentDisplayMode)
setupBottomNavigationView()
setupToolbar()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putSerializable(CURRENT_DISPLAY_MODE, currentDisplayMode)
super.onSaveInstanceState(outState)
}
private fun setupToolbar() {
val parentActivity = vectorBaseActivity
if (parentActivity is ToolbarConfigurable) {
parentActivity.configure(groupToolbar)
}
groupToolbar.title = ""
AvatarRenderer.render(
params.groupAvatar,
params.groupId,
params.groupName,
groupToolbarAvatarImageView
)
groupToolbarAvatarImageView.setOnClickListener {
vectorBaseActivity.notImplemented("Group click in toolbar")
}
}
private fun setupBottomNavigationView() {
bottomNavigationView.setOnNavigationItemSelectedListener {
val displayMode = when (it.itemId) {
R.id.bottom_action_home -> RoomListFragment.DisplayMode.HOME
R.id.bottom_action_people -> RoomListFragment.DisplayMode.PEOPLE
R.id.bottom_action_rooms -> RoomListFragment.DisplayMode.ROOMS
else -> RoomListFragment.DisplayMode.HOME
}
if (currentDisplayMode != displayMode) {
currentDisplayMode = displayMode
switchDisplayMode(displayMode)
}
true
}
val menuView = bottomNavigationView.getChildAt(0) as BottomNavigationMenuView
menuView.forEachIndexed { index, view ->
val itemView = view as BottomNavigationItemView
val badgeLayout = LayoutInflater.from(requireContext()).inflate(R.layout.vector_home_badge_unread_layout, menuView, false)
val unreadCounterBadgeView: UnreadCounterBadgeView = badgeLayout.findViewById(R.id.actionUnreadCounterBadgeView)
itemView.addView(badgeLayout)
unreadCounterBadgeViews.add(index, unreadCounterBadgeView)
}
}
private fun switchDisplayMode(displayMode: RoomListFragment.DisplayMode) {
groupToolbarTitleView.setText(displayMode.titleRes)
updateSelectedFragment(displayMode)
}
private fun updateSelectedFragment(displayMode: RoomListFragment.DisplayMode) {
val fragmentTag = "FRAGMENT_TAG_${displayMode.name}"
var fragment = childFragmentManager.findFragmentByTag(fragmentTag)
if (fragment == null) {
fragment = RoomListFragment.newInstance(RoomListParams(displayMode))
}
childFragmentManager.beginTransaction()
.replace(R.id.roomListContainer, fragment, fragmentTag)
.addToBackStack(fragmentTag)
.commit()
}
override fun invalidate() = withState(viewModel) {
unreadCounterBadgeViews[INDEX_CATCHUP].render(UnreadCounterBadgeView.State(it.notificationCountCatchup, it.notificationHighlightCatchup))
unreadCounterBadgeViews[INDEX_PEOPLE].render(UnreadCounterBadgeView.State(it.notificationCountPeople, it.notificationHighlightPeople))
unreadCounterBadgeViews[INDEX_ROOMS].render(UnreadCounterBadgeView.State(it.notificationCountRooms, it.notificationHighlightRooms))
}
companion object {
fun newInstance(args: HomeDetailParams): HomeDetailFragment {
return HomeDetailFragment().apply {
setArguments(args)
}
}
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.riotredesign.core.platform.VectorViewModel
import org.koin.android.ext.android.get
/**
* View model used to update the home bottom bar notification counts
*/
class HomeDetailViewModel(initialState: HomeDetailViewState,
private val homeRoomListStore: HomeRoomListObservableStore)
: VectorViewModel<HomeDetailViewState>(initialState) {
companion object : MvRxViewModelFactory<HomeDetailViewModel, HomeDetailViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: HomeDetailViewState): HomeDetailViewModel? {
val homeRoomListStore = viewModelContext.activity.get<HomeRoomListObservableStore>()
return HomeDetailViewModel(state, homeRoomListStore)
}
}
init {
observeRoomSummaries()
}
// PRIVATE METHODS *****************************************************************************
private fun observeRoomSummaries() {
homeRoomListStore
.observe()
.subscribe { list ->
list.let { summaries ->
val peopleNotifications = summaries
.filter { it.isDirect }
.map { it.notificationCount }
.takeIf { it.isNotEmpty() }
?.reduce { acc, i -> acc + i }
?: 0
val peopleHasHighlight = summaries
.filter { it.isDirect }
.any { it.highlightCount > 0 }
val roomsNotifications = summaries
.filter { !it.isDirect }
.map { it.notificationCount }
.takeIf { it.isNotEmpty() }
?.reduce { acc, i -> acc + i }
?: 0
val roomsHasHighlight = summaries
.filter { !it.isDirect }
.any { it.highlightCount > 0 }
setState {
copy(
notificationCountCatchup = peopleNotifications + roomsNotifications,
notificationHighlightCatchup = peopleHasHighlight || roomsHasHighlight,
notificationCountPeople = peopleNotifications,
notificationHighlightPeople = peopleHasHighlight,
notificationCountRooms = roomsNotifications,
notificationHighlightRooms = roomsHasHighlight
)
}
}
}
.disposeOnClear()
}
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home
import com.airbnb.mvrx.MvRxState
data class HomeDetailViewState(
val notificationCountCatchup: Int = 0,
val notificationHighlightCatchup: Boolean = false,
val notificationCountPeople: Int = 0,
val notificationHighlightPeople: Boolean = false,
val notificationCountRooms: Int = 0,
val notificationHighlightRooms: Boolean = false
) : MvRxState

View File

@ -17,11 +17,14 @@
package im.vector.riotredesign.features.home
import android.os.Bundle
import im.vector.matrix.android.api.session.Session
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.observeK
import im.vector.riotredesign.core.extensions.replaceChildFragment
import im.vector.riotredesign.core.platform.VectorBaseFragment
import im.vector.riotredesign.features.home.group.GroupListFragment
import im.vector.riotredesign.features.home.room.list.RoomListFragment
import kotlinx.android.synthetic.main.fragment_home_drawer.*
import org.koin.android.ext.android.inject
class HomeDrawerFragment : VectorBaseFragment() {
@ -32,16 +35,31 @@ class HomeDrawerFragment : VectorBaseFragment() {
}
}
val session by inject<Session>()
override fun getLayoutResId() = R.layout.fragment_home_drawer
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (savedInstanceState == null) {
val groupListFragment = GroupListFragment.newInstance()
replaceChildFragment(groupListFragment, R.id.groupListFragmentContainer)
val roomListFragment = RoomListFragment.newInstance()
replaceChildFragment(roomListFragment, R.id.roomListFragmentContainer)
replaceChildFragment(groupListFragment, R.id.homeDrawerGroupListContainer)
}
session.observeUser(session.sessionParams.credentials.userId).observeK(this) { user ->
if (user != null) {
AvatarRenderer.render(user.avatarUrl, user.userId, user.displayName, homeDrawerHeaderAvatarView)
homeDrawerUsernameView.text = user.displayName
homeDrawerUserIdView.text = user.userId
}
}
homeDrawerHeaderSettingsView.setOnClickListener {
navigator.openSettings()
}
// Debug menu
homeDrawerHeaderDebugView.setOnClickListener {
navigator.openDebug()
}
}
}

View File

@ -18,6 +18,7 @@ package im.vector.riotredesign.features.home
import androidx.fragment.app.Fragment
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandController
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter
import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserController
@ -25,10 +26,12 @@ import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresent
import im.vector.riotredesign.features.home.group.GroupSummaryController
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.factory.*
import im.vector.riotredesign.features.home.room.detail.timeline.format.NoticeEventFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.list.RoomSummaryController
import im.vector.riotredesign.features.html.EventHtmlRenderer
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module.module
class HomeModule {
@ -36,8 +39,6 @@ class HomeModule {
companion object {
const val HOME_SCOPE = "HOME_SCOPE"
const val ROOM_DETAIL_SCOPE = "ROOM_DETAIL_SCOPE"
const val ROOM_LIST_SCOPE = "ROOM_LIST_SCOPE"
const val GROUP_LIST_SCOPE = "GROUP_LIST_SCOPE"
}
val definition = module {
@ -49,32 +50,38 @@ class HomeModule {
}
scope(HOME_SCOPE) {
HomePermalinkHandler(get())
HomePermalinkHandler(get(), get())
}
// Fragment scopes
factory { (fragment: Fragment) ->
val eventHtmlRenderer = EventHtmlRenderer(GlideApp.with(fragment), fragment.requireContext(), get())
val timelineDateFormatter = TimelineDateFormatter(get())
val timelineMediaSizeProvider = TimelineMediaSizeProvider()
val messageItemFactory = MessageItemFactory(get(), timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer)
factory {
TimelineDateFormatter(get())
}
val timelineItemFactory = TimelineItemFactory(messageItemFactory = messageItemFactory,
roomNameItemFactory = RoomNameItemFactory(get()),
roomTopicItemFactory = RoomTopicItemFactory(get()),
roomMemberItemFactory = RoomMemberItemFactory(get()),
roomHistoryVisibilityItemFactory = RoomHistoryVisibilityItemFactory(get()),
callItemFactory = CallItemFactory(get()),
factory {
NoticeEventFormatter(get())
}
factory { (fragment: Fragment) ->
val colorProvider = ColorProvider(fragment.requireContext())
val timelineDateFormatter = get<TimelineDateFormatter>()
val eventHtmlRenderer = EventHtmlRenderer(GlideApp.with(fragment), fragment.requireContext(), get())
val noticeEventFormatter = get<NoticeEventFormatter>(parameters = { parametersOf(fragment) })
val timelineMediaSizeProvider = TimelineMediaSizeProvider()
val messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer, get())
val timelineItemFactory = TimelineItemFactory(
messageItemFactory = messageItemFactory,
noticeItemFactory = NoticeItemFactory(noticeEventFormatter),
defaultItemFactory = DefaultItemFactory(),
encryptionItemFactory = EncryptionItemFactory(get()),
encryptedItemFactory = EncryptedItemFactory(get()),
defaultItemFactory = DefaultItemFactory()
encryptedItemFactory = EncryptedItemFactory(get())
)
TimelineEventController(timelineDateFormatter, timelineItemFactory, timelineMediaSizeProvider)
}
factory {
RoomSummaryController(get())
RoomSummaryController(get(), get(), get())
}
factory {

View File

@ -18,11 +18,10 @@ package im.vector.riotredesign.features.home
import androidx.core.view.GravityCompat
import androidx.fragment.app.FragmentManager
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.addFragmentToBackstack
import im.vector.riotredesign.core.extensions.replaceFragment
import im.vector.riotredesign.features.home.room.detail.RoomDetailArgs
import im.vector.riotredesign.features.home.room.detail.RoomDetailFragment
import im.vector.riotredesign.features.navigation.Navigator
import kotlinx.android.synthetic.main.activity_home.*
import timber.log.Timber
@ -32,22 +31,24 @@ class HomeNavigator {
private var rootRoomId: String? = null
fun openSelectedGroup(groupSummary: GroupSummary) {
Timber.v("Open selected group ${groupSummary.groupId}")
activity?.let {
val args = HomeDetailParams(groupSummary.groupId, groupSummary.displayName, groupSummary.avatarUrl)
val homeDetailFragment = HomeDetailFragment.newInstance(args)
it.drawerLayout?.closeDrawer(GravityCompat.START)
it.replaceFragment(homeDetailFragment, R.id.homeDetailFragmentContainer)
}
}
fun openRoomDetail(roomId: String,
eventId: String?,
addToBackstack: Boolean = false) {
Timber.v("Open room detail $roomId - $eventId - $addToBackstack")
navigator: Navigator) {
Timber.v("Open room detail $roomId - $eventId")
activity?.let {
//TODO enable eventId permalink. It doesn't work enough at the moment.
val args = RoomDetailArgs(roomId)
val roomDetailFragment = RoomDetailFragment.newInstance(args)
it.drawerLayout?.closeDrawer(GravityCompat.START)
if (addToBackstack) {
it.addFragmentToBackstack(roomDetailFragment, R.id.homeDetailFragmentContainer, roomId)
} else {
rootRoomId = roomId
clearBackStack(it.supportFragmentManager)
it.replaceFragment(roomDetailFragment, R.id.homeDetailFragmentContainer)
}
navigator.openRoom(roomId)
}
}

View File

@ -19,8 +19,10 @@ package im.vector.riotredesign.features.home
import android.net.Uri
import im.vector.matrix.android.api.permalinks.PermalinkData
import im.vector.matrix.android.api.permalinks.PermalinkParser
import im.vector.riotredesign.features.navigation.Navigator
class HomePermalinkHandler(private val navigator: HomeNavigator) {
class HomePermalinkHandler(private val homeNavigator: HomeNavigator,
private val navigator: Navigator) {
fun launch(deepLink: String?) {
val uri = deepLink?.let { Uri.parse(it) }
@ -34,16 +36,16 @@ class HomePermalinkHandler(private val navigator: HomeNavigator) {
val permalinkData = PermalinkParser.parse(deepLink)
when (permalinkData) {
is PermalinkData.EventLink -> {
navigator.openRoomDetail(permalinkData.roomIdOrAlias, permalinkData.eventId, true)
homeNavigator.openRoomDetail(permalinkData.roomIdOrAlias, permalinkData.eventId, navigator)
}
is PermalinkData.RoomLink -> {
navigator.openRoomDetail(permalinkData.roomIdOrAlias, null, true)
homeNavigator.openRoomDetail(permalinkData.roomIdOrAlias, null, navigator)
}
is PermalinkData.GroupLink -> {
navigator.openGroupDetail(permalinkData.groupId)
homeNavigator.openGroupDetail(permalinkData.groupId)
}
is PermalinkData.UserLink -> {
navigator.openUserDetail(permalinkData.userId)
homeNavigator.openUserDetail(permalinkData.userId)
}
is PermalinkData.FallbackLink -> {

View File

@ -0,0 +1,35 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.core.utils.RxStore
import im.vector.riotredesign.features.home.room.list.RoomListDisplayModeFilter
import im.vector.riotredesign.features.home.room.list.RoomListFragment
import io.reactivex.Observable
class HomeRoomListObservableStore : RxStore<List<RoomSummary>>() {
fun observeFilteredBy(displayMode: RoomListFragment.DisplayMode): Observable<List<RoomSummary>> {
return observe()
.flatMapSingle {
Observable.fromIterable(it).filter(RoomListDisplayModeFilter(displayMode)).toList()
}
}
}

View File

@ -0,0 +1,49 @@
/*
*
* * Copyright 2019 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 im.vector.riotredesign.features.home
import android.graphics.drawable.AnimationDrawable
import android.os.Bundle
import android.view.View
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_loading.*
class LoadingFragment : VectorBaseFragment() {
companion object {
fun newInstance(): LoadingFragment {
return LoadingFragment()
}
}
override fun getLayoutResId() = R.layout.fragment_loading
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val background = animatedLogoImageView.background
if (background is AnimationDrawable) {
background.start()
}
}
}

View File

@ -0,0 +1,49 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home
import androidx.annotation.ColorRes
import im.vector.riotredesign.R
@ColorRes
fun getColorFromUserId(userId: String?): Int {
if (userId.isNullOrBlank()) {
return R.color.riotx_username_1
}
var hash = 0
var i = 0
var chr: Char
while (i < userId.length) {
chr = userId[i]
hash = (hash shl 5) - hash + chr.toInt()
i++
}
return when (Math.abs(hash) % 8 + 1) {
1 -> R.color.riotx_username_1
2 -> R.color.riotx_username_2
3 -> R.color.riotx_username_3
4 -> R.color.riotx_username_4
5 -> R.color.riotx_username_5
6 -> R.color.riotx_username_6
7 -> R.color.riotx_username_7
else -> R.color.riotx_username_8
}
}

View File

@ -22,13 +22,12 @@ import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.platform.StateView
import im.vector.riotredesign.core.platform.VectorBaseFragment
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomeNavigator
import kotlinx.android.synthetic.main.fragment_group_list.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
class GroupListFragment : VectorBaseFragment(), GroupSummaryController.Callback {
@ -39,17 +38,20 @@ class GroupListFragment : VectorBaseFragment(), GroupSummaryController.Callback
}
private val viewModel: GroupListViewModel by fragmentViewModel()
private val homeNavigator by inject<HomeNavigator>()
private val groupController by inject<GroupSummaryController>()
override fun getLayoutResId() = R.layout.fragment_group_list
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
bindScope(getOrCreateScope(HomeModule.GROUP_LIST_SCOPE))
groupController.callback = this
stateView.contentView = epoxyRecyclerView
epoxyRecyclerView.setController(groupController)
viewModel.subscribe { renderState(it) }
viewModel.openGroupLiveData.observeEvent(this) {
homeNavigator.openSelectedGroup(it)
}
}
private fun renderState(state: GroupListViewState) {

View File

@ -16,17 +16,26 @@
package im.vector.riotredesign.features.home.group
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import arrow.core.Option
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.rx.rx
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.VectorViewModel
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.core.utils.LiveEvent
import org.koin.android.ext.android.get
const val ALL_COMMUNITIES_GROUP_ID = "ALL_COMMUNITIES_GROUP_ID"
class GroupListViewModel(initialState: GroupListViewState,
private val selectedGroupHolder: SelectedGroupStore,
private val session: Session
private val session: Session,
private val stringProvider: StringProvider
) : VectorViewModel<GroupListViewState>(initialState) {
companion object : MvRxViewModelFactory<GroupListViewModel, GroupListViewState> {
@ -35,19 +44,27 @@ class GroupListViewModel(initialState: GroupListViewState,
override fun create(viewModelContext: ViewModelContext, state: GroupListViewState): GroupListViewModel? {
val currentSession = viewModelContext.activity.get<Session>()
val selectedGroupHolder = viewModelContext.activity.get<SelectedGroupStore>()
return GroupListViewModel(state, selectedGroupHolder, currentSession)
val stringProvider = viewModelContext.activity.get<StringProvider>()
return GroupListViewModel(state, selectedGroupHolder, currentSession, stringProvider)
}
}
private val _openGroupLiveData = MutableLiveData<LiveEvent<GroupSummary>>()
val openGroupLiveData: LiveData<LiveEvent<GroupSummary>>
get() = _openGroupLiveData
init {
observeGroupSummaries()
observeState()
observeSelectionState()
}
private fun observeState() {
subscribe {
val selectedGroup = Option.fromNullable(it.selectedGroup)
selectedGroupHolder.post(selectedGroup)
private fun observeSelectionState() {
selectSubscribe(GroupListViewState::selectedGroup) {
if (it != null) {
_openGroupLiveData.postValue(LiveEvent(it))
val optionGroup = Option.fromNullable(it)
selectedGroupHolder.post(optionGroup)
}
}
}
@ -62,17 +79,23 @@ class GroupListViewModel(initialState: GroupListViewState,
private fun handleSelectGroup(action: GroupListActions.SelectGroup) = withState { state ->
if (state.selectedGroup?.groupId != action.groupSummary.groupId) {
setState { copy(selectedGroup = action.groupSummary) }
} else {
setState { copy(selectedGroup = null) }
}
}
private fun observeGroupSummaries() {
session
.rx().liveGroupSummaries()
.map {
val myUser = session.getUser(session.sessionParams.credentials.userId)
val allCommunityGroup = GroupSummary(
groupId = ALL_COMMUNITIES_GROUP_ID,
displayName = stringProvider.getString(R.string.group_all_communities),
avatarUrl = myUser?.avatarUrl ?: "")
listOf(allCommunityGroup) + it
}
.execute { async ->
copy(asyncGroups = async)
val newSelectedGroup = selectedGroup ?: async()?.firstOrNull()
copy(asyncGroups = async, selectedGroup = newSelectedGroup)
}
}

View File

@ -17,12 +17,13 @@
package im.vector.riotredesign.features.home.group
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.platform.CheckableFrameLayout
import im.vector.riotredesign.core.platform.CheckableConstraintLayout
import im.vector.riotredesign.features.home.AvatarRenderer
@EpoxyModelClass(layout = R.layout.item_group)
@ -36,14 +37,16 @@ abstract class GroupSummaryItem : VectorEpoxyModel<GroupSummaryItem.Holder>() {
override fun bind(holder: Holder) {
super.bind(holder)
holder.rootView.isSelected = selected
holder.rootView.setOnClickListener { listener?.invoke() }
holder.groupNameView.text = groupName
holder.rootView.isChecked = selected
AvatarRenderer.render(avatarUrl, groupId, groupName.toString(), holder.avatarImageView)
}
class Holder : VectorEpoxyHolder() {
val avatarImageView by bind<ImageView>(R.id.groupAvatarImageView)
val rootView by bind<CheckableFrameLayout>(R.id.itemGroupLayout)
val groupNameView by bind<TextView>(R.id.groupNameView)
val rootView by bind<CheckableConstraintLayout>(R.id.itemGroupLayout)
}
}

View File

@ -1,47 +0,0 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home.room.detail
import android.graphics.drawable.AnimationDrawable
import android.os.Bundle
import android.view.View
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_loading_room_detail.*
class LoadingRoomDetailFragment : VectorBaseFragment() {
companion object {
fun newInstance(): LoadingRoomDetailFragment {
return LoadingRoomDetailFragment()
}
}
override fun getLayoutResId() = R.layout.fragment_loading_room_detail
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val background = animatedLogoImageView.background
if (background is AnimationDrawable) {
background.start()
}
}
}

View File

@ -17,15 +17,27 @@
package im.vector.riotredesign.features.home.room.detail
import com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
sealed class RoomDetailActions {
data class SendMessage(val text: String) : RoomDetailActions()
data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
object IsDisplayed : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()
data class SendReaction(val reaction: String, val targetEventId: String) : RoomDetailActions()
data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailActions()
data class UndoReaction(val targetEventId: String, val key: String, val reason: String? = "") : RoomDetailActions()
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val opposite: String) : RoomDetailActions()
data class ShowEditHistoryAction(val event: String, val editAggregatedSummary: EditAggregatedSummary) : RoomDetailActions()
object AcceptInvite : RoomDetailActions()
object RejectInvite : RoomDetailActions()
data class EnterEditMode(val eventId: String) : RoomDetailActions()
data class EnterQuoteMode(val eventId: String) : RoomDetailActions()
data class EnterReplyMode(val eventId: String) : RoomDetailActions()
}

View File

@ -0,0 +1,63 @@
/*
*
* * Copyright 2019 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 im.vector.riotredesign.features.home.room.detail
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.widget.Toolbar
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.replaceFragment
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.core.platform.VectorBaseActivity
class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {
override fun getLayoutRes(): Int {
return R.layout.activity_room_detail
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (isFirstCreation()) {
val roomDetailArgs: RoomDetailArgs = intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS)
?: return
val roomDetailFragment = RoomDetailFragment.newInstance(roomDetailArgs)
replaceFragment(roomDetailFragment, R.id.roomDetailContainer)
}
}
override fun configure(toolbar: Toolbar) {
configureToolbar(toolbar)
}
companion object {
private const val EXTRA_ROOM_DETAIL_ARGS = "EXTRA_ROOM_DETAIL_ARGS"
fun newIntent(context: Context, roomDetailArgs: RoomDetailArgs): Intent {
return Intent(context, RoomDetailActivity::class.java).apply {
putExtra(EXTRA_ROOM_DETAIL_ARGS, roomDetailArgs)
}
}
}
}

View File

@ -40,8 +40,9 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import butterknife.BindView
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.ImageLoader
@ -53,15 +54,18 @@ import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.extensions.hideKeyboard
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.core.platform.VectorBaseFragment
import im.vector.riotredesign.core.utils.*
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter
@ -71,7 +75,9 @@ import im.vector.riotredesign.features.command.Command
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomePermalinkHandler
import im.vector.riotredesign.features.home.getColorFromUserId
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerActions
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerView
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewModel
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewState
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
@ -81,17 +87,23 @@ import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageM
import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.html.PillImageSpan
import im.vector.riotredesign.features.invite.VectorInviteView
import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.ImageMediaViewerActivity
import im.vector.riotredesign.features.media.VideoContentRenderer
import im.vector.riotredesign.features.media.VideoMediaViewerActivity
import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity
import im.vector.riotredesign.features.settings.PreferencesManager
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.*
import kotlinx.android.synthetic.main.merge_composer_layout.view.*
import org.commonmark.parser.Parser
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
import org.koin.core.parameter.parametersOf
import ru.noties.markwon.Markwon
import ru.noties.markwon.html.HtmlPlugin
import timber.log.Timber
import java.io.File
@ -106,11 +118,13 @@ data class RoomDetailArgs(
private const val CAMERA_VALUE_TITLE = "attachment"
private const val REQUEST_FILES_REQUEST_CODE = 0
private const val TAKE_IMAGE_REQUEST_CODE = 1
private const val REACTION_SELECT_REQUEST_CODE = 2
class RoomDetailFragment :
VectorBaseFragment(),
TimelineEventController.Callback,
AutocompleteUserPresenter.Callback {
AutocompleteUserPresenter.Callback,
VectorInviteView.Callback {
companion object {
@ -127,13 +141,12 @@ class RoomDetailFragment :
* @return the sanitized display name
*/
fun sanitizeDisplayname(displayName: String): String? {
var displayName = displayName
// sanity checks
if (!TextUtils.isEmpty(displayName)) {
val ircPattern = " (IRC)"
if (displayName.endsWith(ircPattern)) {
displayName = displayName.substring(0, displayName.length - ircPattern.length)
return displayName.substring(0, displayName.length - ircPattern.length)
}
}
@ -141,6 +154,7 @@ class RoomDetailFragment :
}
}
private val roomDetailArgs: RoomDetailArgs by args()
private val session by inject<Session>()
private val glideRequests by lazy {
GlideApp.with(this)
@ -149,6 +163,7 @@ class RoomDetailFragment :
private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel()
private val textComposerViewModel: TextComposerViewModel by fragmentViewModel()
private val timelineEventController: TimelineEventController by inject { parametersOf(this) }
private val commandAutocompletePolicy = CommandAutocompletePolicy()
private val autocompleteCommandPresenter: AutocompleteCommandPresenter by inject { parametersOf(this) }
private val autocompleteUserPresenter: AutocompleteUserPresenter by inject { parametersOf(this) }
private val homePermalinkHandler: HomePermalinkHandler by inject()
@ -159,21 +174,101 @@ class RoomDetailFragment :
private lateinit var actionViewModel: ActionsHandler
@BindView(R.id.composerLayout)
lateinit var composerLayout: TextComposerView
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
actionViewModel = ViewModelProviders.of(requireActivity()).get(ActionsHandler::class.java)
bindScope(getOrCreateScope(HomeModule.ROOM_DETAIL_SCOPE))
setupToolbar(roomToolbar)
setupRecyclerView()
setupToolbar()
setupComposer()
setupAttachmentButton()
setupInviteView()
roomDetailViewModel.subscribe { renderState(it) }
textComposerViewModel.subscribe { renderTextComposerState(it) }
roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) }
roomDetailViewModel.nonBlockingPopAlert.observe(this, Observer { liveEvent ->
liveEvent.getContentIfNotHandled()?.let {
val message = requireContext().getString(it.first, *it.second.toTypedArray())
showSnackWithMessage(message, Snackbar.LENGTH_LONG)
}
})
actionViewModel.actionCommandEvent.observe(this, Observer {
handleActions(it)
})
roomDetailViewModel.selectSubscribe(
RoomDetailViewState::sendMode,
RoomDetailViewState::selectedEvent,
RoomDetailViewState::roomId) { mode, event, roomId ->
when (mode) {
SendMode.REGULAR -> {
commandAutocompletePolicy.enabled = true
val uid = session.sessionParams.credentials.userId
val meMember = session.getRoom(roomId)?.getRoomMember(uid)
AvatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView)
composerLayout.collapse()
}
SendMode.EDIT,
SendMode.QUOTE,
SendMode.REPLY -> {
commandAutocompletePolicy.enabled = false
if (event == null) {
//we should ignore? can this happen?
Timber.e("Enter edit mode with no event selected")
return@selectSubscribe
}
//switch to expanded bar
composerLayout.composerRelatedMessageTitle.apply {
text = event.senderName
setTextColor(ContextCompat.getColor(requireContext(), getColorFromUserId(event.root.sender)))
}
//TODO this is used at several places, find way to refactor?
val messageContent: MessageContent? =
event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel()
val nonFormattedBody = messageContent?.body ?: ""
var formattedBody: CharSequence? = null
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody ?: messageContent.body)
formattedBody = Markwon.builder(requireContext())
.usePlugin(HtmlPlugin.create()).build().render(document)
}
composerLayout.composerRelatedMessageContent.text = formattedBody ?: nonFormattedBody
if (mode == SendMode.EDIT) {
//TODO if it's a reply we should trim the top part of message
composerLayout.composerEditText.setText(nonFormattedBody)
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_edit))
} else if (mode == SendMode.QUOTE) {
composerLayout.composerEditText.setText("")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_quote))
} else if (mode == SendMode.REPLY) {
composerLayout.composerEditText.setText("")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_reply))
}
AvatarRenderer.render(event.senderAvatar, event.root.sender
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
composerLayout.expand {
focusComposerAndShowKeyboard()
}
composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
composerLayout.composerEditText.setText("")
roomDetailViewModel.resetSendMode()
}
}
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@ -181,24 +276,20 @@ class RoomDetailFragment :
if (resultCode == RESULT_OK && data != null) {
when (requestCode) {
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
REACTION_SELECT_REQUEST_CODE -> {
val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID)
?: return
val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT)
?: return
//TODO check if already reacted with that?
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId))
}
}
}
}
override fun onResume() {
super.onResume()
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
}
// PRIVATE METHODS *****************************************************************************
private fun setupToolbar() {
val parentActivity = vectorBaseActivity
if (parentActivity is ToolbarConfigurable) {
parentActivity.configure(toolbar)
}
}
private fun setupRecyclerView() {
val epoxyVisibilityTracker = EpoxyVisibilityTracker()
epoxyVisibilityTracker.attach(recyclerView)
@ -224,8 +315,8 @@ class RoomDetailFragment :
private fun setupComposer() {
val elevation = 6f
val backgroundDrawable = ColorDrawable(Color.WHITE)
Autocomplete.on<Command>(composerEditText)
.with(CommandAutocompletePolicy())
Autocomplete.on<Command>(composerLayout.composerEditText)
.with(commandAutocompletePolicy)
.with(autocompleteCommandPresenter)
.with(elevation)
.with(backgroundDrawable)
@ -244,7 +335,7 @@ class RoomDetailFragment :
.build()
autocompleteUserPresenter.callback = this
Autocomplete.on<User>(composerEditText)
Autocomplete.on<User>(composerLayout.composerEditText)
.with(CharPolicy('@', true))
.with(autocompleteUserPresenter)
.with(elevation)
@ -272,7 +363,7 @@ class RoomDetailFragment :
// Add the span
val user = session.getUser(item.userId)
val span = PillImageSpan(glideRequests, context!!, item.userId, user)
span.bind(composerEditText)
span.bind(composerLayout.composerEditText)
editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
@ -284,16 +375,16 @@ class RoomDetailFragment :
})
.build()
sendButton.setOnClickListener {
val textMessage = composerEditText.text.toString()
composerLayout.sendButton.setOnClickListener {
val textMessage = composerLayout.composerEditText.text.toString()
if (textMessage.isNotBlank()) {
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage))
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage, PreferencesManager.isMarkdownEnabled(requireContext())))
}
}
}
private fun setupAttachmentButton() {
attachmentButton.setOnClickListener {
composerLayout.attachmentButton.setOnClickListener {
val intent = Intent(requireContext(), FilePickerActivity::class.java)
intent.putExtra(FilePickerActivity.CONFIGS, Configurations.Builder()
.setCheckPermission(true)
@ -334,27 +425,31 @@ class RoomDetailFragment :
}
}
private fun setupInviteView() {
inviteView.callback = this
}
private fun onSendChoiceClicked(dialogListItem: DialogListItem) {
Timber.v("On send choice clicked: $dialogListItem")
when (dialogListItem) {
is DialogListItem.SendFile -> {
is DialogListItem.SendFile -> {
// launchFileIntent
}
is DialogListItem.SendVoice -> {
is DialogListItem.SendVoice -> {
//launchAudioRecorderIntent()
}
is DialogListItem.SendSticker -> {
is DialogListItem.SendSticker -> {
//startStickerPickerActivity()
}
is DialogListItem.TakePhotoVideo ->
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
// launchCamera()
}
is DialogListItem.TakePhoto ->
is DialogListItem.TakePhoto ->
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA)) {
openCamera(requireActivity(), CAMERA_VALUE_TITLE, TAKE_IMAGE_REQUEST_CODE)
}
is DialogListItem.TakeVideo ->
is DialogListItem.TakeVideo ->
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA)) {
// launchNativeVideoRecorder()
}
@ -368,18 +463,33 @@ class RoomDetailFragment :
private fun renderState(state: RoomDetailViewState) {
renderRoomSummary(state)
timelineEventController.setTimeline(state.timeline)
val summary = state.asyncRoomSummary()
val inviter = state.asyncInviter()
if (summary?.membership == Membership.JOIN) {
timelineEventController.setTimeline(state.timeline)
inviteView.visibility = View.GONE
val uid = session.sessionParams.credentials.userId
val meMember = session.getRoom(state.roomId)?.getRoomMember(uid)
AvatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView)
} else if (summary?.membership == Membership.INVITE && inviter != null) {
inviteView.visibility = View.VISIBLE
inviteView.render(inviter, VectorInviteView.Mode.LARGE)
} else if (state.asyncInviter.complete) {
vectorBaseActivity.finish()
}
}
private fun renderRoomSummary(state: RoomDetailViewState) {
state.asyncRoomSummary()?.let {
toolbarTitleView.text = it.displayName
AvatarRenderer.render(it, toolbarAvatarImageView)
roomToolbarTitleView.text = it.displayName
AvatarRenderer.render(it, roomToolbarAvatarImageView)
if (it.topic.isNotEmpty()) {
toolbarSubtitleView.visibility = View.VISIBLE
toolbarSubtitleView.text = it.topic
roomToolbarSubtitleView.visibility = View.VISIBLE
roomToolbarSubtitleView.text = it.topic
} else {
toolbarSubtitleView.visibility = View.GONE
roomToolbarSubtitleView.visibility = View.GONE
}
}
}
@ -391,20 +501,20 @@ class RoomDetailFragment :
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) {
is SendMessageResult.MessageSent,
is SendMessageResult.SlashCommandHandled -> {
is SendMessageResult.SlashCommandHandled -> {
// Clear composer
composerEditText.text = null
composerLayout.composerEditText.text = null
}
is SendMessageResult.SlashCommandError -> {
is SendMessageResult.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
}
is SendMessageResult.SlashCommandUnknown -> {
is SendMessageResult.SlashCommandUnknown -> {
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
}
is SendMessageResult.SlashCommandResultOk -> {
is SendMessageResult.SlashCommandResultOk -> {
// Ignore
}
is SendMessageResult.SlashCommandResultError -> {
is SendMessageResult.SlashCommandResultError -> {
displayCommandError(sendMessageResult.throwable.localizedMessage)
}
is SendMessageResult.SlashCommandNotImplemented -> {
@ -455,11 +565,9 @@ class RoomDetailFragment :
override fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
val roomId = (arguments?.get(MvRx.KEY_ARG) as? RoomDetailArgs)?.roomId
if (roomId.isNullOrBlank()) {
Timber.e("Missing RoomId, cannot open bottomsheet")
return false
}
val roomId = roomDetailArgs.roomId
this.view?.hideKeyboard()
MessageActionsBottomSheet
.newInstance(roomId, informationData)
.show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS")
@ -474,6 +582,23 @@ class RoomDetailFragment :
override fun onMemberNameClicked(informationData: MessageInformationData) {
insertUserDisplayNameInTextEditor(informationData.memberName?.toString())
}
override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) {
if (on) {
//we should test the current real state of reaction on this event
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, informationData.eventId))
} else {
//I need to redact a reaction
roomDetailViewModel.process(RoomDetailActions.UndoReaction(informationData.eventId, reaction))
}
}
override fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?) {
editAggregatedSummary?.also {
roomDetailViewModel.process(RoomDetailActions.ShowEditHistoryAction(informationData.eventId, it))
}
}
// AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) {
@ -484,17 +609,22 @@ class RoomDetailFragment :
it?.getContentIfNotHandled()?.let { actionData ->
when (actionData.actionId) {
MessageMenuViewModel.ACTION_ADD_REACTION -> {
startActivityForResult(EmojiReactionPickerActivity.intent(requireContext()), 0)
MessageMenuViewModel.ACTION_ADD_REACTION -> {
val eventId = actionData.data?.toString() ?: return
startActivityForResult(EmojiReactionPickerActivity.intent(requireContext(), eventId), REACTION_SELECT_REQUEST_CODE)
}
MessageMenuViewModel.ACTION_COPY -> {
MessageMenuViewModel.ACTION_COPY -> {
//I need info about the current selected message :/
copyToClipboard(requireContext(), actionData.data?.toString() ?: "", false)
val snack = Snackbar.make(view!!, requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT)
snack.view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.notification_accent_color))
snack.show()
}
MessageMenuViewModel.ACTION_SHARE -> {
MessageMenuViewModel.ACTION_DELETE -> {
val eventId = actionData.data?.toString() ?: return
roomDetailViewModel.process(RoomDetailActions.RedactAction(eventId, context?.getString(R.string.event_redacted_by_user_reason)))
}
MessageMenuViewModel.ACTION_SHARE -> {
//TODO current data communication is too limited
//Need to now the media type
actionData.data?.toString()?.let {
@ -537,7 +667,25 @@ class RoomDetailFragment :
.setPositiveButton(R.string.ok) { dialog, id -> dialog.cancel() }
.show()
}
else -> {
MessageMenuViewModel.ACTION_QUICK_REACT -> {
//eventId,ClickedOn,Opposite
(actionData.data as? Triple<String, String, String>)?.let { (eventId, clickedOn, opposite) ->
roomDetailViewModel.process(RoomDetailActions.UpdateQuickReactAction(eventId, clickedOn, opposite))
}
}
MessageMenuViewModel.ACTION_EDIT -> {
val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.EnterEditMode(eventId))
}
MessageMenuViewModel.ACTION_QUOTE -> {
val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(eventId))
}
MessageMenuViewModel.ACTION_REPLY -> {
val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
}
else -> {
Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show()
}
}
@ -550,28 +698,30 @@ class RoomDetailFragment :
*
* @param text the text to insert.
*/
//TODO legacy, refactor
private fun insertUserDisplayNameInTextEditor(text: String?) {
//TODO move logic outside of fragment
if (null != text) {
// var vibrate = false
val myDisplayName = session.getUser(session.sessionParams.credentials.userId)?.displayName
if (TextUtils.equals(myDisplayName, text)) {
// current user
if (TextUtils.isEmpty(composerEditText.text)) {
composerEditText.append(Command.EMOTE.command + " ")
composerEditText.setSelection(composerEditText.text.length)
if (TextUtils.isEmpty(composerLayout.composerEditText.text)) {
composerLayout.composerEditText.append(Command.EMOTE.command + " ")
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
// vibrate = true
}
} else {
// another user
if (TextUtils.isEmpty(composerEditText.text)) {
if (TextUtils.isEmpty(composerLayout.composerEditText.text)) {
// Ensure displayName will not be interpreted as a Slash command
if (text.startsWith("/")) {
composerEditText.append("\\")
composerLayout.composerEditText.append("\\")
}
composerEditText.append(sanitizeDisplayname(text)!! + ": ")
composerLayout.composerEditText.append(sanitizeDisplayname(text)!! + ": ")
} else {
composerEditText.text.insert(composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ")
composerLayout.composerEditText.text.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ")
}
// vibrate = true
@ -583,10 +733,29 @@ class RoomDetailFragment :
// v.vibrate(100)
// }
// }
composerEditText.requestFocus()
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(composerEditText, InputMethodManager.SHOW_FORCED)
focusComposerAndShowKeyboard()
}
}
private fun focusComposerAndShowKeyboard() {
composerLayout.composerEditText.requestFocus()
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(composerLayout.composerEditText, InputMethodManager.SHOW_IMPLICIT)
}
fun showSnackWithMessage(message: String, duration: Int = Snackbar.LENGTH_SHORT) {
val snack = Snackbar.make(view!!, message, duration)
snack.view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.notification_accent_color))
snack.show()
}
// VectorInviteView.Callback
override fun onAcceptInvite() {
roomDetailViewModel.process(RoomDetailActions.AcceptInvite)
}
override fun onRejectInvite() {
roomDetailViewModel.process(RoomDetailActions.RejectInvite)
}
}

View File

@ -16,29 +16,39 @@
package im.vector.riotredesign.features.home.room.detail
import android.text.TextUtils
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.rx.rx
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.VectorViewModel
import im.vector.riotredesign.core.utils.LiveEvent
import im.vector.riotredesign.features.command.CommandParser
import im.vector.riotredesign.features.command.ParsedCommand
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import io.reactivex.rxkotlin.subscribeBy
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.koin.android.ext.android.get
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
class RoomDetailViewModel(initialState: RoomDetailViewState,
private val session: Session,
private val visibleRoomHolder: VisibleRoomStore
private val session: Session
) : VectorViewModel<RoomDetailViewState>(initialState) {
private val room = session.getRoom(initialState.roomId)!!
@ -54,14 +64,14 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: RoomDetailViewState): RoomDetailViewModel? {
val currentSession = viewModelContext.activity.get<Session>()
val visibleRoomHolder = viewModelContext.activity.get<VisibleRoomStore>()
return RoomDetailViewModel(state, currentSession, visibleRoomHolder)
return RoomDetailViewModel(state, currentSession)
}
}
init {
observeRoomSummary()
observeEventDisplayedActions()
observeInvitationState()
room.loadRoomMembersIfNeeded()
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
@ -69,14 +79,46 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
fun process(action: RoomDetailActions) {
when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
is RoomDetailActions.SendReaction -> handleSendReaction(action)
is RoomDetailActions.AcceptInvite -> handleAcceptInvite()
is RoomDetailActions.RejectInvite -> handleRejectInvite()
is RoomDetailActions.RedactAction -> handleRedactEvent(action)
is RoomDetailActions.UndoReaction -> handleUndoReact(action)
is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action)
is RoomDetailActions.ShowEditHistoryAction -> handleShowEditHistoryReaction(action)
is RoomDetailActions.EnterEditMode -> handleEditAction(action)
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
}
}
fun enterEditMode(event: TimelineEvent) {
setState {
copy(
sendMode = SendMode.EDIT,
selectedEvent = event
)
}
}
fun resetSendMode() {
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
)
}
}
private val _nonBlockingPopAlert = MutableLiveData<LiveEvent<Pair<Int, List<Any>>>>()
val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>>
get() = _nonBlockingPopAlert
private val _sendMessageResultLiveData = MutableLiveData<LiveEvent<SendMessageResult>>()
val sendMessageResultLiveData: LiveData<LiveEvent<SendMessageResult>>
get() = _sendMessageResultLiveData
@ -84,73 +126,163 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
// PRIVATE METHODS *****************************************************************************
private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
// Handle slash command
val slashCommandResult = CommandParser.parseSplashCommand(action.text)
withState { state ->
when (state.sendMode) {
SendMode.REGULAR -> {
val slashCommandResult = CommandParser.parseSplashCommand(action.text)
when (slashCommandResult) {
is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room
room.sendTextMessage(action.text)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
is ParsedCommand.ErrorSyntax -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)))
}
is ParsedCommand.ErrorEmptySlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/")))
}
is ParsedCommand.ErrorUnknownSlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand)))
}
is ParsedCommand.Invite -> {
handleInviteSlashCommand(slashCommandResult)
}
is ParsedCommand.SetUserPowerLevel -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.ClearScalarToken -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SetMarkdown -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.UnbanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.BanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.KickUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.JoinRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.PartRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
}
is ParsedCommand.ChangeTopic -> {
handleChangeTopicSlashCommand(slashCommandResult)
}
is ParsedCommand.ChangeDisplayName -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
when (slashCommandResult) {
is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
is ParsedCommand.ErrorSyntax -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)))
}
is ParsedCommand.ErrorEmptySlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/")))
}
is ParsedCommand.ErrorUnknownSlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand)))
}
is ParsedCommand.Invite -> {
handleInviteSlashCommand(slashCommandResult)
}
is ParsedCommand.SetUserPowerLevel -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.ClearScalarToken -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SetMarkdown -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.UnbanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.BanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.KickUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.JoinRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.PartRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
}
is ParsedCommand.ChangeTopic -> {
handleChangeTopicSlashCommand(slashCommandResult)
}
is ParsedCommand.ChangeDisplayName -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
}
}
SendMode.EDIT -> {
room.editTextMessage(state.selectedEvent?.root?.eventId ?: "", action.text, action.autoMarkdown)
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
)
}
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
SendMode.QUOTE -> {
val messageContent: MessageContent? =
state.selectedEvent?.annotations?.editSummary?.aggregatedContent?.toModel()
?: state.selectedEvent?.root?.content.toModel()
val textMsg = messageContent?.body
val finalText = legacyRiotQuoteText(textMsg, action.text)
//TODO Refactor this, just temporary for quotes
val parser = Parser.builder().build()
val document = parser.parse(finalText)
val renderer = HtmlRenderer.builder().build()
val htmlText = renderer.render(document)
if (TextUtils.equals(finalText, htmlText)) {
room.sendTextMessage(finalText)
} else {
room.sendFormattedTextMessage(finalText, htmlText)
}
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
)
}
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
SendMode.REPLY -> {
state.selectedEvent?.let {
room.replyToMessage(it.root, action.text)
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
)
}
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
}
}
}
// Handle slash command
}
private fun legacyRiotQuoteText(quotedText: String?, myText: String): String {
val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray()
var quotedTextMsg = StringBuilder()
if (messageParagraphs != null) {
for (i in messageParagraphs.indices) {
if (messageParagraphs[i].trim({ it <= ' ' }) != "") {
quotedTextMsg.append("> ").append(messageParagraphs[i])
}
if (i + 1 != messageParagraphs.size) {
quotedTextMsg.append("\n\n")
}
}
}
val finalText = "$quotedTextMsg\n\n$myText"
return finalText
}
private fun handleShowEditHistoryReaction(action: RoomDetailActions.ShowEditHistoryAction) {
//TODO temporary implementation
val lastReplace = action.editAggregatedSummary.sourceEvents.lastOrNull()?.let {
room.getTimeLineEvent(it)
} ?: return
val dateFormat = SimpleDateFormat("EEE, d MMM yyyy HH:mm", Locale.getDefault())
_nonBlockingPopAlert.postValue(LiveEvent(
Pair(R.string.last_edited_info_message, listOf(
lastReplace.senderName ?: "?",
dateFormat.format(Date(lastReplace.root.originServerTs ?: 0)))
))
)
}
private fun handleChangeTopicSlashCommand(changeTopic: ParsedCommand.ChangeTopic) {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
@ -179,6 +311,26 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
})
}
private fun handleSendReaction(action: RoomDetailActions.SendReaction) {
room.sendReaction(action.reaction, action.targetEventId)
}
private fun handleRedactEvent(action: RoomDetailActions.RedactAction) {
val event = room.getTimeLineEvent(action.targetEventId) ?: return
room.redactEvent(event.root, action.reason)
}
private fun handleUndoReact(action: RoomDetailActions.UndoReaction) {
room.undoReaction(action.key, action.targetEventId, session.sessionParams.credentials.userId)
}
private fun handleUpdateQuickReaction(action: RoomDetailActions.UpdateQuickReactAction) {
room.updateQuickReaction(action.selectedReaction, action.opposite, action.targetEventId, session.sessionParams.credentials.userId)
}
private fun handleSendMedia(action: RoomDetailActions.SendMedia) {
val attachments = action.mediaFiles.map {
ContentAttachmentData(
@ -200,14 +352,47 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
displayedEventsObservable.accept(action)
}
private fun handleIsDisplayed() {
visibleRoomHolder.post(roomId)
}
private fun handleLoadMore(action: RoomDetailActions.LoadMore) {
timeline.paginate(action.direction, PAGINATION_COUNT)
}
private fun handleRejectInvite() {
room.leave(object : MatrixCallback<Unit> {})
}
private fun handleAcceptInvite() {
room.join(object : MatrixCallback<Unit> {})
}
private fun handleEditAction(action: RoomDetailActions.EnterEditMode) {
room.getTimeLineEvent(action.eventId)?.let {
enterEditMode(it)
}
}
private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) {
room.getTimeLineEvent(action.eventId)?.let {
setState {
copy(
sendMode = SendMode.QUOTE,
selectedEvent = it
)
}
}
}
private fun handleReplyAction(action: RoomDetailActions.EnterReplyMode) {
room.getTimeLineEvent(action.eventId)?.let {
setState {
copy(
sendMode = SendMode.REPLY,
selectedEvent = it
)
}
}
}
private fun observeEventDisplayedActions() {
// We are buffering scroll events for one second
// and keep the most recent one to set the read receipt on.
@ -230,6 +415,18 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
}
}
private fun observeInvitationState() {
asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary ->
if (summary.membership == Membership.INVITE) {
summary.lastMessage?.sender?.let { senderId ->
session.getUser(senderId)
}?.also {
setState { copy(asyncInviter = Success(it)) }
}
}
}
}
override fun onCleared() {
timeline.dispose()
super.onCleared()

View File

@ -22,13 +22,33 @@ import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineData
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User
/**
* Describes the current send mode:
* REGULAR: sends the text as a regular message
* QUOTE: User is currently quoting a message
* EDIT: User is currently editing an existing message
*
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
*/
enum class SendMode {
REGULAR,
QUOTE,
EDIT,
REPLY
}
data class RoomDetailViewState(
val roomId: String,
val eventId: String?,
val timeline: Timeline? = null,
val asyncInviter: Async<User> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val asyncTimelineData: Async<TimelineData> = Uninitialized
val asyncTimelineData: Async<TimelineData> = Uninitialized,
val sendMode: SendMode = SendMode.REGULAR,
val selectedEvent: TimelineEvent? = null
) : MvRxState {
constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)

View File

@ -0,0 +1,116 @@
package im.vector.riotredesign.features.home.room.detail.composer
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isVisible
import androidx.transition.AutoTransition
import androidx.transition.Transition
import androidx.transition.TransitionManager
import butterknife.BindView
import butterknife.ButterKnife
import im.vector.riotredesign.R
/**
* Encapsulate the timeline composer UX.
*
*/
class TextComposerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {
@BindView(R.id.composer_related_message_sender)
lateinit var composerRelatedMessageTitle: TextView
@BindView(R.id.composer_related_message_preview)
lateinit var composerRelatedMessageContent: TextView
@BindView(R.id.composer_related_message_avatar_view)
lateinit var composerRelatedMessageAvatar: ImageView
@BindView(R.id.composer_related_message_action_image)
lateinit var composerRelatedMessageActionIcon: ImageView
@BindView(R.id.composer_related_message_close)
lateinit var composerRelatedMessageCloseButton: ImageButton
@BindView(R.id.composerEditText)
lateinit var composerEditText: EditText
@BindView(R.id.composer_avatar_view)
lateinit var composerAvatarImageView: ImageView
var currentConstraintSetId: Int = -1
init {
inflate(context, R.layout.merge_composer_layout, this)
ButterKnife.bind(this)
collapse(false)
}
fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
if (currentConstraintSetId == R.layout.constraint_set_composer_layout_compact) {
//ignore we good
return
}
currentConstraintSetId = R.layout.constraint_set_composer_layout_compact
if (animate) {
val transition = AutoTransition()
// transition.duration = 5000
transition.addListener(object : Transition.TransitionListener {
override fun onTransitionEnd(transition: Transition) {
transitionComplete?.invoke()
}
override fun onTransitionResume(transition: Transition) {}
override fun onTransitionPause(transition: Transition) {}
override fun onTransitionCancel(transition: Transition) {}
override fun onTransitionStart(transition: Transition) {}
}
)
TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition)
}
ConstraintSet().also {
it.clone(context, currentConstraintSetId)
it.applyTo(this)
}
}
fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
if (currentConstraintSetId == R.layout.constraint_set_composer_layout_expanded) {
//ignore we good
return
}
currentConstraintSetId = R.layout.constraint_set_composer_layout_expanded
if (animate) {
val transition = AutoTransition()
// transition.duration = 5000
transition.addListener(object : Transition.TransitionListener {
override fun onTransitionEnd(transition: Transition) {
transitionComplete?.invoke()
}
override fun onTransitionResume(transition: Transition) {}
override fun onTransitionPause(transition: Transition) {}
override fun onTransitionCancel(transition: Transition) {}
override fun onTransitionStart(transition: Transition) {}
}
)
TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition)
}
ConstraintSet().also {
it.clone(context, currentConstraintSetId)
it.applyTo(this)
}
}
}

View File

@ -24,13 +24,11 @@ import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.LoadingItemModel_
import im.vector.riotredesign.core.epoxy.loadingItem
import im.vector.riotredesign.core.epoxy.LoadingItem_
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.*
@ -48,7 +46,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
private val backgroundHandler: Handler = TimelineAsyncHelper.getBackgroundHandler()
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener {
interface Callback {
interface Callback : ReactionPillCallback {
fun onEventVisible(event: TimelineEvent)
fun onUrlClicked(url: String)
fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View)
@ -59,6 +57,11 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean
fun onAvatarClicked(informationData: MessageInformationData)
fun onMemberNameClicked(informationData: MessageInformationData)
fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?)
}
interface ReactionPillCallback {
fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean)
}
private val collapsedEventIds = linkedSetOf<String>()
@ -126,14 +129,14 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
}
override fun buildModels() {
LoadingItemModel_()
LoadingItem_()
.id("forward_loading_item")
.addWhen(Timeline.Direction.FORWARDS)
val timelineModels = getModels()
add(timelineModels)
LoadingItemModel_()
LoadingItem_()
.id("backward_loading_item")
.addWhen(Timeline.Direction.BACKWARDS)
}
@ -224,10 +227,8 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
} else {
val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed()
val mergedData = mergedEvents.map { mergedEvent ->
val eventContent: RoomMember? = mergedEvent.root.content.toModel()
val prevEventContent: RoomMember? = mergedEvent.root.prevContent.toModel()
val senderAvatar = RoomMemberEventHelper.senderAvatar(eventContent, prevEventContent, mergedEvent)
val senderName = RoomMemberEventHelper.senderName(eventContent, prevEventContent, mergedEvent)
val senderAvatar = mergedEvent.senderAvatar()
val senderName = mergedEvent.senderName()
MergedHeaderItem.Data(
userId = mergedEvent.root.sender ?: "",
avatarUrl = senderAvatar,
@ -256,7 +257,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
}
}
private fun LoadingItemModel_.addWhen(direction: Timeline.Direction) {
private fun LoadingItem_.addWhen(direction: Timeline.Direction) {
val shouldAdd = timeline?.hasMoreToLoad(direction) ?: false
addIf(shouldAdd, this@TimelineEventController)
}

View File

@ -16,24 +16,35 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action
import android.os.Bundle
import android.os.Parcelable
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.MvRxView
import com.airbnb.mvrx.MvRxViewModelStore
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import java.util.*
/**
* Add MvRx capabilities to bottomsheetdialog (like BaseMvRxFragment)
*/
abstract class BaseMvRxBottomSheetDialog() : BottomSheetDialogFragment(), MvRxView {
abstract class BaseMvRxBottomSheetDialog : BottomSheetDialogFragment(), MvRxView {
override val mvrxViewModelStore by lazy { MvRxViewModelStore(viewModelStore) }
private lateinit var mvrxPersistedViewId: String
final override val mvrxViewId: String by lazy { mvrxPersistedViewId }
override fun onCreate(savedInstanceState: Bundle?) {
mvrxViewModelStore.restoreViewModels(this, savedInstanceState)
mvrxPersistedViewId = savedInstanceState?.getString(PERSISTED_VIEW_ID_KEY)
?: this::class.java.simpleName + "_" + UUID.randomUUID().toString()
super.onCreate(savedInstanceState)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mvrxViewModelStore.saveViewModels(outState)
outState.putString(PERSISTED_VIEW_ID_KEY, mvrxViewId)
}
override fun onStart() {
@ -42,4 +53,10 @@ abstract class BaseMvRxBottomSheetDialog() : BottomSheetDialogFragment(), MvRxVi
// subscribe to a ViewModel.
postInvalidate()
}
}
protected fun setArguments(args: Parcelable? = null) {
arguments = args?.let { Bundle().apply { putParcelable(MvRx.KEY_ARG, it) } }
}
}
private const val PERSISTED_VIEW_ID_KEY = "mvrx:bottomsheet_persisted_view_id"

View File

@ -89,14 +89,14 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {
var quickReactionFragment = cfm.findFragmentByTag("QuickReaction") as? QuickReactionFragment
if (quickReactionFragment == null) {
quickReactionFragment = QuickReactionFragment.newInstance()
quickReactionFragment = QuickReactionFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as ParcelableArgs)
cfm.beginTransaction()
.replace(R.id.bottom_sheet_quick_reaction_container, quickReactionFragment, "QuickReaction")
.commit()
}
quickReactionFragment.interactionListener = object : QuickReactionFragment.InteractionListener {
override fun didQuickReactWith(reactions: List<String>) {
actionHandlerModel.fireAction("Quick React", reactions)
override fun didQuickReactWith(clikedOn: String, opposite: String, reactions: List<String>, eventId: String) {
actionHandlerModel.fireAction(MessageMenuViewModel.ACTION_QUICK_REACT, Triple(eventId, clikedOn, opposite))
dismiss()
}
}
@ -144,15 +144,15 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {
companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): MessageActionsBottomSheet {
val args = Bundle()
val parcelableArgs = ParcelableArgs(
informationData.eventId,
roomId,
informationData
)
args.putParcelable(MvRx.KEY_ARG, parcelableArgs)
return MessageActionsBottomSheet().apply { arguments = args }
return MessageActionsBottomSheet().apply {
setArguments(
ParcelableArgs(
informationData.eventId,
roomId,
informationData
)
)
}
}
}
}

View File

@ -21,8 +21,14 @@ import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.riotredesign.core.platform.VectorViewModel
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.koin.android.ext.android.get
import ru.noties.markwon.Markwon
import ru.noties.markwon.html.HtmlPlugin
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.*
@ -31,7 +37,7 @@ import java.util.*
data class MessageActionState(
val userId: String,
val senderName: String,
val messageBody: String,
val messageBody: CharSequence,
val ts: String?,
val senderAvatarPath: String? = null)
: MvRxState
@ -51,12 +57,22 @@ class MessageActionsViewModel(initialState: MessageActionState) : VectorViewMode
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
return if (event != null) {
val messageContent: MessageContent? = event.root.content.toModel()
val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel()
val originTs = event.root.originServerTs
var body: CharSequence = messageContent?.body ?: ""
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody?.trim() ?: messageContent.body)
// val renderer = HtmlRenderer.builder().build()
body = Markwon.builder(viewModelContext.activity)
.usePlugin(HtmlPlugin.create()).build().render(document)
// body = renderer.render(document)
}
MessageActionState(
event.root.sender ?: "",
parcel.informationData.memberName.toString(),
messageContent?.body ?: "",
body,
dateFormat.format(Date(originTs ?: 0)),
currentSession.contentUrlResolver().resolveFullSize(parcel.informationData.avatarUrl)
)

View File

@ -50,16 +50,17 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null
val messageContent: MessageContent = event.root.content.toModel() ?: return null
val messageContent: MessageContent = event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel() ?: return null
val type = messageContent.type
if (event.sendState == SendState.UNSENT) {
//Resend and Delete
return MessageMenuState(
listOf(
SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_corner_down_right, event.root.eventId),
SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_send, event.root.eventId),
//TODO delete icon
SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_material_delete, event.root.eventId)
SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId)
)
)
}
@ -67,20 +68,36 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
//TODO determine if can copy, forward, reply, quote, report?
val actions = ArrayList<SimpleAction>().apply {
this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_smile))
if (event.sendState == SendState.SENDING) {
//TODO add cancel?
return@apply
}
//TODO is downloading attachement?
this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, event.root.eventId))
if (canCopy(type)) {
//TODO copy images? html? see ClipBoard
this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent.body))
}
if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, event.root.eventId))
}
if (canEdit(event, currentSession.sessionParams.credentials.userId)) {
this.add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, event.root.eventId))
}
if (canRedact(event, currentSession.sessionParams.credentials.userId)) {
this.add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId))
}
if (canQuote(event, messageContent)) {
//TODO quote icon
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, parcel.eventId))
}
if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_corner_down_right))
}
if (canShare(type)) {
if (messageContent is MessageImageContent) {
this.add(
@ -92,8 +109,6 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
//TODO
}
//TODO is uploading
//TODO is downloading
if (event.sendState == SendState.SENT) {
@ -148,6 +163,25 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
}
}
private fun canRedact(event: TimelineEvent, myUserId: String): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.type != EventType.MESSAGE) return false
//TODO if user is admin or moderator
return event.root.sender == myUserId
}
private fun canEdit(event: TimelineEvent, myUserId: String): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.type != EventType.MESSAGE) return false
//TODO if user is admin or moderator
val messageContent = event.root.content.toModel<MessageContent>()
return event.root.sender == myUserId && (
messageContent?.type == MessageType.MSGTYPE_TEXT
|| messageContent?.type == MessageType.MSGTYPE_EMOTE
)
}
private fun canCopy(type: String): Boolean {
return when (type) {
MessageType.MSGTYPE_TEXT,
@ -175,6 +209,7 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
const val ACTION_ADD_REACTION = "add_reaction"
const val ACTION_COPY = "copy"
const val ACTION_EDIT = "edit"
const val ACTION_QUOTE = "quote"
const val ACTION_REPLY = "reply"
const val ACTION_SHARE = "share"
@ -184,6 +219,7 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
const val VIEW_DECRYPTED_SOURCE = "VIEW_DECRYPTED_SOURCE"
const val PERMALINK = "PERMALINK"
const val ACTION_FLAG = "ACTION_FLAG"
const val ACTION_QUICK_REACT = "ACTION_QUICK_REACT"
}

View File

@ -25,6 +25,7 @@ import androidx.transition.TransitionManager
import butterknife.BindView
import butterknife.ButterKnife
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.riotredesign.R
@ -62,10 +63,10 @@ class QuickReactionFragment : BaseMvRxFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
quickReact1Text.text = viewModel.agreePositive
quickReact2Text.text = viewModel.agreeNegative
quickReact3Text.text = viewModel.likePositive
quickReact4Text.text = viewModel.likeNegative
quickReact1Text.text = QuickReactionViewModel.agreePositive
quickReact2Text.text = QuickReactionViewModel.agreeNegative
quickReact3Text.text = QuickReactionViewModel.likePositive
quickReact4Text.text = QuickReactionViewModel.likeNegative
//configure click listeners
quickReact1Text.setOnClickListener {
@ -118,17 +119,23 @@ class QuickReactionFragment : BaseMvRxFragment() {
}
if (it.selectionResult != null) {
interactionListener?.didQuickReactWith(it.selectionResult)
val clikedOn = it.selectionResult.first
interactionListener?.didQuickReactWith(clikedOn, QuickReactionViewModel.getOpposite(clikedOn)
?: "", it.selectionResult.second, it.eventId)
}
}
interface InteractionListener {
fun didQuickReactWith(reactions: List<String>)
fun didQuickReactWith(clikedOn: String, opposite: String, reactions: List<String>, eventId: String)
}
companion object {
fun newInstance(): QuickReactionFragment {
return QuickReactionFragment()
fun newInstance(pa: MessageActionsBottomSheet.ParcelableArgs): QuickReactionFragment {
val args = Bundle()
args.putParcelable(MvRx.KEY_ARG, pa)
val fragment = QuickReactionFragment()
fragment.arguments = args
return fragment
}
}
}

View File

@ -18,7 +18,9 @@ package im.vector.riotredesign.features.home.room.detail.timeline.action
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session
import im.vector.riotredesign.core.platform.VectorViewModel
import org.koin.android.ext.android.get
/**
* Quick reactions state, it's a toggle with 3rd state
@ -29,7 +31,12 @@ enum class TriggleState {
SECOND
}
data class QuickReactionState(val agreeTrigleState: TriggleState, val likeTriggleState: TriggleState, val selectionResult: List<String>? = null) : MvRxState
data class QuickReactionState(
val agreeTrigleState: TriggleState,
val likeTriggleState: TriggleState,
/** Pair of 'clickedOn' and current toggles state*/
val selectionResult: Pair<String, List<String>>? = null,
val eventId: String) : MvRxState
/**
* Quick reaction view model
@ -37,25 +44,22 @@ data class QuickReactionState(val agreeTrigleState: TriggleState, val likeTriggl
*/
class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel<QuickReactionState>(initialState) {
val agreePositive = "👍"
val agreeNegative = "👎"
val likePositive = "😀"
val likeNegative = "😞"
fun toggleAgree(isFirst: Boolean) = withState {
if (isFirst) {
setState {
val newTriggle = if (it.agreeTrigleState == TriggleState.FIRST) TriggleState.NONE else TriggleState.FIRST
copy(
agreeTrigleState = if (it.agreeTrigleState == TriggleState.FIRST) TriggleState.NONE else TriggleState.FIRST,
selectionResult = getReactions(this)
agreeTrigleState = newTriggle,
selectionResult = Pair(agreePositive, getReactions(this, newTriggle, null))
)
}
} else {
setState {
val newTriggle = if (it.agreeTrigleState == TriggleState.SECOND) TriggleState.NONE else TriggleState.SECOND
copy(
agreeTrigleState = if (it.agreeTrigleState == TriggleState.SECOND) TriggleState.NONE else TriggleState.SECOND,
selectionResult = getReactions(this)
agreeTrigleState = agreeTrigleState,
selectionResult = Pair(agreeNegative, getReactions(this, newTriggle, null))
)
}
}
@ -64,30 +68,32 @@ class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel
fun toggleLike(isFirst: Boolean) = withState {
if (isFirst) {
setState {
val newTriggle = if (it.likeTriggleState == TriggleState.FIRST) TriggleState.NONE else TriggleState.FIRST
copy(
likeTriggleState = if (it.likeTriggleState == TriggleState.FIRST) TriggleState.NONE else TriggleState.FIRST,
selectionResult = getReactions(this)
likeTriggleState = newTriggle,
selectionResult = Pair(likePositive, getReactions(this, null, newTriggle))
)
}
} else {
setState {
val newTriggle = if (it.likeTriggleState == TriggleState.SECOND) TriggleState.NONE else TriggleState.SECOND
copy(
likeTriggleState = if (it.likeTriggleState == TriggleState.SECOND) TriggleState.NONE else TriggleState.SECOND,
selectionResult = getReactions(this)
likeTriggleState = newTriggle,
selectionResult = Pair(likeNegative, getReactions(this, null, newTriggle))
)
}
}
}
private fun getReactions(state: QuickReactionState): List<String> {
private fun getReactions(state: QuickReactionState, newState1: TriggleState?, newState2: TriggleState?): List<String> {
return ArrayList<String>(4).apply {
when (state.likeTriggleState) {
when (newState2 ?: state.likeTriggleState) {
TriggleState.FIRST -> add(likePositive)
TriggleState.SECOND -> add(likeNegative)
else -> {
}
}
when (state.agreeTrigleState) {
when (newState1 ?: state.agreeTrigleState) {
TriggleState.FIRST -> add(agreePositive)
TriggleState.SECOND -> add(agreeNegative)
else -> {
@ -99,10 +105,47 @@ class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel
companion object : MvRxViewModelFactory<QuickReactionViewModel, QuickReactionState> {
val agreePositive = "👍"
val agreeNegative = "👎"
val likePositive = "🙂"
val likeNegative = "😔"
fun getOpposite(reaction: String): String? {
return when (reaction) {
agreePositive -> agreeNegative
agreeNegative -> agreePositive
likePositive -> likeNegative
likeNegative -> likePositive
else -> null
}
}
override fun initialState(viewModelContext: ViewModelContext): QuickReactionState? {
// Args are accessible from the context.
// val foo = vieWModelContext.args<MyArgs>.foo
return QuickReactionState(TriggleState.NONE, TriggleState.NONE)
val currentSession = viewModelContext.activity.get<Session>()
val parcel = viewModelContext.args as MessageActionsBottomSheet.ParcelableArgs
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null
var agreeTriggle: TriggleState = TriggleState.NONE
var likeTriggle: TriggleState = TriggleState.NONE
event.annotations?.reactionsSummary?.forEach {
//it.addedByMe
if (it.addedByMe) {
if (agreePositive == it.key) {
agreeTriggle = TriggleState.FIRST
} else if (agreeNegative == it.key) {
agreeTriggle = TriggleState.SECOND
}
if (likePositive == it.key) {
likeTriggle = TriggleState.FIRST
} else if (likeNegative == it.key) {
likeTriggle = TriggleState.SECOND
}
}
}
return QuickReactionState(agreeTriggle, likeTriggle, null, event.root.eventId ?: "")
}
}
}

View File

@ -19,7 +19,6 @@ package im.vector.riotredesign.features.home.room.detail.timeline.factory
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
@ -50,7 +49,7 @@ class CallItemFactory(private val stringProvider: StringProvider) {
}
EventType.CALL_ANSWER == event.getClearType() -> stringProvider.getString(R.string.notice_answered_call, senderName)
EventType.CALL_HANGUP == event.getClearType() -> stringProvider.getString(R.string.notice_ended_call, senderName)
else -> null
else -> null
}
}

View File

@ -17,12 +17,19 @@
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.view.View
import androidx.annotation.ColorRes
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.RelationType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
@ -31,8 +38,10 @@ import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.core.linkify.VectorLinkify
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.core.utils.DebouncedClickListener
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.getColorFromUserId
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
@ -45,7 +54,8 @@ import me.gujun.android.span.span
class MessageItemFactory(private val colorProvider: ColorProvider,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val timelineDateFormatter: TimelineDateFormatter,
private val htmlRenderer: EventHtmlRenderer) {
private val htmlRenderer: EventHtmlRenderer,
private val stringProvider: StringProvider) {
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
@ -66,29 +76,57 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
|| nextEvent?.root?.getClearType() != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo
val messageContent: MessageContent = event.root.getClearContent().toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date)
val avatarUrl = event.senderAvatar
val memberName = event.senderName ?: event.root.sender ?: ""
val formattedMemberName = span(memberName) {
textColor = colorProvider.getColor(AvatarRenderer.getColorFromUserId(event.root.sender ?: ""))
textColor = colorProvider.getColor(getColorFromUserId(event.root.sender
?: ""))
}
val hasBeenEdited = event.annotations?.editSummary != null
val informationData = MessageInformationData(eventId = eventId,
senderId = event.root.sender ?: "",
sendState = event.sendState,
time = time,
avatarUrl = avatarUrl,
memberName = formattedMemberName,
showInformation = showInformation)
showInformation = showInformation,
orderedReactionList = event.annotations?.reactionsSummary?.map {
ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty())
},
hasBeenEdited = hasBeenEdited
)
//Test for reactions UX
//informationData.orderedReactionList = listOf( Triple("👍",1,false), Triple("👎",2,false))
if (event.root.unsignedData?.redactedEvent != null) {
//message is redacted
return buildRedactedItem(informationData, callback)
}
val messageContent: MessageContent =
event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.getClearContent().toModel()
?: //Malformed content, we should echo something on screen
return DefaultItem_().text(stringProvider.getString(R.string.malformed_message))
if (messageContent.relatesTo?.type == RelationType.REPLACE) {
// ignore replace event, the targeted id is already edited
return BlankItem_()
}
// val all = event.root.toContent()
// val ev = all.toModel<Event>()
return when (messageContent) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(event.sendState, messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent,
informationData,
hasBeenEdited,
event.annotations?.editSummary,
callback)
is MessageTextContent -> buildTextMessageItem(event.sendState,
messageContent,
informationData,
hasBeenEdited,
event.annotations?.editSummary,
callback
)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, callback)
@ -98,12 +136,14 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}
private fun buildAudioMessageItem(messageContent: MessageAudioContent, informationData: MessageInformationData,
private fun buildAudioMessageItem(messageContent: MessageAudioContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageFileItem? {
return MessageFileItem_()
.informationData(informationData)
.filename(messageContent.body)
.iconRes(R.drawable.filetype_audio)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
@ -126,11 +166,13 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}
private fun buildFileMessageItem(messageContent: MessageFileContent, informationData: MessageInformationData,
private fun buildFileMessageItem(messageContent: MessageFileContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageFileItem? {
return MessageFileItem_()
.informationData(informationData)
.filename(messageContent.body)
.reactionPillCallback(callback)
.iconRes(R.drawable.filetype_attachment)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
@ -159,7 +201,8 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return DefaultItem_().text(text)
}
private fun buildImageMessageItem(messageContent: MessageImageContent, informationData: MessageInformationData,
private fun buildImageMessageItem(messageContent: MessageImageContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageVideoItem? {
val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
@ -177,6 +220,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
.playable(messageContent.info?.mimeType == "image/gif")
.informationData(informationData)
.mediaData(data)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
@ -193,14 +237,14 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
}
private fun buildVideoMessageItem(messageContent: MessageVideoContent, informationData: MessageInformationData,
private fun buildVideoMessageItem(messageContent: MessageVideoContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageVideoItem? {
val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
@ -223,6 +267,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
.playable(true)
.informationData(informationData)
.mediaData(thumbnailData)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
@ -242,18 +287,30 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}
private fun buildTextMessageItem(sendState: SendState, messageContent: MessageTextContent,
private fun buildTextMessageItem(sendState: SendState,
messageContent: MessageTextContent,
informationData: MessageInformationData,
hasBeenEdited: Boolean,
editSummary: EditAggregatedSummary?,
callback: TimelineEventController.Callback?): MessageTextItem? {
val bodyToUse = messageContent.formattedBody?.let {
htmlRenderer.render(it)
htmlRenderer.render(it.trim())
} ?: messageContent.body
val linkifiedBody = linkifyBody(bodyToUse, callback)
return MessageTextItem_()
.message(linkifiedBody)
.apply {
if (hasBeenEdited) {
val spannable = annotateWithEdited(linkifiedBody, callback, informationData, editSummary)
message(spannable)
} else {
message(linkifiedBody)
}
}
.informationData(informationData)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
@ -262,11 +319,6 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
//click on the text
.clickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -277,13 +329,48 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}
private fun buildNoticeMessageItem(messageContent: MessageNoticeContent, informationData: MessageInformationData,
private fun annotateWithEdited(linkifiedBody: CharSequence,
callback: TimelineEventController.Callback?,
informationData: MessageInformationData,
editSummary: EditAggregatedSummary?): SpannableStringBuilder {
val spannable = SpannableStringBuilder()
spannable.append(linkifiedBody)
// TODO i18n
val editedSuffix = "(edited)"
spannable.append(" ").append(editedSuffix)
val color = colorProvider.getColorFromAttribute(R.attr.vctr_list_header_secondary_text_color)
val editStart = spannable.indexOf(editedSuffix)
val editEnd = editStart + editedSuffix.length
spannable.setSpan(
ForegroundColorSpan(color),
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.setSpan(RelativeSizeSpan(.9f), editStart, editEnd, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.setSpan(object : ClickableSpan() {
override fun onClick(widget: View?) {
callback?.onEditedDecorationClicked(informationData, editSummary)
}
override fun updateDrawState(ds: TextPaint?) {
//nop
}
},
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
return spannable
}
private fun buildNoticeMessageItem(messageContent: MessageNoticeContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {
val message = messageContent.body.let {
val formattedBody = span {
text = it
textColor = colorProvider.getColor(R.color.slate_grey)
textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary)
textStyle = "italic"
}
linkifyBody(formattedBody, callback)
@ -291,6 +378,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageTextItem_()
.message(message)
.informationData(informationData)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
@ -309,7 +397,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}
private fun buildEmoteMessageItem(messageContent: MessageEmoteContent, informationData: MessageInformationData,
private fun buildEmoteMessageItem(messageContent: MessageEmoteContent,
informationData: MessageInformationData,
hasBeenEdited: Boolean,
editSummary: EditAggregatedSummary?,
callback: TimelineEventController.Callback?): MessageTextItem? {
val message = messageContent.body.let {
@ -317,8 +408,16 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
linkifyBody(formattedBody, callback)
}
return MessageTextItem_()
.message(message)
.apply {
if (hasBeenEdited) {
val spannable = annotateWithEdited(message, callback, informationData, editSummary)
message(spannable)
} else {
message(message)
}
}
.informationData(informationData)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
@ -337,6 +436,20 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}
private fun buildRedactedItem(informationData: MessageInformationData,
callback: TimelineEventController.Callback?): RedactedMessageItem? {
return RedactedMessageItem_()
.informationData(informationData)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
}
private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence {
val spannable = SpannableStringBuilder(body)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
@ -347,32 +460,4 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
VectorLinkify.addLinks(spannable, true)
return spannable
}
//Based on riot-web implementation
@ColorRes
private fun getColorFor(sender: String): Int {
var hash = 0
var i = 0
var chr: Char
if (sender.isEmpty()) {
return R.color.username_1
}
while (i < sender.length) {
chr = sender[i]
hash = (hash shl 5) - hash + chr.toInt()
hash = hash or 0
i++
}
val cI = Math.abs(hash) % 8 + 1
return when (cI) {
1 -> R.color.username_1
2 -> R.color.username_2
3 -> R.color.username_3
4 -> R.color.username_4
5 -> R.color.username_5
6 -> R.color.username_6
7 -> R.color.username_7
else -> R.color.username_8
}
}
}

View File

@ -16,28 +16,24 @@
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomTopicContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.format.NoticeEventFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.senderAvatar
import im.vector.riotredesign.features.home.room.detail.timeline.helper.senderName
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
class RoomTopicItemFactory(private val stringProvider: StringProvider) {
class NoticeItemFactory(private val eventFormatter: NoticeEventFormatter) {
fun create(event: TimelineEvent): NoticeItem? {
val formattedText = eventFormatter.format(event) ?: return null
val senderName = event.senderName()
val senderAvatar = event.senderAvatar()
val content: RoomTopicContent = event.root.content.toModel() ?: return null
val text = if (content.topic.isNullOrEmpty()) {
stringProvider.getString(R.string.notice_room_topic_removed, event.senderName)
} else {
stringProvider.getString(R.string.notice_room_topic_changed, event.senderName, content.topic)
}
return NoticeItem_()
.noticeText(text)
.avatarUrl(event.senderAvatar)
.memberName(event.senderName)
.noticeText(formattedText)
.avatarUrl(senderAvatar)
.memberName(senderName)
}

View File

@ -1,135 +0,0 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home.room.detail.timeline.factory
import android.text.TextUtils
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.helper.RoomMemberEventHelper
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
//TODO : complete with call membership events¬
class RoomMemberItemFactory(private val stringProvider: StringProvider) {
fun create(event: TimelineEvent): NoticeItem? {
val eventContent: RoomMember? = event.root.content.toModel()
val prevEventContent: RoomMember? = event.root.prevContent.toModel()
val noticeText = buildRoomMemberNotice(event, eventContent, prevEventContent) ?: return null
val senderAvatar = RoomMemberEventHelper.senderAvatar(eventContent, prevEventContent, event)
val senderName = RoomMemberEventHelper.senderName(eventContent, prevEventContent, event)
return NoticeItem_()
.userId(event.root.sender ?: "")
.noticeText(noticeText)
.avatarUrl(senderAvatar)
.memberName(senderName)
}
private fun buildRoomMemberNotice(event: TimelineEvent, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val isMembershipEvent = prevEventContent?.membership != eventContent?.membership
return if (isMembershipEvent) {
buildMembershipNotice(event, eventContent, prevEventContent)
} else {
buildProfileNotice(event, eventContent, prevEventContent)
}
}
private fun buildProfileNotice(event: TimelineEvent, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val displayText = StringBuilder()
// Check display name has been changed
if (!TextUtils.equals(eventContent?.displayName, prevEventContent?.displayName)) {
val displayNameText = when {
prevEventContent?.displayName.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_display_name_set, event.root.sender, eventContent?.displayName)
eventContent?.displayName.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_display_name_removed, event.root.sender, prevEventContent?.displayName)
else ->
stringProvider.getString(R.string.notice_display_name_changed_from,
event.root.sender, prevEventContent?.displayName, eventContent?.displayName)
}
displayText.append(displayNameText)
}
// Check whether the avatar has been changed
if (!TextUtils.equals(eventContent?.avatarUrl, prevEventContent?.avatarUrl)) {
val displayAvatarText = if (displayText.isNotEmpty()) {
displayText.append(" ")
stringProvider.getString(R.string.notice_avatar_changed_too)
} else {
stringProvider.getString(R.string.notice_avatar_url_changed, event.senderName)
}
displayText.append(displayAvatarText)
}
return displayText.toString()
}
private fun buildMembershipNotice(event: TimelineEvent, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val senderDisplayName = event.senderName ?: event.root.sender
val targetDisplayName = eventContent?.displayName ?: event.root.sender
return when {
Membership.INVITE == eventContent?.membership -> {
// TODO get userId
val selfUserId = ""
when {
eventContent.thirdPartyInvite != null ->
stringProvider.getString(R.string.notice_room_third_party_registered_invite,
targetDisplayName, eventContent.thirdPartyInvite?.displayName)
TextUtils.equals(event.root.stateKey, selfUserId) ->
stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName)
event.root.stateKey.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_room_invite_no_invitee, senderDisplayName)
else ->
stringProvider.getString(R.string.notice_room_invite, senderDisplayName, targetDisplayName)
}
}
Membership.JOIN == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_join, senderDisplayName)
Membership.LEAVE == eventContent?.membership ->
// 2 cases here: this member may have left voluntarily or they may have been "left" by someone else ie. kicked
return if (TextUtils.equals(event.root.sender, event.root.stateKey)) {
if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_reject, senderDisplayName)
} else {
val leftDisplayName = RoomMemberEventHelper.senderName(eventContent, prevEventContent, event)
stringProvider.getString(R.string.notice_room_leave, leftDisplayName)
}
} else if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_withdraw, senderDisplayName, targetDisplayName)
} else if (prevEventContent?.membership == Membership.JOIN) {
stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName)
} else if (prevEventContent?.membership == Membership.BAN) {
stringProvider.getString(R.string.notice_room_unban, senderDisplayName, targetDisplayName)
} else {
null
}
Membership.BAN == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_ban, senderDisplayName, targetDisplayName)
Membership.KNOCK == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName)
else -> null
}
}
}

View File

@ -1,45 +0,0 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home.room.detail.timeline.factory
import android.text.TextUtils
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomNameContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
class RoomNameItemFactory(private val stringProvider: StringProvider) {
fun create(event: TimelineEvent): NoticeItem? {
val content: RoomNameContent = event.root.content.toModel() ?: return null
val text = if (!TextUtils.isEmpty(content.name)) {
stringProvider.getString(R.string.notice_room_name_changed, event.senderName, content.name)
} else {
stringProvider.getString(R.string.notice_room_name_removed, event.senderName)
}
return NoticeItem_()
.noticeText(text)
.avatarUrl(event.senderAvatar)
.memberName(event.senderName)
}
}

View File

@ -24,13 +24,9 @@ import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventCo
import timber.log.Timber
class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
private val roomNameItemFactory: RoomNameItemFactory,
private val roomTopicItemFactory: RoomTopicItemFactory,
private val roomMemberItemFactory: RoomMemberItemFactory,
private val roomHistoryVisibilityItemFactory: RoomHistoryVisibilityItemFactory,
private val callItemFactory: CallItemFactory,
private val encryptionItemFactory: EncryptionItemFactory,
private val encryptedItemFactory: EncryptedItemFactory,
private val noticeItemFactory: NoticeItemFactory,
private val defaultItemFactory: DefaultItemFactory) {
fun create(event: TimelineEvent,
@ -39,29 +35,31 @@ class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
val computedModel = try {
when (event.root.getClearType()) {
EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, callback)
EventType.STATE_ROOM_NAME -> roomNameItemFactory.create(event)
EventType.STATE_ROOM_TOPIC -> roomTopicItemFactory.create(event)
EventType.STATE_ROOM_MEMBER -> roomMemberItemFactory.create(event)
EventType.STATE_HISTORY_VISIBILITY -> roomHistoryVisibilityItemFactory.create(event)
EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, callback)
// State and call
EventType.STATE_ROOM_NAME,
EventType.STATE_ROOM_TOPIC,
EventType.STATE_ROOM_MEMBER,
EventType.STATE_HISTORY_VISIBILITY,
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> callItemFactory.create(event)
EventType.CALL_ANSWER -> noticeItemFactory.create(event)
EventType.ENCRYPTION -> encryptionItemFactory.create(event)
EventType.ENCRYPTED -> encryptedItemFactory.create(event)
// Crypto
EventType.ENCRYPTION -> encryptionItemFactory.create(event)
EventType.ENCRYPTED -> encryptedItemFactory.create(event)
// Unhandled event types (yet)
EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STICKER,
EventType.STATE_ROOM_CREATE -> defaultItemFactory.create(event)
else -> null
EventType.STATE_ROOM_CREATE -> defaultItemFactory.create(event)
else -> {
Timber.w("Ignored event (type: ${event.root.type}")
null
}
}
} catch (e: Exception) {
Timber.e(e, "Error")
Timber.e(e, "failed to create message item")
defaultItemFactory.create(event, e)
}
return (computedModel ?: EmptyItem_())

View File

@ -0,0 +1,182 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home.room.detail.timeline.format
import android.text.TextUtils
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.*
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.helper.senderName
import timber.log.Timber
class NoticeEventFormatter(private val stringProvider: StringProvider) {
fun format(timelineEvent: TimelineEvent): CharSequence? {
return when (val type = timelineEvent.root.getClearType()) {
EventType.STATE_ROOM_NAME -> formatRoomNameEvent(timelineEvent.root, timelineEvent.senderName)
EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.senderName)
EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.senderName())
EventType.STATE_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.senderName)
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.senderName)
else -> {
Timber.v("Type $type not handled by this formatter")
null
}
}
}
private fun formatRoomNameEvent(event: Event, senderName: String?): CharSequence? {
val content = event.getClearContent().toModel<RoomNameContent>() ?: return null
return if (!TextUtils.isEmpty(content.name)) {
stringProvider.getString(R.string.notice_room_name_changed, senderName, content.name)
} else {
stringProvider.getString(R.string.notice_room_name_removed, senderName)
}
}
private fun formatRoomTopicEvent(event: Event, senderName: String?): CharSequence? {
val content = event.getClearContent().toModel<RoomTopicContent>() ?: return null
return if (content.topic.isNullOrEmpty()) {
stringProvider.getString(R.string.notice_room_topic_removed, senderName)
} else {
stringProvider.getString(R.string.notice_room_topic_changed, senderName, content.topic)
}
}
private fun formatRoomHistoryVisibilityEvent(event: Event, senderName: String?): CharSequence? {
val historyVisibility = event.getClearContent().toModel<RoomHistoryVisibilityContent>()?.historyVisibility
?: return null
val formattedVisibility = when (historyVisibility) {
RoomHistoryVisibility.SHARED -> stringProvider.getString(R.string.notice_room_visibility_shared)
RoomHistoryVisibility.INVITED -> stringProvider.getString(R.string.notice_room_visibility_invited)
RoomHistoryVisibility.JOINED -> stringProvider.getString(R.string.notice_room_visibility_joined)
RoomHistoryVisibility.WORLD_READABLE -> stringProvider.getString(R.string.notice_room_visibility_world_readable)
}
return stringProvider.getString(R.string.notice_made_future_room_visibility, senderName, formattedVisibility)
}
private fun formatCallEvent(event: Event, senderName: String?): CharSequence? {
return when {
EventType.CALL_INVITE == event.type -> {
val content = event.getClearContent().toModel<CallInviteContent>() ?: return null
val isVideoCall = content.offer.sdp == CallInviteContent.Offer.SDP_VIDEO
return if (isVideoCall) {
stringProvider.getString(R.string.notice_placed_video_call, senderName)
} else {
stringProvider.getString(R.string.notice_placed_voice_call, senderName)
}
}
EventType.CALL_ANSWER == event.type -> stringProvider.getString(R.string.notice_answered_call, senderName)
EventType.CALL_HANGUP == event.type -> stringProvider.getString(R.string.notice_ended_call, senderName)
else -> null
}
}
private fun formatRoomMemberEvent(event: Event, senderName: String?): String? {
val eventContent: RoomMember? = event.getClearContent().toModel()
val prevEventContent: RoomMember? = event.prevContent.toModel()
val isMembershipEvent = prevEventContent?.membership != eventContent?.membership
return if (isMembershipEvent) {
buildMembershipNotice(event, senderName, eventContent, prevEventContent)
} else {
buildProfileNotice(event, senderName, eventContent, prevEventContent)
}
}
private fun buildProfileNotice(event: Event, senderName: String?, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val displayText = StringBuilder()
// Check display name has been changed
if (!TextUtils.equals(eventContent?.displayName, prevEventContent?.displayName)) {
val displayNameText = when {
prevEventContent?.displayName.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_display_name_set, event.sender, eventContent?.displayName)
eventContent?.displayName.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_display_name_removed, event.sender, prevEventContent?.displayName)
else ->
stringProvider.getString(R.string.notice_display_name_changed_from,
event.sender, prevEventContent?.displayName, eventContent?.displayName)
}
displayText.append(displayNameText)
}
// Check whether the avatar has been changed
if (!TextUtils.equals(eventContent?.avatarUrl, prevEventContent?.avatarUrl)) {
val displayAvatarText = if (displayText.isNotEmpty()) {
displayText.append(" ")
stringProvider.getString(R.string.notice_avatar_changed_too)
} else {
stringProvider.getString(R.string.notice_avatar_url_changed, senderName)
}
displayText.append(displayAvatarText)
}
return displayText.toString()
}
private fun buildMembershipNotice(event: Event, senderName: String?, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val senderDisplayName = senderName ?: event.sender
val targetDisplayName = eventContent?.displayName ?: event.sender
return when {
Membership.INVITE == eventContent?.membership -> {
// TODO get userId
val selfUserId = ""
when {
eventContent.thirdPartyInvite != null ->
stringProvider.getString(R.string.notice_room_third_party_registered_invite,
targetDisplayName, eventContent.thirdPartyInvite?.displayName)
TextUtils.equals(event.stateKey, selfUserId) ->
stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName)
event.stateKey.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_room_invite_no_invitee, senderDisplayName)
else ->
stringProvider.getString(R.string.notice_room_invite, senderDisplayName, targetDisplayName)
}
}
Membership.JOIN == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_join, senderDisplayName)
Membership.LEAVE == eventContent?.membership ->
// 2 cases here: this member may have left voluntarily or they may have been "left" by someone else ie. kicked
return if (TextUtils.equals(event.sender, event.stateKey)) {
if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_reject, senderDisplayName)
} else {
stringProvider.getString(R.string.notice_room_leave, senderDisplayName)
}
} else if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_withdraw, senderDisplayName, targetDisplayName)
} else if (prevEventContent?.membership == Membership.JOIN) {
stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName)
} else if (prevEventContent?.membership == Membership.BAN) {
stringProvider.getString(R.string.notice_room_unban, senderDisplayName, targetDisplayName)
} else {
null
}
Membership.BAN == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_ban, senderDisplayName, targetDisplayName)
Membership.KNOCK == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName)
else -> null
}
}
}

View File

@ -16,25 +16,11 @@
package im.vector.riotredesign.features.home.room.detail.timeline.helper
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
object RoomMemberEventHelper {
fun senderAvatar(eventContent: RoomMember?, prevEventContent: RoomMember?, event: TimelineEvent): String? {
return if (eventContent?.membership == Membership.LEAVE && eventContent.avatarUrl == null && prevEventContent?.avatarUrl != null) {
prevEventContent.avatarUrl
} else {
event.senderAvatar
}
}
fun senderName(eventContent: RoomMember?, prevEventContent: RoomMember?, event: TimelineEvent): String? {
return if (eventContent?.membership == Membership.LEAVE && eventContent.displayName == null && prevEventContent?.displayName != null) {
prevEventContent.displayName
} else {
event.senderName
}
}
}

View File

@ -17,6 +17,10 @@
package im.vector.riotredesign.features.home.room.detail.timeline.helper
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.RelationType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.extensions.localDateTime
@ -40,13 +44,44 @@ object TimelineDisplayableEvents {
}
fun TimelineEvent.isDisplayable(): Boolean {
return TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.getClearType()) && !root.content.isNullOrEmpty()
if (!TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.type)) {
return false
}
if (root.content.isNullOrEmpty()) {
return false
}
//Edits should be filtered out!
if (EventType.MESSAGE == root.type
&& root.content.toModel<MessageContent>()?.relatesTo?.type == RelationType.REPLACE) {
return false
}
return true
}
//
//fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
// return this.filter {
// it.isDisplayable()
// }
//}
fun TimelineEvent.senderAvatar(): String? {
// We might have no avatar when user leave, so we try to get it from prevContent
return senderAvatar
?: if (root.type == EventType.STATE_ROOM_MEMBER) {
root.prevContent.toModel<RoomMember>()?.avatarUrl
} else {
null
}
}
fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
return this.filter {
it.isDisplayable()
}
fun TimelineEvent.senderName(): String? {
// We might have no senderName when user leave, so we try to get it from prevContent
return senderName
?: if (root.type == EventType.STATE_ROOM_MEMBER) {
root.prevContent.toModel<RoomMember>()?.displayName
} else {
null
}
}
fun TimelineEvent.canBeMerged(): Boolean {

View File

@ -19,6 +19,7 @@ package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.view.ViewStub
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.helper.widget.Flow
@ -29,6 +30,7 @@ import com.airbnb.epoxy.EpoxyAttribute
import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.reactions.widget.ReactionButton
@ -48,6 +50,19 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@EpoxyAttribute
var memberClickListener: View.OnClickListener? = null
@EpoxyAttribute
var reactionPillCallback: TimelineEventController.ReactionPillCallback? = null
var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener {
override fun onReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, true)
}
override fun onUnReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, false)
}
}
override fun bind(holder: H) {
super.bind(holder)
if (informationData.showInformation) {
@ -65,42 +80,60 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.timeView.text = informationData.time
holder.memberNameView.text = informationData.memberName
AvatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView)
holder.view.setOnClickListener(cellClickListener)
holder.view.setOnLongClickListener(longClickListener)
holder.avatarImageView.setOnLongClickListener(longClickListener)
holder.memberNameView.setOnLongClickListener(longClickListener)
} else {
holder.avatarImageView.setOnClickListener(null)
holder.memberNameView.setOnClickListener(null)
holder.avatarImageView.visibility = View.GONE
holder.memberNameView.visibility = View.GONE
holder.timeView.visibility = View.GONE
holder.view.setOnClickListener(null)
holder.view.setOnLongClickListener(null)
holder.avatarImageView.setOnLongClickListener(null)
holder.memberNameView.setOnLongClickListener(null)
}
holder.view.setOnClickListener(cellClickListener)
holder.view.setOnLongClickListener(longClickListener)
if (informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper.isVisible = false
if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper?.isVisible = false
} else {
holder.reactionWrapper.isVisible = true
//inflate if needed
if (holder.reactionFlowHelper == null) {
holder.reactionWrapper = holder.view.findViewById<ViewStub>(R.id.messageBottomInfo).inflate() as? ViewGroup
holder.reactionFlowHelper = holder.view.findViewById(R.id.reactionsFlowHelper)
}
holder.reactionWrapper?.isVisible = true
//clear all reaction buttons (but not the Flow helper!)
holder.reactionWrapper.children.forEach { (it as? ReactionButton)?.isGone = true }
holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true }
val idToRefInFlow = ArrayList<Int>()
informationData.orderedReactionList?.forEachIndexed { index, reaction ->
(holder.reactionWrapper.children.elementAt(index) as? ReactionButton)?.let { reactionButton ->
informationData.orderedReactionList?.chunked(7)?.firstOrNull()?.forEachIndexed { index, reaction ->
(holder.reactionWrapper?.children?.elementAtOrNull(index) as? ReactionButton)?.let { reactionButton ->
reactionButton.isVisible = true
reactionButton.reactedListener = reactionClickListener
reactionButton.setTag(R.id.messageBottomInfo, reaction.key)
idToRefInFlow.add(reactionButton.id)
reactionButton.reactionString = reaction.first
reactionButton.reactionCount = reaction.second
reactionButton.setChecked(reaction.third)
reactionButton.reactionString = reaction.key
reactionButton.reactionCount = reaction.count
reactionButton.setChecked(reaction.addedByMe)
reactionButton.isEnabled = reaction.synced
}
}
// Just setting the view as gone will break the FlowHelper (and invisible will take too much space),
// so have to update ref ids
holder.reactionFlowHelper.referencedIds = idToRefInFlow.toIntArray()
holder.reactionFlowHelper?.referencedIds = idToRefInFlow.toIntArray()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && !holder.view.isInLayout) {
holder.reactionFlowHelper.requestLayout()
holder.reactionFlowHelper?.requestLayout()
}
holder.reactionWrapper?.setOnLongClickListener(longClickListener)
}
}
open fun shouldShowReactionAtBottom(): Boolean {
return true
}
protected fun View.renderSendState() {
isClickable = informationData.sendState.isSent()
alpha = if (informationData.sendState.isSent()) 1f else 0.5f
@ -112,8 +145,8 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView)
val reactionWrapper: ViewGroup by bind(R.id.messageBottomInfo)
val reactionFlowHelper: Flow by bind(R.id.reactionsFlowHelper)
var reactionWrapper: ViewGroup? = null
var reactionFlowHelper: Flow? = null
}
}

View File

@ -0,0 +1,27 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home.room.detail.timeline.item
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
@EpoxyModelClass(layout = R.layout.item_timeline_event_blank_stub)
abstract class BlankItem : VectorEpoxyModel<BlankItem.BlankHolder>() {
class BlankHolder : VectorEpoxyHolder()
}

View File

@ -40,6 +40,6 @@ abstract class DefaultItem : BaseEventItem<DefaultItem.Holder>() {
}
companion object {
private val STUB_ID = R.id.messageContentDefaultStub
private const val STUB_ID = R.id.messageContentDefaultStub
}
}

View File

@ -43,6 +43,8 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
ContentUploadStateTrackerBinder.bind(informationData.eventId, mediaData, holder.progressLayout)
holder.imageView.setOnClickListener(clickListener)
holder.imageView.setOnLongClickListener(longClickListener)
holder.mediaContentView.setOnClickListener(cellClickListener)
holder.mediaContentView.setOnLongClickListener(longClickListener)
holder.imageView.renderSendState()
holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
}
@ -62,6 +64,8 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
val imageView by bind<ImageView>(R.id.messageThumbnailView)
val playContentView by bind<ImageView>(R.id.messageMediaPlayView)
val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia)
}

View File

@ -16,9 +16,8 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item
import im.vector.matrix.android.api.session.room.send.SendState
import android.os.Parcelable
import im.vector.matrix.android.api.session.room.send.SendState
import kotlinx.android.parcel.Parcelize
@Parcelize
@ -31,5 +30,15 @@ data class MessageInformationData(
val memberName: CharSequence? = null,
val showInformation: Boolean = true,
/*List of reactions (emoji,count,isSelected)*/
var orderedReactionList: List<Triple<String,Int,Boolean>>? = null
) : Parcelable
var orderedReactionList: List<ReactionInfoData>? = null,
var hasBeenEdited: Boolean = false
) : Parcelable
@Parcelize
data class ReactionInfoData(
val key: String,
val count: Int,
val addedByMe: Boolean,
val synced: Boolean
) : Parcelable

View File

@ -16,23 +16,19 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.text.Spannable
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat
import androidx.core.text.toSpannable
import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.riotredesign.R
import im.vector.riotredesign.features.html.PillImageSpan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.saket.bettermovementmethod.BetterLinkMovementMethod
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@ -41,18 +37,29 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
var message: CharSequence? = null
@EpoxyAttribute
override lateinit var informationData: MessageInformationData
@EpoxyAttribute
var clickListener: View.OnClickListener? = null
val mvmtMethod = BetterLinkMovementMethod.newInstance().also {
it.setOnLinkClickListener { textView, url ->
//Return false to let android manage the click on the link
false
}
it.setOnLinkLongClickListener { textView, url ->
//Long clicks are handled by parent, return true to block android to do something with url
true
}
}
override fun bind(holder: Holder) {
super.bind(holder)
MatrixLinkify.addLinkMovementMethod(holder.messageView)
holder.messageView.movementMethod = mvmtMethod
val textFuture = PrecomputedTextCompat.getTextFuture(message ?: "",
TextViewCompat.getTextMetricsParams(holder.messageView),
null)
holder.messageView.setTextFuture(textFuture)
holder.messageView.renderSendState()
holder.messageView.setOnClickListener (clickListener)
holder.messageView.setOnClickListener(cellClickListener)
holder.messageView.setOnLongClickListener(longClickListener)
findPillsAndProcess { it.bind(holder.messageView) }
}

View File

@ -57,6 +57,6 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
}
companion object {
private val STUB_ID = R.id.messageContentNoticeStub
private const val STUB_ID = R.id.messageContentNoticeStub
}
}

View File

@ -0,0 +1,24 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class RedactedMessageItem : AbsMessageItem<RedactedMessageItem.Holder>() {
@EpoxyAttribute
override lateinit var informationData: MessageInformationData
override fun getStubType(): Int = STUB_ID
override fun shouldShowReactionAtBottom() = false
class Holder : AbsMessageItem.Holder() {
override fun getStubId(): Int = STUB_ID
}
companion object {
private val STUB_ID = R.id.messageContentRedactedStub
}
}

View File

@ -0,0 +1,32 @@
/*
* Copyright 2019 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 im.vector.riotredesign.features.home.room.list
import im.vector.matrix.android.api.session.room.model.RoomSummary
class AlphabeticalRoomComparator
: Comparator<RoomSummary> {
override fun compare(leftRoomSummary: RoomSummary?, rightRoomSummary: RoomSummary?): Int {
return when {
rightRoomSummary?.displayName == null -> -1
leftRoomSummary?.displayName == null -> 1
else -> leftRoomSummary.displayName.compareTo(rightRoomSummary.displayName)
}
}
}

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