updated to 22.0.1
This commit is contained in:
parent
9fc8a043ba
commit
44a570528b
243 changed files with 14828 additions and 3056 deletions
|
|
@ -25,6 +25,9 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.lang.Boolean
|
||||
import kotlin.Long
|
||||
import kotlin.String
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChatBlocksDaoTest {
|
||||
|
|
@ -50,12 +53,82 @@ class ChatBlocksDaoTest {
|
|||
fun closeDb() = db.close()
|
||||
|
||||
@Test
|
||||
fun testGetConnectedChatBlocks() =
|
||||
fun testGetChatBlocksContainingMessageId() =
|
||||
runTest {
|
||||
usersDao.saveUser(createUserEntity("account1", "Account 1"))
|
||||
val user = createUserEntity("account1", "Account 1")
|
||||
usersDao.saveUser(user)
|
||||
val account1 = usersDao.getUserWithUserId("account1").blockingGet()
|
||||
|
||||
conversationsDao.upsertConversations(
|
||||
accountId = user.id,
|
||||
listOf(
|
||||
createConversationEntity(
|
||||
accountId = account1.id,
|
||||
"abc",
|
||||
roomName = "Conversation One"
|
||||
),
|
||||
createConversationEntity(
|
||||
accountId = account1.id,
|
||||
"def",
|
||||
roomName = "Conversation Two"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]
|
||||
|
||||
val chatBlock1 = ChatBlockEntity(
|
||||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = 123,
|
||||
oldestMessageId = 50,
|
||||
newestMessageId = 60,
|
||||
hasHistory = true
|
||||
)
|
||||
|
||||
val chatBlock2 = ChatBlockEntity(
|
||||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = 123,
|
||||
oldestMessageId = 10,
|
||||
newestMessageId = 20,
|
||||
hasHistory = true
|
||||
)
|
||||
|
||||
val chatBlock3 = ChatBlockEntity(
|
||||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 50,
|
||||
newestMessageId = 60,
|
||||
hasHistory = true
|
||||
)
|
||||
|
||||
chatBlocksDao.upsertChatBlock(chatBlock1)
|
||||
chatBlocksDao.upsertChatBlock(chatBlock2)
|
||||
chatBlocksDao.upsertChatBlock(chatBlock3)
|
||||
|
||||
val chatBlocksOfThread = chatBlocksDao.getChatBlocksContainingMessageId(
|
||||
internalConversationId = conversation1.internalId,
|
||||
threadId = 123,
|
||||
messageId = 55
|
||||
)
|
||||
|
||||
assertEquals(1, chatBlocksOfThread.first().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetConnectedChatBlocks() =
|
||||
runTest {
|
||||
val user = createUserEntity("account1", "Account 1")
|
||||
usersDao.saveUser(user)
|
||||
val account1 = usersDao.getUserWithUserId("account1").blockingGet()
|
||||
|
||||
conversationsDao.upsertConversations(
|
||||
account1.id,
|
||||
listOf(
|
||||
createConversationEntity(
|
||||
accountId = account1.id,
|
||||
|
|
@ -77,6 +150,7 @@ class ChatBlocksDaoTest {
|
|||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 50,
|
||||
newestMessageId = 60,
|
||||
hasHistory = true
|
||||
|
|
@ -86,6 +160,7 @@ class ChatBlocksDaoTest {
|
|||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 10,
|
||||
newestMessageId = 20,
|
||||
hasHistory = true
|
||||
|
|
@ -95,6 +170,7 @@ class ChatBlocksDaoTest {
|
|||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 45,
|
||||
newestMessageId = 55,
|
||||
hasHistory = true
|
||||
|
|
@ -104,6 +180,7 @@ class ChatBlocksDaoTest {
|
|||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 52,
|
||||
newestMessageId = 58,
|
||||
hasHistory = true
|
||||
|
|
@ -113,6 +190,7 @@ class ChatBlocksDaoTest {
|
|||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 1,
|
||||
newestMessageId = 99,
|
||||
hasHistory = true
|
||||
|
|
@ -122,6 +200,7 @@ class ChatBlocksDaoTest {
|
|||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 59,
|
||||
newestMessageId = 70,
|
||||
hasHistory = true
|
||||
|
|
@ -131,6 +210,7 @@ class ChatBlocksDaoTest {
|
|||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 80,
|
||||
newestMessageId = 90,
|
||||
hasHistory = true
|
||||
|
|
@ -140,6 +220,7 @@ class ChatBlocksDaoTest {
|
|||
internalConversationId = conversation2.internalId,
|
||||
accountId = conversation2.accountId,
|
||||
token = conversation2.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 53,
|
||||
newestMessageId = 57,
|
||||
hasHistory = true
|
||||
|
|
@ -156,14 +237,94 @@ class ChatBlocksDaoTest {
|
|||
chatBlocksDao.upsertChatBlock(chatBlockWithinButOtherConversation)
|
||||
|
||||
val results = chatBlocksDao.getConnectedChatBlocks(
|
||||
conversation1.internalId,
|
||||
searchedChatBlock.oldestMessageId,
|
||||
searchedChatBlock.newestMessageId
|
||||
internalConversationId = conversation1.internalId,
|
||||
threadId = null,
|
||||
oldestMessageId = searchedChatBlock.oldestMessageId,
|
||||
newestMessageId = searchedChatBlock.newestMessageId
|
||||
)
|
||||
|
||||
assertEquals(5, results.first().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetConnectedChatBlocksWithThreadsScenario() =
|
||||
runTest {
|
||||
val user = createUserEntity("account1", "Account 1")
|
||||
usersDao.saveUser(user)
|
||||
val account1 = usersDao.getUserWithUserId("account1").blockingGet()
|
||||
|
||||
conversationsDao.upsertConversations(
|
||||
account1.id,
|
||||
listOf(
|
||||
createConversationEntity(
|
||||
accountId = account1.id,
|
||||
"abc",
|
||||
roomName = "Conversation One"
|
||||
),
|
||||
createConversationEntity(
|
||||
accountId = account1.id,
|
||||
"def",
|
||||
roomName = "Conversation Two"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]
|
||||
|
||||
val searchedChatBlock = ChatBlockEntity(
|
||||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = 123,
|
||||
oldestMessageId = 50,
|
||||
newestMessageId = 60,
|
||||
hasHistory = true
|
||||
)
|
||||
|
||||
val chatBlockOverlap1 = ChatBlockEntity(
|
||||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = null,
|
||||
oldestMessageId = 45,
|
||||
newestMessageId = 55,
|
||||
hasHistory = true
|
||||
)
|
||||
|
||||
val chatBlockOverlap2 = ChatBlockEntity(
|
||||
internalConversationId = conversation1.internalId,
|
||||
accountId = conversation1.accountId,
|
||||
token = conversation1.token,
|
||||
threadId = 123,
|
||||
oldestMessageId = 59,
|
||||
newestMessageId = 70,
|
||||
hasHistory = true
|
||||
)
|
||||
|
||||
chatBlocksDao.upsertChatBlock(searchedChatBlock)
|
||||
|
||||
chatBlocksDao.upsertChatBlock(chatBlockOverlap1)
|
||||
chatBlocksDao.upsertChatBlock(chatBlockOverlap2)
|
||||
|
||||
val resultsForThreadIdNull = chatBlocksDao.getConnectedChatBlocks(
|
||||
internalConversationId = conversation1.internalId,
|
||||
threadId = null,
|
||||
oldestMessageId = searchedChatBlock.oldestMessageId,
|
||||
newestMessageId = searchedChatBlock.newestMessageId
|
||||
)
|
||||
|
||||
assertEquals(1, resultsForThreadIdNull.first().size)
|
||||
|
||||
val resultsForThreadId123 = chatBlocksDao.getConnectedChatBlocks(
|
||||
internalConversationId = conversation1.internalId,
|
||||
threadId = 123,
|
||||
oldestMessageId = searchedChatBlock.oldestMessageId,
|
||||
newestMessageId = searchedChatBlock.newestMessageId
|
||||
)
|
||||
|
||||
assertEquals(2, resultsForThreadId123.first().size)
|
||||
}
|
||||
|
||||
private fun createUserEntity(userId: String, userName: String) =
|
||||
UserEntity(
|
||||
userId = userId,
|
||||
|
|
@ -176,8 +337,8 @@ class ChatBlocksDaoTest {
|
|||
serverVersion = null,
|
||||
clientCertificate = null,
|
||||
externalSignalingServer = null,
|
||||
current = java.lang.Boolean.FALSE,
|
||||
scheduledForDeletion = java.lang.Boolean.FALSE
|
||||
current = Boolean.FALSE,
|
||||
scheduledForDeletion = Boolean.FALSE
|
||||
)
|
||||
|
||||
private fun createConversationEntity(accountId: Long, token: String, roomName: String) =
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ class ChatMessagesDaoTest {
|
|||
// Problem: lets say we want to update the conv list -> We don#t know the primary keys!
|
||||
// with account@token that would be easier!
|
||||
conversationsDao.upsertConversations(
|
||||
account1.id,
|
||||
listOf(
|
||||
createConversationEntity(
|
||||
accountId = account1.id,
|
||||
|
|
@ -140,7 +141,11 @@ class ChatMessagesDaoTest {
|
|||
assertEquals("are", conv1chatMessage3.message)
|
||||
|
||||
val chatMessagesConv1Since =
|
||||
chatMessagesDao.getMessagesForConversationSince(conversation1.internalId, conv1chatMessage3.id)
|
||||
chatMessagesDao.getMessagesForConversationSince(
|
||||
conversation1.internalId,
|
||||
conv1chatMessage3.id,
|
||||
null
|
||||
)
|
||||
assertEquals(3, chatMessagesConv1Since.first().size)
|
||||
assertEquals("are", chatMessagesConv1Since.first()[0].message)
|
||||
assertEquals("some", chatMessagesConv1Since.first()[1].message)
|
||||
|
|
@ -150,7 +155,8 @@ class ChatMessagesDaoTest {
|
|||
chatMessagesDao.getMessagesForConversationBeforeAndEqual(
|
||||
conversation1.internalId,
|
||||
conv1chatMessage3.id,
|
||||
3
|
||||
3,
|
||||
null
|
||||
)
|
||||
assertEquals(3, chatMessagesConv1To.first().size)
|
||||
assertEquals("hello", chatMessagesConv1To.first()[2].message)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@email.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.data.database.migrations
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.nextcloud.talk.data.source.local.Migrations
|
||||
import com.nextcloud.talk.data.source.local.TalkDatabase
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.IOException
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MigrationsTest {
|
||||
companion object {
|
||||
private const val TEST_DB = "migration-test"
|
||||
private const val INIT_VERSION = 10 // last version before update to offline first
|
||||
private val TAG = MigrationsTest::class.java.simpleName
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val helper: MigrationTestHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
TalkDatabase::class.java
|
||||
)
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
@Suppress("SpreadOperator")
|
||||
fun migrateAll() {
|
||||
helper.createDatabase(TEST_DB, INIT_VERSION).apply {
|
||||
close()
|
||||
}
|
||||
|
||||
Room.databaseBuilder(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
TalkDatabase::class.java,
|
||||
TEST_DB
|
||||
).addMigrations(*TalkDatabase.MIGRATIONS).build().apply {
|
||||
openHelper.writableDatabase.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
fun migrate10To11() {
|
||||
helper.createDatabase(TEST_DB, 10).apply {
|
||||
close()
|
||||
}
|
||||
helper.runMigrationsAndValidate(TEST_DB, 11, true, Migrations.MIGRATION_10_11)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
fun migrate11To12() {
|
||||
helper.createDatabase(TEST_DB, 11).apply {
|
||||
close()
|
||||
}
|
||||
helper.runMigrationsAndValidate(TEST_DB, 12, true, Migrations.MIGRATION_11_12)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
fun migrate12To13() {
|
||||
helper.createDatabase(TEST_DB, 12).apply {
|
||||
close()
|
||||
}
|
||||
helper.runMigrationsAndValidate(TEST_DB, 13, true, Migrations.MIGRATION_12_13)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
fun migrate13To14() {
|
||||
helper.createDatabase(TEST_DB, 13).apply {
|
||||
close()
|
||||
}
|
||||
helper.runMigrationsAndValidate(TEST_DB, 14, true, Migrations.MIGRATION_13_14)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
fun migrate14To15() {
|
||||
helper.createDatabase(TEST_DB, 14).apply {
|
||||
close()
|
||||
}
|
||||
helper.runMigrationsAndValidate(TEST_DB, 15, true, Migrations.MIGRATION_14_15)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
fun migrate15To16() {
|
||||
helper.createDatabase(TEST_DB, 15).apply {
|
||||
close()
|
||||
}
|
||||
helper.runMigrationsAndValidate(TEST_DB, 16, true, Migrations.MIGRATION_15_16)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
fun migrate17To19() {
|
||||
helper.createDatabase(TEST_DB, 17).apply {
|
||||
close()
|
||||
}
|
||||
helper.runMigrationsAndValidate(TEST_DB, 19, true, Migrations.MIGRATION_17_19)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.utils
|
||||
|
||||
import android.graphics.Color
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
class ColorGeneratorTest {
|
||||
|
||||
@Test
|
||||
fun testUsernameToColor() {
|
||||
usernameToColorHexHelper("", "#0082c9")
|
||||
usernameToColorHexHelper(",", "#1e78c1")
|
||||
usernameToColorHexHelper(".", "#c98879")
|
||||
usernameToColorHexHelper("admin", "#d09e6d")
|
||||
usernameToColorHexHelper("123e4567-e89b-12d3-a456-426614174000", "#bc5c91")
|
||||
usernameToColorHexHelper("Akeel Robertson", "#9750a4")
|
||||
usernameToColorHexHelper("Brayden Truong", "#d09e6d")
|
||||
usernameToColorHexHelper("Daphne Roy", "#9750a4")
|
||||
usernameToColorHexHelper("Ellena Wright Frederic Conway", "#c37285")
|
||||
usernameToColorHexHelper("Gianluca Hills", "#d6b461")
|
||||
usernameToColorHexHelper("Haseeb Stephens", "#d6b461")
|
||||
usernameToColorHexHelper("Idris Mac", "#9750a4")
|
||||
usernameToColorHexHelper("Kristi Fisher", "#0082c9")
|
||||
usernameToColorHexHelper("Lillian Wall", "#bc5c91")
|
||||
usernameToColorHexHelper("Lorelai Taylor", "#ddcb55")
|
||||
usernameToColorHexHelper("Madina Knight", "#9750a4")
|
||||
usernameToColorHexHelper("Meeting", "#c98879")
|
||||
usernameToColorHexHelper("Private Circle", "#c37285")
|
||||
usernameToColorHexHelper("Rae Hope", "#795aab")
|
||||
usernameToColorHexHelper("Santiago Singleton", "#bc5c91")
|
||||
usernameToColorHexHelper("Sid Combs", "#d09e6d")
|
||||
usernameToColorHexHelper("TestCircle", "#499aa2")
|
||||
usernameToColorHexHelper("Tom Mörtel", "#248eb5")
|
||||
usernameToColorHexHelper("Vivienne Jacobs", "#1e78c1")
|
||||
usernameToColorHexHelper("Zaki Cortes", "#6ea68f")
|
||||
usernameToColorHexHelper("a user", "#5b64b3")
|
||||
usernameToColorHexHelper("admin@cloud.example.com", "#9750a4")
|
||||
usernameToColorHexHelper("another user", "#ddcb55")
|
||||
usernameToColorHexHelper("asd", "#248eb5")
|
||||
usernameToColorHexHelper("bar", "#0082c9")
|
||||
usernameToColorHexHelper("foo", "#d09e6d")
|
||||
usernameToColorHexHelper("wasd", "#b6469d")
|
||||
usernameToColorHexHelper("مرحبا بالعالم", "#c98879")
|
||||
usernameToColorHexHelper("🙈", "#b6469d")
|
||||
}
|
||||
|
||||
private fun usernameToColorHexHelper(username: String, expectedHexColor: String) {
|
||||
val userColorInt = ColorGenerator.usernameToColor(username) // returns Int
|
||||
val userHexColor = intToHex(userColorInt)
|
||||
|
||||
Assert.assertEquals(expectedHexColor.lowercase(), userHexColor.lowercase())
|
||||
}
|
||||
|
||||
private fun intToHex(colorInt: Int): String {
|
||||
val r = Color.red(colorInt)
|
||||
val g = Color.green(colorInt)
|
||||
val b = Color.blue(colorInt)
|
||||
return String.format("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
}
|
||||
|
|
@ -258,6 +258,10 @@
|
|||
android:name=".lock.LockedActivity"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".threadsoverview.ThreadsOverviewActivity"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.PackageReplacedReceiver"
|
||||
android:exported="false">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
package com.nextcloud.talk.account
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
|
|
@ -21,8 +22,13 @@ import android.view.View
|
|||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import autodagger.AutoInjector
|
||||
import com.blikoon.qrcodescanner.QrCodeActivity
|
||||
import com.github.dhaval2404.imagepicker.util.PermissionUtil
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.activities.BaseActivity
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
|
|
@ -47,6 +53,7 @@ import io.reactivex.schedulers.Schedulers
|
|||
import java.security.cert.CertificateException
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class ServerSelectionActivity : BaseActivity() {
|
||||
|
||||
|
|
@ -120,6 +127,8 @@ class ServerSelectionActivity : BaseActivity() {
|
|||
}
|
||||
binding.certTextView.setOnClickListener { onCertClick() }
|
||||
|
||||
binding.scanQr.setOnClickListener { onScan() }
|
||||
|
||||
if (ApplicationWideMessageHolder.getInstance().messageType != null) {
|
||||
if (ApplicationWideMessageHolder.getInstance().messageType
|
||||
== ApplicationWideMessageHolder.MessageType.SERVER_WITHOUT_TALK
|
||||
|
|
@ -390,6 +399,52 @@ class ServerSelectionActivity : BaseActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private val requestCameraPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
// Permission was granted
|
||||
startQRScanner()
|
||||
}
|
||||
}
|
||||
|
||||
fun onScan() {
|
||||
if (PermissionUtil.isPermissionGranted(this, Manifest.permission.CAMERA)) {
|
||||
startQRScanner()
|
||||
} else {
|
||||
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startQRScanner() {
|
||||
val intent = Intent(this, QrCodeActivity::class.java)
|
||||
qrScanResultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private val qrScanResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
val data = result.data
|
||||
|
||||
if (data == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val resultData = data.getStringExtra(QR_URI)
|
||||
|
||||
if (resultData == null || !resultData.startsWith("nc")) {
|
||||
Snackbar.make(binding.root, getString(R.string.qr_code_error), Snackbar.LENGTH_SHORT).show()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val intent = Intent(this, WebViewLoginActivity::class.java)
|
||||
val bundle = bundleOf().apply {
|
||||
putString(BundleKeys.KEY_FROM_QR, resultData)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
dispose()
|
||||
|
|
@ -408,5 +463,6 @@ class ServerSelectionActivity : BaseActivity() {
|
|||
companion object {
|
||||
private val TAG = ServerSelectionActivity::class.java.simpleName
|
||||
const val MIN_SERVER_MAJOR_VERSION = 13
|
||||
private const val QR_URI = "com.blikoon.qrcodescanner.got_qr_scan_relult"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.account
|
||||
|
|
@ -63,6 +61,7 @@ import java.security.cert.X509Certificate
|
|||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("ReturnCount", "LongMethod")
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class WebViewLoginActivity : BaseActivity() {
|
||||
|
||||
|
|
@ -115,10 +114,9 @@ class WebViewLoginActivity : BaseActivity() {
|
|||
setContentView(binding.root)
|
||||
actionBar?.hide()
|
||||
initSystemBars()
|
||||
|
||||
assembledPrefix = resources!!.getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/"
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
||||
handleIntent()
|
||||
setupWebView()
|
||||
}
|
||||
|
||||
private fun handleIntent() {
|
||||
|
|
@ -133,11 +131,18 @@ class WebViewLoginActivity : BaseActivity() {
|
|||
if (extras.containsKey(BundleKeys.KEY_PASSWORD)) {
|
||||
password = extras.getString(BundleKeys.KEY_PASSWORD)
|
||||
}
|
||||
|
||||
if (extras.containsKey(BundleKeys.KEY_FROM_QR)) {
|
||||
extras.getString(BundleKeys.KEY_FROM_QR)?.let {
|
||||
parseAndLoginFromWebView(it)
|
||||
}
|
||||
} else {
|
||||
setupWebView()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun setupWebView() {
|
||||
assembledPrefix = resources!!.getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/"
|
||||
binding.webview.settings.allowFileAccess = false
|
||||
binding.webview.settings.allowFileAccessFromFileURLs = false
|
||||
binding.webview.settings.javaScriptEnabled = true
|
||||
|
|
@ -289,22 +294,18 @@ class WebViewLoginActivity : BaseActivity() {
|
|||
}
|
||||
|
||||
@SuppressLint("DiscouragedPrivateApi")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "WebViewClientOnReceivedSslError")
|
||||
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
|
||||
try {
|
||||
val sslCertificate = error.certificate
|
||||
val f: Field = sslCertificate.javaClass.getDeclaredField("mX509Certificate")
|
||||
f.isAccessible = true
|
||||
val cert = f[sslCertificate] as X509Certificate
|
||||
if (cert == null) {
|
||||
handler.cancel()
|
||||
} else {
|
||||
try {
|
||||
trustManager.checkServerTrusted(arrayOf(cert), "generic")
|
||||
handler.proceed()
|
||||
} catch (exception: CertificateException) {
|
||||
eventBus.post(CertificateEvent(cert, trustManager, handler))
|
||||
}
|
||||
try {
|
||||
trustManager.checkServerTrusted(arrayOf(cert), "generic")
|
||||
handler.proceed()
|
||||
} catch (exception: CertificateException) {
|
||||
eventBus.post(CertificateEvent(cert, trustManager, handler))
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
handler.cancel()
|
||||
|
|
@ -332,12 +333,16 @@ class WebViewLoginActivity : BaseActivity() {
|
|||
dispose()
|
||||
cookieManager.cookieStore.removeAll()
|
||||
|
||||
if (userManager.checkIfUserIsScheduledForDeletion(loginData.username!!, baseUrl!!).blockingGet()) {
|
||||
if (userManager.checkIfUserIsScheduledForDeletion(loginData.username!!, loginData.serverUrl!!)
|
||||
.blockingGet()
|
||||
) {
|
||||
Log.e(TAG, "Tried to add already existing user who is scheduled for deletion.")
|
||||
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
||||
// however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it.
|
||||
startAccountRemovalWorkerAndRestartApp()
|
||||
} else if (userManager.checkIfUserExists(loginData.username!!, baseUrl!!).blockingGet()) {
|
||||
} else if (userManager.checkIfUserExists(loginData.username!!, loginData.serverUrl!!)
|
||||
.blockingGet()
|
||||
) {
|
||||
if (reauthorizeAccount) {
|
||||
updateUserAndRestartApp(loginData)
|
||||
} else {
|
||||
|
|
@ -347,6 +352,9 @@ class WebViewLoginActivity : BaseActivity() {
|
|||
} else {
|
||||
startAccountVerification(loginData)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Login Data was null")
|
||||
restartApp()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -356,9 +364,9 @@ class WebViewLoginActivity : BaseActivity() {
|
|||
bundle.putString(KEY_TOKEN, loginData.token)
|
||||
bundle.putString(KEY_BASE_URL, loginData.serverUrl)
|
||||
var protocol = ""
|
||||
if (baseUrl!!.startsWith("http://")) {
|
||||
if (loginData.serverUrl!!.startsWith("http://")) {
|
||||
protocol = "http://"
|
||||
} else if (baseUrl!!.startsWith("https://")) {
|
||||
} else if (loginData.serverUrl!!.startsWith("https://")) {
|
||||
protocol = "https://"
|
||||
}
|
||||
if (!TextUtils.isEmpty(protocol)) {
|
||||
|
|
@ -416,17 +424,17 @@ class WebViewLoginActivity : BaseActivity() {
|
|||
return null
|
||||
}
|
||||
for (value in values) {
|
||||
if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
|
||||
if (value.startsWith("user$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR")) {
|
||||
loginData.username = URLDecoder.decode(
|
||||
value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
|
||||
value.substring(("user$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length)
|
||||
)
|
||||
} else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
|
||||
} else if (value.startsWith("password$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR")) {
|
||||
loginData.token = URLDecoder.decode(
|
||||
value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
|
||||
value.substring(("password$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length)
|
||||
)
|
||||
} else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
|
||||
} else if (value.startsWith("server$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR")) {
|
||||
loginData.serverUrl = URLDecoder.decode(
|
||||
value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
|
||||
value.substring(("server$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length)
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.data
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.nextcloud.talk.account.data.io.LocalLoginDataSource
|
||||
import com.nextcloud.talk.account.data.model.LoginCompletion
|
||||
import com.nextcloud.talk.account.data.model.LoginResponse
|
||||
import com.nextcloud.talk.account.data.network.NetworkLoginDataSource
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLDecoder
|
||||
|
||||
@Suppress("TooManyFunctions", "ReturnCount")
|
||||
class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLoginDataSource) {
|
||||
|
||||
companion object {
|
||||
val TAG: String = LoginRepository::class.java.simpleName
|
||||
private const val INTERVAL = 250L
|
||||
private const val HTTP_OK = 200
|
||||
private const val USER_KEY = "user:"
|
||||
private const val SERVER_KEY = "server:"
|
||||
private const val PASS_KEY = "password:"
|
||||
private const val PREFIX = "nc://login/"
|
||||
private const val MAX_ARGS = 3
|
||||
}
|
||||
|
||||
private var shouldReauthorizeUser = false
|
||||
private var shouldLoop = true
|
||||
|
||||
suspend fun pollLogin(response: LoginResponse): LoginCompletion? =
|
||||
withContext(Dispatchers.IO) {
|
||||
while (shouldLoop) {
|
||||
val loginData = network.performLoginFlowV2(response)
|
||||
if (loginData == null) {
|
||||
break
|
||||
}
|
||||
|
||||
if (loginData.status == HTTP_OK) {
|
||||
return@withContext loginData
|
||||
}
|
||||
|
||||
delay(INTERVAL) // No response yet, retry
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for QR scanner
|
||||
*
|
||||
*/
|
||||
fun startLoginFlowFromQR(dataString: String, reAuth: Boolean = false): LoginCompletion? {
|
||||
shouldReauthorizeUser = reAuth
|
||||
|
||||
if (!dataString.startsWith(PREFIX)) {
|
||||
Log.e(TAG, "Invalid login URL detected")
|
||||
return null
|
||||
}
|
||||
|
||||
val data = dataString.removePrefix(PREFIX)
|
||||
val values = data.split('&')
|
||||
|
||||
if (values.size !in 1..MAX_ARGS) {
|
||||
Log.e(TAG, "Illegal number of login URL elements detected: ${values.size}")
|
||||
return null
|
||||
}
|
||||
|
||||
var server = ""
|
||||
var loginName = ""
|
||||
var appPassword = ""
|
||||
values.forEach { value ->
|
||||
when {
|
||||
value.startsWith(USER_KEY) -> {
|
||||
loginName = URLDecoder.decode(value.removePrefix(USER_KEY), "UTF-8")
|
||||
}
|
||||
|
||||
value.startsWith(PASS_KEY) -> {
|
||||
appPassword = URLDecoder.decode(value.removePrefix(PASS_KEY), "UTF-8")
|
||||
}
|
||||
|
||||
value.startsWith(SERVER_KEY) -> {
|
||||
server = URLDecoder.decode(value.removePrefix(SERVER_KEY), "UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (server.isNotEmpty() && loginName.isNotEmpty() && appPassword.isNotEmpty()) {
|
||||
LoginCompletion(HTTP_OK, server, loginName, appPassword)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point to the login process
|
||||
*/
|
||||
suspend fun startLoginFlow(baseUrl: String, reAuth: Boolean = false): LoginResponse? =
|
||||
withContext(Dispatchers.IO) {
|
||||
shouldReauthorizeUser = reAuth
|
||||
val response = network.anonymouslyPostLoginRequest(baseUrl)
|
||||
return@withContext response
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends normal login process by canceling the polling
|
||||
*/
|
||||
fun cancelLoginFlow() {
|
||||
shouldLoop = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bundle if user is not scheduled for deletion or doesn't already exist, null otherwise
|
||||
*/
|
||||
fun parseAndLogin(loginData: LoginCompletion): Bundle? {
|
||||
if (local.checkIfUserIsScheduledForDeletion(loginData)) {
|
||||
// however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it.
|
||||
local.startAccountRemovalWorker()
|
||||
return null
|
||||
} else if (local.checkIfUserExists(loginData)) {
|
||||
if (shouldReauthorizeUser) {
|
||||
local.updateUser(loginData)
|
||||
} else {
|
||||
Log.w(TAG, "Tried to add an account that account already exists. Skipped user creation.")
|
||||
}
|
||||
|
||||
return null
|
||||
} else {
|
||||
return startAccountVerification(loginData)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAccountVerification(loginData: LoginCompletion): Bundle {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_USERNAME, loginData.loginName)
|
||||
bundle.putString(KEY_TOKEN, loginData.appPassword)
|
||||
bundle.putString(KEY_BASE_URL, loginData.server)
|
||||
var protocol = ""
|
||||
if (loginData.server.startsWith("http://")) {
|
||||
protocol = "http://"
|
||||
} else if (loginData.server.startsWith("https://")) {
|
||||
protocol = "https://"
|
||||
}
|
||||
if (!TextUtils.isEmpty(protocol)) {
|
||||
bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.data.io
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.nextcloud.talk.account.data.model.LoginCompletion
|
||||
import com.nextcloud.talk.jobs.AccountRemovalWorker
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
|
||||
// local datasource for communicating with room through account manager
|
||||
// crucial for making sure the login process interacts with the db as expected.
|
||||
class LocalLoginDataSource(val userManager: UserManager, val appPreferences: AppPreferences, val context: Context) {
|
||||
|
||||
fun updateUser(loginData: LoginCompletion) {
|
||||
val currentUser = userManager.currentUser.blockingGet()
|
||||
if (currentUser != null) {
|
||||
currentUser.clientCertificate = appPreferences.temporaryClientCertAlias
|
||||
currentUser.token = loginData.appPassword
|
||||
userManager.updateOrCreateUser(currentUser)
|
||||
}
|
||||
}
|
||||
|
||||
fun startAccountRemovalWorker(): LiveData<WorkInfo?> {
|
||||
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
|
||||
WorkManager.getInstance(context).enqueue(accountRemovalWork)
|
||||
|
||||
return WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
|
||||
}
|
||||
|
||||
fun checkIfUserIsScheduledForDeletion(data: LoginCompletion): Boolean =
|
||||
userManager.checkIfUserIsScheduledForDeletion(data.loginName, data.server).blockingGet()
|
||||
|
||||
fun checkIfUserExists(data: LoginCompletion): Boolean =
|
||||
userManager.checkIfUserExists(data.loginName, data.server).blockingGet()
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.data.model
|
||||
|
||||
data class LoginResponse(val token: String, val pollUrl: String, val loginUrl: String)
|
||||
|
||||
data class LoginCompletion(val status: Int, val server: String, val loginName: String, val appPassword: String)
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.data.network
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import com.nextcloud.talk.account.data.model.LoginCompletion
|
||||
import com.nextcloud.talk.account.data.model.LoginResponse
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import java.io.IOException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
// This class handles the network and polling logic in isolation, which makes it easier to test
|
||||
// Login and Authentication is critical, thus it needs to be working properly.
|
||||
class NetworkLoginDataSource(val okHttpClient: OkHttpClient) {
|
||||
|
||||
companion object {
|
||||
val TAG: String = NetworkLoginDataSource::class.java.simpleName
|
||||
}
|
||||
|
||||
fun anonymouslyPostLoginRequest(baseUrl: String): LoginResponse? {
|
||||
val url = "$baseUrl/index.php/login/v2"
|
||||
var result: LoginResponse? = null
|
||||
runCatching {
|
||||
val response = getResponseOfAnonymouslyPostLoginRequest(url)
|
||||
val jsonObject: JsonObject = JsonParser.parseString(response).asJsonObject
|
||||
val loginUrl: String = getLoginUrl(jsonObject)
|
||||
val token = jsonObject.getAsJsonObject("poll").get("token").asString
|
||||
val pollUrl = jsonObject.getAsJsonObject("poll").get("endpoint").asString
|
||||
result = LoginResponse(token, pollUrl, loginUrl)
|
||||
}.getOrElse { e ->
|
||||
when (e) {
|
||||
is SSLHandshakeException,
|
||||
is NullPointerException,
|
||||
is IOException -> {
|
||||
Log.e(TAG, "Error caught at anonymouslyPostLoginRequest: $e")
|
||||
}
|
||||
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun getResponseOfAnonymouslyPostLoginRequest(url: String): String? {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(FormBody.Builder().build())
|
||||
.addHeader("Clear-Site-Data", "cookies")
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Unexpected code $response")
|
||||
}
|
||||
return response.body?.string()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLoginUrl(response: JsonObject): String {
|
||||
var result: String? = response.get("login").asString
|
||||
if (result == null) {
|
||||
result = ""
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun performLoginFlowV2(response: LoginResponse): LoginCompletion? {
|
||||
val requestBody: RequestBody = FormBody.Builder()
|
||||
.add("token", response.token)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(response.pollUrl)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
var result: LoginCompletion? = null
|
||||
runCatching {
|
||||
okHttpClient.newCall(request).execute()
|
||||
.use { response ->
|
||||
val status: Int = response.code
|
||||
val responseBody = response.body?.string()
|
||||
|
||||
result = if (response.isSuccessful && responseBody?.isNotEmpty() == true) {
|
||||
val jsonObject = JsonParser.parseString(responseBody).asJsonObject
|
||||
val server: String = jsonObject.get("server").asString
|
||||
val loginName: String = jsonObject.get("loginName").asString
|
||||
val appPassword: String = jsonObject.get("appPassword").asString
|
||||
|
||||
LoginCompletion(status, server, loginName, appPassword)
|
||||
} else {
|
||||
LoginCompletion(status, "", "", "")
|
||||
}
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
when (e) {
|
||||
is NullPointerException,
|
||||
is SSLHandshakeException,
|
||||
is IllegalStateException,
|
||||
is IOException -> {
|
||||
Log.e(TAG, "Error caught at performLoginFlowV2: $e")
|
||||
}
|
||||
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.account.viewmodels
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nextcloud.talk.account.data.LoginRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRepository) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = BrowserLoginActivityViewModel::class.java.simpleName
|
||||
}
|
||||
|
||||
sealed class InitialLoginViewState {
|
||||
data object None : InitialLoginViewState()
|
||||
data class InitialLoginRequestSuccess(val loginUrl: String) : InitialLoginViewState()
|
||||
data object InitialLoginRequestError : InitialLoginViewState()
|
||||
}
|
||||
|
||||
private val _initialLoginRequestState = MutableStateFlow<InitialLoginViewState>(InitialLoginViewState.None)
|
||||
val initialLoginRequestState: StateFlow<InitialLoginViewState> = _initialLoginRequestState
|
||||
|
||||
sealed class PostLoginViewState {
|
||||
data object None : PostLoginViewState()
|
||||
data object PostLoginRestartApp : PostLoginViewState()
|
||||
data object PostLoginError : PostLoginViewState()
|
||||
data class PostLoginContinue(val data: Bundle) : PostLoginViewState()
|
||||
}
|
||||
|
||||
private val _postLoginState = MutableStateFlow<PostLoginViewState>(PostLoginViewState.None)
|
||||
val postLoginState: StateFlow<PostLoginViewState> = _postLoginState
|
||||
|
||||
fun loginNormally(baseUrl: String, reAuth: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
val response = repository.startLoginFlow(baseUrl, reAuth)
|
||||
|
||||
if (response == null) {
|
||||
_initialLoginRequestState.value = InitialLoginViewState.InitialLoginRequestError
|
||||
return@launch
|
||||
}
|
||||
|
||||
_initialLoginRequestState.value =
|
||||
InitialLoginViewState.InitialLoginRequestSuccess(response.loginUrl)
|
||||
|
||||
val loginCompletionResponse = repository.pollLogin(response)
|
||||
|
||||
if (loginCompletionResponse == null) {
|
||||
_postLoginState.value = PostLoginViewState.PostLoginError
|
||||
return@launch
|
||||
}
|
||||
|
||||
val bundle = repository.parseAndLogin(loginCompletionResponse)
|
||||
if (bundle == null) {
|
||||
_postLoginState.value = PostLoginViewState.PostLoginRestartApp
|
||||
return@launch
|
||||
}
|
||||
|
||||
_postLoginState.value = PostLoginViewState.PostLoginContinue(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
fun loginWithQR(dataString: String, reAuth: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth)
|
||||
if (loginCompletionResponse == null) {
|
||||
_postLoginState.value = PostLoginViewState.PostLoginError
|
||||
return@launch
|
||||
}
|
||||
|
||||
val bundle = repository.parseAndLogin(loginCompletionResponse)
|
||||
if (bundle == null) {
|
||||
_postLoginState.value = PostLoginViewState.PostLoginRestartApp
|
||||
return@launch
|
||||
}
|
||||
|
||||
_postLoginState.value = PostLoginViewState.PostLoginContinue(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelLogin() = repository.cancelLoginFlow()
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ import android.text.TextUtils
|
|||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.webkit.SslErrorHandler
|
||||
|
|
@ -25,6 +24,8 @@ import android.widget.EditText
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import autodagger.AutoInjector
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.nextcloud.talk.R
|
||||
|
|
@ -119,18 +120,23 @@ open class BaseActivity : AppCompatActivity() {
|
|||
* May be aligned with android-common lib in the future: .../ui/util/extensions/AppCompatActivityExtensions.kt
|
||||
*/
|
||||
fun initSystemBars() {
|
||||
window.decorView.setOnApplyWindowInsetsListener { view, insets ->
|
||||
val decorView = window.decorView
|
||||
decorView.setOnApplyWindowInsetsListener { view, insets ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
val statusBarHeight = insets.getInsets(WindowInsets.Type.statusBars()).top
|
||||
view.setPadding(0, statusBarHeight, 0, 0)
|
||||
val systemBars = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or
|
||||
WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
val color = ResourcesCompat.getColor(resources, R.color.bg_default, context.theme)
|
||||
view.setBackgroundColor(color)
|
||||
view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
|
||||
} else {
|
||||
colorizeStatusBar()
|
||||
colorizeNavigationBar()
|
||||
}
|
||||
insets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(decorView)
|
||||
}
|
||||
|
||||
open fun colorizeStatusBar() {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
package com.nextcloud.talk.activities
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
|
|
@ -93,7 +92,7 @@ class MainActivity :
|
|||
}
|
||||
|
||||
fun lockScreenIfConditionsApply() {
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
|
||||
if (keyguardManager.isKeyguardSecure && appPreferences.isScreenLocked) {
|
||||
if (!SecurityUtils.checkIfWeAreAuthenticated(appPreferences.screenLockTimeout)) {
|
||||
val lockIntent = Intent(context, LockedActivity::class.java)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import android.view.View
|
|||
import android.widget.RelativeLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import coil.dispose
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.adapters.items.ConversationItem.ConversationItemViewHolder
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
|
|
@ -182,7 +183,9 @@ class ConversationItem(
|
|||
}
|
||||
|
||||
private fun showAvatar(holder: ConversationItemViewHolder) {
|
||||
holder.binding.dialogAvatar.dispose()
|
||||
holder.binding.dialogAvatar.visibility = View.VISIBLE
|
||||
|
||||
var shouldLoadAvatar = shouldLoadAvatar(holder)
|
||||
if (ConversationEnums.ConversationType.ROOM_SYSTEM == model.type) {
|
||||
holder.binding.dialogAvatar.loadSystemAvatar()
|
||||
|
|
|
|||
|
|
@ -221,6 +221,10 @@ class MentionAutocompleteItem(
|
|||
if (statusMessage.isNullOrEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.dnd)
|
||||
}
|
||||
} else if (status != null && status == StatusType.BUSY.string) {
|
||||
if (statusMessage.isNullOrEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.busy)
|
||||
}
|
||||
} else if (status != null && status == StatusType.AWAY.string) {
|
||||
if (statusMessage.isNullOrEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.away)
|
||||
|
|
|
|||
|
|
@ -276,6 +276,10 @@ class ParticipantItem(
|
|||
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.dnd)
|
||||
}
|
||||
} else if (model.status != null && model.status == StatusType.BUSY.string) {
|
||||
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.busy)
|
||||
}
|
||||
} else if (model.status != null && model.status == StatusType.AWAY.string) {
|
||||
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
|
||||
holder.binding.conversationInfoStatusMessage.setText(R.string.away)
|
||||
|
|
|
|||
|
|
@ -22,21 +22,21 @@ interface AdjustableMessageHolderInterface {
|
|||
|
||||
val binding: ViewBinding
|
||||
|
||||
fun adjustIfNoteToSelf(viewHolder: AdjustableMessageHolderInterface, currentConversation: ConversationModel?) {
|
||||
fun adjustIfNoteToSelf(currentConversation: ConversationModel?) {
|
||||
if (currentConversation?.type == ConversationType.NOTE_TO_SELF) {
|
||||
when (viewHolder.binding.javaClass) {
|
||||
when (this.binding.javaClass) {
|
||||
ItemCustomOutcomingTextMessageBinding::class.java ->
|
||||
(viewHolder.binding as ItemCustomOutcomingTextMessageBinding).bubble
|
||||
(this.binding as ItemCustomOutcomingTextMessageBinding).bubble
|
||||
ItemCustomOutcomingDeckCardMessageBinding::class.java ->
|
||||
(viewHolder.binding as ItemCustomOutcomingDeckCardMessageBinding).bubble
|
||||
(this.binding as ItemCustomOutcomingDeckCardMessageBinding).bubble
|
||||
ItemCustomOutcomingLinkPreviewMessageBinding::class.java ->
|
||||
(viewHolder.binding as ItemCustomOutcomingLinkPreviewMessageBinding).bubble
|
||||
(this.binding as ItemCustomOutcomingLinkPreviewMessageBinding).bubble
|
||||
ItemCustomOutcomingPollMessageBinding::class.java ->
|
||||
(viewHolder.binding as ItemCustomOutcomingPollMessageBinding).bubble
|
||||
(this.binding as ItemCustomOutcomingPollMessageBinding).bubble
|
||||
ItemCustomOutcomingVoiceMessageBinding::class.java ->
|
||||
(viewHolder.binding as ItemCustomOutcomingVoiceMessageBinding).bubble
|
||||
(this.binding as ItemCustomOutcomingVoiceMessageBinding).bubble
|
||||
ItemCustomOutcomingLocationMessageBinding::class.java ->
|
||||
(viewHolder.binding as ItemCustomOutcomingLocationMessageBinding).bubble
|
||||
(this.binding as ItemCustomOutcomingLocationMessageBinding).bubble
|
||||
else -> null
|
||||
}?.let {
|
||||
RelativeLayout.LayoutParams(binding.root.layoutParams).apply {
|
||||
|
|
|
|||
|
|
@ -12,4 +12,5 @@ interface CommonMessageInterface {
|
|||
fun onLongClickReactions(chatMessage: ChatMessage)
|
||||
fun onClickReaction(chatMessage: ChatMessage, emoji: String)
|
||||
fun onOpenMessageActionsDialog(chatMessage: ChatMessage)
|
||||
fun openThread(chatMessage: ChatMessage)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ class IncomingDeckCardViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -229,10 +229,18 @@ class IncomingDeckCardViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quoteColoredView
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,19 @@ class IncomingLinkPreviewMessageViewHolder(incomingView: View, payload: Any) :
|
|||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val showThreadButton = chatActivity.conversationThreadId == null && message.isThread
|
||||
if (showThreadButton) {
|
||||
binding.reactions.threadButton.visibility = View.VISIBLE
|
||||
binding.reactions.threadButton.setContent {
|
||||
ThreadButtonComposable(
|
||||
onButtonClick = { openThread(message) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
binding.reactions.threadButton.visibility = View.GONE
|
||||
}
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
|
|
@ -129,6 +142,10 @@ class IncomingLinkPreviewMessageViewHolder(incomingView: View, payload: Any) :
|
|||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
|
||||
val actorName = message.actorDisplayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
|
|
@ -157,7 +174,7 @@ class IncomingLinkPreviewMessageViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -204,10 +221,18 @@ class IncomingLinkPreviewMessageViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quoteColoredView
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class IncomingLocationMessageViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -189,10 +189,18 @@ class IncomingLocationMessageViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quoteColoredView
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ class IncomingPollMessageViewHolder(incomingView: View, payload: Any) :
|
|||
|
||||
setPollPreview(message)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = binding.threadTitleWrapper,
|
||||
reactionsBinding = binding.reactions,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
|
|
@ -100,6 +109,10 @@ class IncomingPollMessageViewHolder(incomingView: View, payload: Any) :
|
|||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
private fun setPollPreview(message: ChatMessage) {
|
||||
var pollId: String? = null
|
||||
var pollName: String? = null
|
||||
|
|
@ -164,7 +177,7 @@ class IncomingPollMessageViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -211,9 +224,17 @@ class IncomingPollMessageViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quoteColoredView
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import android.widget.ProgressBar;
|
|||
import com.google.android.material.card.MaterialCardView;
|
||||
import com.nextcloud.talk.R;
|
||||
import com.nextcloud.talk.databinding.ItemCustomIncomingPreviewMessageBinding;
|
||||
import com.nextcloud.talk.databinding.ItemThreadTitleBinding;
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding;
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage;
|
||||
import com.nextcloud.talk.utils.TextMatchers;
|
||||
|
|
@ -138,4 +139,7 @@ public class IncomingPreviewMessageViewHolder extends PreviewMessageViewHolder {
|
|||
|
||||
@Override
|
||||
public ReactionsInsideMessageBinding getReactionsBinding(){ return binding.reactions; }
|
||||
|
||||
@Override
|
||||
public ItemThreadTitleBinding getThreadsBinding(){ return binding.threadTitleWrapper; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
|
|||
}
|
||||
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||
binding.messageText.text = processedMessageText
|
||||
// just for debugging:
|
||||
// binding.messageText.text =
|
||||
// SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")")
|
||||
} else {
|
||||
binding.messageText.visibility = View.GONE
|
||||
binding.checkboxContainer.visibility = View.VISIBLE
|
||||
|
|
@ -159,16 +162,35 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
|
|||
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
}
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
|
||||
// parent message handling
|
||||
if (!message.isDeleted && message.parentMessageId != null) {
|
||||
processParentMessage(message)
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
processParentMessage(message)
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.setOnLongClickListener { l: View? ->
|
||||
commonMessageInterface.onOpenMessageActionsDialog(message)
|
||||
true
|
||||
}
|
||||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = binding.threadTitleWrapper,
|
||||
reactionsBinding = binding.reactions,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
|
|
@ -291,6 +313,10 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
|
|||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
|
||||
val actorName = message.actorDisplayName
|
||||
if (!actorName.isNullOrBlank()) {
|
||||
|
|
@ -369,7 +395,7 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quoteColoredView,
|
||||
binding.messageQuote.quotedChatMessageView,
|
||||
R.color.high_emphasis_text
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -351,10 +351,20 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
|||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quoteColoredView
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import coil.load
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ReferenceInsideMessageBinding
|
||||
|
|
@ -74,12 +76,18 @@ class LinkPreview {
|
|||
}
|
||||
|
||||
val referenceThumbUrl = reference.openGraphObject?.thumb
|
||||
var backgroundId = R.drawable.link_text_background
|
||||
if (!referenceThumbUrl.isNullOrEmpty()) {
|
||||
binding.referenceThumbImage.visibility = View.VISIBLE
|
||||
binding.referenceThumbImage.load(referenceThumbUrl)
|
||||
} else {
|
||||
backgroundId = R.drawable.link_text_no_preview_background
|
||||
binding.referenceThumbImage.visibility = View.GONE
|
||||
}
|
||||
binding.referenceMetadataContainer.background = ContextCompat.getDrawable(
|
||||
binding.referenceMetadataContainer.context,
|
||||
backgroundId
|
||||
)
|
||||
|
||||
binding.referenceWrapper.setOnClickListener {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, referenceLink!!.toUri())
|
||||
|
|
@ -95,7 +103,6 @@ class LinkPreview {
|
|||
binding.referenceDescription.visibility = View.GONE
|
||||
binding.referenceLink.visibility = View.GONE
|
||||
binding.referenceThumbImage.visibility = View.GONE
|
||||
binding.referenceIndentedSideBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ class OutcomingDeckCardViewHolder(outcomingView: View) :
|
|||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -217,10 +217,18 @@ class OutcomingDeckCardViewHolder(outcomingView: View) :
|
|||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quoteColoredView
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,19 @@ class OutcomingLinkPreviewMessageViewHolder(outcomingView: View, payload: Any) :
|
|||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
val showThreadButton = chatActivity.conversationThreadId == null && message.isThread
|
||||
if (showThreadButton) {
|
||||
binding.reactions.threadButton.visibility = View.VISIBLE
|
||||
binding.reactions.threadButton.setContent {
|
||||
ThreadButtonComposable(
|
||||
onButtonClick = { openThread(message) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
binding.reactions.threadButton.visibility = View.GONE
|
||||
}
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
|
|
@ -146,7 +159,11 @@ class OutcomingLinkPreviewMessageViewHolder(outcomingView: View, payload: Any) :
|
|||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -188,9 +205,21 @@ class OutcomingLinkPreviewMessageViewHolder(outcomingView: View, payload: Any) :
|
|||
)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ class OutcomingLocationMessageViewHolder(incomingView: View) :
|
|||
})
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -238,9 +238,21 @@ class OutcomingLocationMessageViewHolder(incomingView: View) :
|
|||
)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ import javax.inject.Inject
|
|||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class OutcomingPollMessageViewHolder(outcomingView: View, payload: Any) :
|
||||
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView, payload) {
|
||||
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView, payload),
|
||||
AdjustableMessageHolderInterface {
|
||||
|
||||
private val binding: ItemCustomOutcomingPollMessageBinding = ItemCustomOutcomingPollMessageBinding.bind(itemView)
|
||||
override val binding: ItemCustomOutcomingPollMessageBinding = ItemCustomOutcomingPollMessageBinding.bind(itemView)
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
|
@ -103,6 +104,15 @@ class OutcomingPollMessageViewHolder(outcomingView: View, payload: Any) :
|
|||
|
||||
setPollPreview(message)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = binding.threadTitleWrapper,
|
||||
reactionsBinding = binding.reactions,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
|
|
@ -122,6 +132,10 @@ class OutcomingPollMessageViewHolder(outcomingView: View, payload: Any) :
|
|||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
private fun setPollPreview(message: ChatMessage) {
|
||||
var pollId: String? = null
|
||||
var pollName: String? = null
|
||||
|
|
@ -158,7 +172,7 @@ class OutcomingPollMessageViewHolder(outcomingView: View, payload: Any) :
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -200,9 +214,21 @@ class OutcomingPollMessageViewHolder(outcomingView: View, payload: Any) :
|
|||
)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import android.widget.ProgressBar;
|
|||
import com.google.android.material.card.MaterialCardView;
|
||||
import com.nextcloud.talk.R;
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingPreviewMessageBinding;
|
||||
import com.nextcloud.talk.databinding.ItemThreadTitleBinding;
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding;
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage;
|
||||
import com.nextcloud.talk.utils.TextMatchers;
|
||||
|
|
@ -133,6 +134,9 @@ public class OutcomingPreviewMessageViewHolder extends PreviewMessageViewHolder
|
|||
@Override
|
||||
public ReactionsInsideMessageBinding getReactionsBinding() { return binding.reactions; }
|
||||
|
||||
@Override
|
||||
public ItemThreadTitleBinding getThreadsBinding(){ return binding.threadTitleWrapper; }
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public EmojiTextView getMessageCaption() { return binding.messageCaption; }
|
||||
|
|
|
|||
|
|
@ -160,6 +160,9 @@ class OutcomingTextMessageViewHolder(itemView: View) :
|
|||
binding.messageTime.layoutParams = layoutParams
|
||||
viewThemeUtils.platform.colorTextView(binding.messageText, ColorRole.ON_SURFACE_VARIANT)
|
||||
binding.messageText.text = processedMessageText
|
||||
// just for debugging:
|
||||
// binding.messageText.text =
|
||||
// SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")")
|
||||
} else {
|
||||
binding.messageText.visibility = View.GONE
|
||||
binding.checkboxContainer.visibility = View.VISIBLE
|
||||
|
|
@ -174,12 +177,23 @@ class OutcomingTextMessageViewHolder(itemView: View) :
|
|||
}
|
||||
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
|
||||
setBubbleOnChatMessage(message)
|
||||
|
||||
// parent message handling
|
||||
if (!message.isDeleted && message.parentMessageId != null) {
|
||||
processParentMessage(message)
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
processParentMessage(message)
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.setOnLongClickListener { l: View? ->
|
||||
commonMessageInterface.onOpenMessageActionsDialog(message)
|
||||
true
|
||||
}
|
||||
|
||||
binding.checkMark.visibility = View.INVISIBLE
|
||||
|
|
@ -195,8 +209,6 @@ class OutcomingTextMessageViewHolder(itemView: View) :
|
|||
updateStatus(R.drawable.ic_check, context.resources?.getString(R.string.nc_message_sent))
|
||||
}
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
|
||||
chatActivity.lifecycleScope.launch {
|
||||
if (message.isTemporary && !networkMonitor.isOnline.value) {
|
||||
updateStatus(
|
||||
|
|
@ -208,6 +220,14 @@ class OutcomingTextMessageViewHolder(itemView: View) :
|
|||
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = binding.threadTitleWrapper,
|
||||
reactionsBinding = binding.reactions,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
::clickOnReaction,
|
||||
|
|
@ -345,6 +365,10 @@ class OutcomingTextMessageViewHolder(itemView: View) :
|
|||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
private fun processParentMessage(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
|
|
@ -389,7 +413,11 @@ class OutcomingTextMessageViewHolder(itemView: View) :
|
|||
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.setOnClickListener {
|
||||
chatActivity.jumpToQuotedMessage(parentChatMessage)
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
|||
binding.progressBar.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
|
||||
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
|
||||
if (message.parentMessageId != null && !message.isDeleted) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
@ -349,9 +349,21 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
|||
)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
|
||||
viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
|
||||
viewThemeUtils.talk.themeParentMessage(
|
||||
parentChatMessage,
|
||||
message,
|
||||
binding.messageQuote.quotedChatMessageView
|
||||
)
|
||||
|
||||
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
|
||||
binding.messageQuote.quotedChatMessageView.visibility =
|
||||
if (!message.isDeleted &&
|
||||
message.parentMessageId != null &&
|
||||
message.parentMessageId != chatActivity.conversationThreadId
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error when processing parent message in view holder", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
|||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ItemThreadTitleBinding
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding
|
||||
import com.nextcloud.talk.extensions.loadChangelogBotAvatar
|
||||
import com.nextcloud.talk.extensions.loadFederatedUserAvatar
|
||||
|
|
@ -78,6 +80,7 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) :
|
|||
var okHttpClient: OkHttpClient? = null
|
||||
open var progressBar: ProgressBar? = null
|
||||
open var reactionsBinding: ReactionsInsideMessageBinding? = null
|
||||
open var threadsBinding: ItemThreadTitleBinding? = null
|
||||
var fileViewerUtils: FileViewerUtils? = null
|
||||
var clickView: View? = null
|
||||
|
||||
|
|
@ -150,6 +153,16 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) :
|
|||
messageText.text = ""
|
||||
}
|
||||
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
|
||||
|
||||
val chatActivity = commonMessageInterface as ChatActivity
|
||||
Thread().showThreadPreview(
|
||||
chatActivity,
|
||||
message,
|
||||
threadBinding = threadsBinding!!,
|
||||
reactionsBinding = reactionsBinding!!,
|
||||
openThread = { openThread(message) }
|
||||
)
|
||||
|
||||
val paddingSide = DisplayUtils.convertDpToPixel(HORIZONTAL_REACTION_PADDING, context!!).toInt()
|
||||
Reaction().showReactions(
|
||||
message,
|
||||
|
|
@ -203,6 +216,10 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) :
|
|||
commonMessageInterface.onClickReaction(chatMessage, emoji)
|
||||
}
|
||||
|
||||
private fun openThread(chatMessage: ChatMessage) {
|
||||
commonMessageInterface.openThread(chatMessage)
|
||||
}
|
||||
|
||||
override fun getPayloadForImageLoader(message: ChatMessage?): Any? {
|
||||
if (message!!.selectedIndividualHashMap!!.containsKey(KEY_CONTACT_NAME)) {
|
||||
previewContainer.visibility = View.GONE
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import android.text.SpannableString
|
|||
import android.text.TextPaint
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
|
|
@ -76,6 +77,10 @@ class SystemMessageViewHolder(itemView: View) :
|
|||
R.drawable.shape_grouped_incoming_message
|
||||
)
|
||||
ViewCompat.setBackground(background, bubbleDrawable)
|
||||
binding.messageText.setTextSize(
|
||||
TypedValue.COMPLEX_UNIT_PX,
|
||||
resources.getDimension(R.dimen.chat_system_message_text_size)
|
||||
)
|
||||
var messageString: Spannable = SpannableString(message.text)
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
|
|
@ -89,7 +94,13 @@ class SystemMessageViewHolder(itemView: View) :
|
|||
} else {
|
||||
individualMap["name"]
|
||||
}
|
||||
messageString = DisplayUtils.searchAndColor(messageString, searchText!!, mentionColor)
|
||||
messageString =
|
||||
DisplayUtils.searchAndColor(
|
||||
messageString,
|
||||
searchText!!,
|
||||
mentionColor,
|
||||
resources.getDimensionPixelSize(R.dimen.chat_system_message_text_size)
|
||||
)
|
||||
if (individualMap["link"] != null) {
|
||||
val displayName = individualMap["name"] ?: ""
|
||||
val link = (user.baseUrl + individualMap["link"])
|
||||
|
|
|
|||
|
|
@ -39,19 +39,19 @@ public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAda
|
|||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingTextMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(holderInstance, chatActivity.getCurrentConversation());
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof IncomingLocationMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingLocationMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(holderInstance, chatActivity.getCurrentConversation());
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof IncomingLinkPreviewMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingLinkPreviewMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(holderInstance, chatActivity.getCurrentConversation());
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof IncomingVoiceMessageViewHolder holderInstance) {
|
||||
holderInstance.assignVoiceMessageInterface(chatActivity);
|
||||
|
|
@ -59,7 +59,7 @@ public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAda
|
|||
} else if (holder instanceof OutcomingVoiceMessageViewHolder holderInstance) {
|
||||
holderInstance.assignVoiceMessageInterface(chatActivity);
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(holderInstance, chatActivity.getCurrentConversation());
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof PreviewMessageViewHolder holderInstance) {
|
||||
holderInstance.assignPreviewMessageInterface(chatActivity);
|
||||
|
|
@ -72,7 +72,13 @@ public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAda
|
|||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingDeckCardViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(holderInstance, chatActivity.getCurrentConversation());
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
|
||||
} else if (holder instanceof IncomingPollMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
} else if (holder instanceof OutcomingPollMessageViewHolder holderInstance) {
|
||||
holderInstance.assignCommonMessageInterface(chatActivity);
|
||||
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
|
||||
}
|
||||
|
||||
super.onBindViewHolder(holder, position);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import android.view.View
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.databinding.ItemThreadTitleBinding
|
||||
|
||||
class Thread {
|
||||
|
||||
fun showThreadPreview(
|
||||
chatActivity: ChatActivity,
|
||||
message: ChatMessage,
|
||||
threadBinding: ItemThreadTitleBinding,
|
||||
reactionsBinding: ReactionsInsideMessageBinding,
|
||||
openThread: (message: ChatMessage) -> Unit
|
||||
) {
|
||||
val isFirstMessageOfThreadInNormalChat = chatActivity.conversationThreadId == null && message.isThread
|
||||
if (isFirstMessageOfThreadInNormalChat) {
|
||||
threadBinding.threadTitleLayout.visibility = View.VISIBLE
|
||||
|
||||
threadBinding.threadTitleLayout.findViewById<androidx.emoji2.widget.EmojiTextView>(R.id.threadTitle).text =
|
||||
message.threadTitle
|
||||
|
||||
reactionsBinding.threadButton.visibility = View.VISIBLE
|
||||
|
||||
reactionsBinding.threadButton.setContent {
|
||||
ThreadButtonComposable(
|
||||
message.threadReplies ?: 0,
|
||||
onButtonClick = { openThread(message) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
threadBinding.threadTitleLayout.visibility = View.GONE
|
||||
reactionsBinding.threadButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.adapters.messages
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nextcloud.talk.R
|
||||
|
||||
@Composable
|
||||
fun ThreadButtonComposable(replyAmount: Int = 0, onButtonClick: () -> Unit = {}) {
|
||||
val replyAmountText = if (replyAmount == 0) {
|
||||
stringResource(R.string.thread_reply)
|
||||
} else {
|
||||
pluralStringResource(
|
||||
R.plurals.thread_replies,
|
||||
replyAmount,
|
||||
replyAmount
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onButtonClick,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.height(24.dp),
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
border = BorderStroke(1.dp, colorResource(R.color.grey_600)),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = colorResource(R.color.grey_600)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_reply),
|
||||
contentDescription = stringResource(R.string.open_thread),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.padding(start = 5.dp, end = 2.dp),
|
||||
tint = colorResource(R.color.grey_600)
|
||||
)
|
||||
Text(
|
||||
text = replyAmountText,
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ThreadButtonPreviewMultipleReplies() {
|
||||
ThreadButtonComposable(2)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ThreadButtonPreviewOneReply() {
|
||||
ThreadButtonComposable(1)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ThreadButtonPreviewZeroReplies() {
|
||||
ThreadButtonComposable(0)
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import com.nextcloud.talk.models.json.participants.TalkBan
|
|||
import com.nextcloud.talk.models.json.participants.TalkBanOverall
|
||||
import com.nextcloud.talk.models.json.profile.ProfileOverall
|
||||
import com.nextcloud.talk.models.json.testNotification.TestNotificationOverall
|
||||
import com.nextcloud.talk.models.json.threads.ThreadOverall
|
||||
import com.nextcloud.talk.models.json.threads.ThreadsOverall
|
||||
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
|
|
@ -146,7 +148,8 @@ interface NcApiCoroutines {
|
|||
@Field("actorDisplayName") actorDisplayName: String,
|
||||
@Field("replyTo") replyTo: Int,
|
||||
@Field("silent") sendWithoutNotification: Boolean,
|
||||
@Field("referenceId") referenceId: String
|
||||
@Field("referenceId") referenceId: String,
|
||||
@Field("threadTitle") threadTitle: String?
|
||||
): ChatOverallSingleMessage
|
||||
|
||||
@FormUrlEncoded
|
||||
|
|
@ -274,7 +277,8 @@ interface NcApiCoroutines {
|
|||
suspend fun getContextOfChatMessage(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Query("limit") limit: Int
|
||||
@Query("limit") limit: Int,
|
||||
@Query("threadId") threadId: Int?
|
||||
): ChatOverall
|
||||
|
||||
@GET
|
||||
|
|
@ -285,4 +289,22 @@ interface NcApiCoroutines {
|
|||
|
||||
@DELETE
|
||||
suspend fun unbindRoom(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
|
||||
|
||||
@GET
|
||||
suspend fun getThreads(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Query("limit") limit: Int?
|
||||
): ThreadsOverall
|
||||
|
||||
@GET
|
||||
suspend fun getThread(@Header("Authorization") authorization: String, @Url url: String): ThreadOverall
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST
|
||||
suspend fun setThreadNotificationLevel(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Field("level") level: Int
|
||||
): ThreadOverall
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ fun ParticipantTile(
|
|||
modifier: Modifier = Modifier,
|
||||
isVoiceOnlyCall: Boolean
|
||||
) {
|
||||
val colorInt = ColorGenerator.shared.usernameToColor(participantUiState.nick)
|
||||
val colorInt = ColorGenerator.usernameToColor(participantUiState.nick)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
|
|
|
|||
|
|
@ -24,12 +24,14 @@ import android.content.pm.PackageManager
|
|||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.location.LocationManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
|
|
@ -38,6 +40,7 @@ import android.view.Menu
|
|||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.AbsListView
|
||||
import android.widget.FrameLayout
|
||||
|
|
@ -56,7 +59,13 @@ import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
|
||||
|
|
@ -126,6 +135,8 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
|
|||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
|
||||
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
|
||||
import com.nextcloud.talk.contextchat.ContextChatView
|
||||
import com.nextcloud.talk.contextchat.ContextChatViewModel
|
||||
import com.nextcloud.talk.conversationinfo.ConversationInfoActivity
|
||||
import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel
|
||||
import com.nextcloud.talk.conversationlist.ConversationsListActivity
|
||||
|
|
@ -148,17 +159,18 @@ import com.nextcloud.talk.models.json.chat.ReadStatus
|
|||
import com.nextcloud.talk.models.json.conversations.ConversationEnums
|
||||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall
|
||||
import com.nextcloud.talk.models.json.threads.ThreadInfo
|
||||
import com.nextcloud.talk.polls.ui.PollCreateDialogFragment
|
||||
import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
|
||||
import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
|
||||
import com.nextcloud.talk.signaling.SignalingMessageReceiver
|
||||
import com.nextcloud.talk.signaling.SignalingMessageSender
|
||||
import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity
|
||||
import com.nextcloud.talk.translate.ui.TranslateActivity
|
||||
import com.nextcloud.talk.ui.PlaybackSpeed
|
||||
import com.nextcloud.talk.ui.PlaybackSpeedControl
|
||||
import com.nextcloud.talk.ui.StatusDrawable
|
||||
import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet
|
||||
import com.nextcloud.talk.ui.dialog.ContextChatCompose
|
||||
import com.nextcloud.talk.ui.dialog.DateTimeCompose
|
||||
import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment
|
||||
import com.nextcloud.talk.ui.dialog.MessageActionsDialog
|
||||
|
|
@ -193,10 +205,12 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_PATHS
|
|||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_BREAKOUT_ROOM
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_MODERATOR
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_OPENED_VIA_NOTIFICATION
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_RECORDING_STATE
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_START_CALL_AFTER_ROOM_SWITCH
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID
|
||||
import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
|
||||
import com.nextcloud.talk.utils.rx.DisposableSet
|
||||
import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
|
||||
|
|
@ -275,10 +289,14 @@ class ChatActivity :
|
|||
lateinit var chatViewModel: ChatViewModel
|
||||
|
||||
lateinit var conversationInfoViewModel: ConversationInfoViewModel
|
||||
lateinit var contextChatViewModel: ContextChatViewModel
|
||||
lateinit var messageInputViewModel: MessageInputViewModel
|
||||
|
||||
private var chatMenu: Menu? = null
|
||||
|
||||
private var overflowMenuHostView: ComposeView? = null
|
||||
private var isThreadMenuExpanded by mutableStateOf(false)
|
||||
|
||||
private val startSelectContactForResult = registerForActivityResult(
|
||||
ActivityResultContracts
|
||||
.StartActivityForResult()
|
||||
|
|
@ -308,28 +326,27 @@ class ChatActivity :
|
|||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
executeIfResultOk(it) { intent ->
|
||||
runBlocking {
|
||||
val id = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
|
||||
id?.let {
|
||||
startContextChatWindowForMessage(id)
|
||||
val messageId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
|
||||
val threadId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_THREAD_ID)
|
||||
messageId?.let {
|
||||
startContextChatWindowForMessage(messageId, threadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startContextChatWindowForMessage(id: String?) {
|
||||
private fun startContextChatWindowForMessage(messageId: String?, threadId: String?) {
|
||||
binding.genericComposeView.apply {
|
||||
val shouldDismiss = mutableStateOf(false)
|
||||
setContent {
|
||||
val bundle = bundleOf()
|
||||
bundle.putString(BundleKeys.KEY_CREDENTIALS, credentials!!)
|
||||
bundle.putString(BundleKeys.KEY_BASE_URL, conversationUser!!.baseUrl)
|
||||
bundle.putString(KEY_ROOM_TOKEN, roomToken)
|
||||
bundle.putString(BundleKeys.KEY_MESSAGE_ID, id)
|
||||
bundle.putString(
|
||||
KEY_CONVERSATION_NAME,
|
||||
currentConversation!!.displayName
|
||||
contextChatViewModel.getContextForChatMessages(
|
||||
credentials = credentials!!,
|
||||
baseUrl = conversationUser!!.baseUrl!!,
|
||||
token = roomToken,
|
||||
threadId = threadId,
|
||||
messageId = messageId!!,
|
||||
title = currentConversation!!.displayName
|
||||
)
|
||||
ContextChatCompose(bundle).GetDialogView(shouldDismiss, context)
|
||||
ContextChatView(context, contextChatViewModel)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Should open something else")
|
||||
|
|
@ -350,6 +367,9 @@ class ChatActivity :
|
|||
|
||||
var sessionIdAfterRoomJoined: String? = null
|
||||
lateinit var roomToken: String
|
||||
var conversationThreadId: Long? = null
|
||||
var openedViaNotification: Boolean = false
|
||||
var conversationThreadInfo: ThreadInfo? = null
|
||||
var conversationUser: User? = null
|
||||
lateinit var spreedCapabilities: SpreedCapability
|
||||
var chatApiVersion: Int = 1
|
||||
|
|
@ -391,8 +411,13 @@ class ChatActivity :
|
|||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java)
|
||||
startActivity(intent)
|
||||
if (!openedViaNotification && isChatThread()) {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
} else {
|
||||
val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -461,18 +486,20 @@ class ChatActivity :
|
|||
setContentView(binding.root)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.chat_container)) { view, insets ->
|
||||
val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.chatContainer) { view, insets ->
|
||||
val systemBarInsets = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or
|
||||
WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
|
||||
val isKeyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
val bottomPadding = if (isKeyboardVisible) imeInsets.bottom else navBarInsets.bottom
|
||||
val bottomPadding = if (isKeyboardVisible) imeInsets.bottom else systemBarInsets.bottom
|
||||
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
statusBarInsets.top,
|
||||
view.paddingRight,
|
||||
systemBarInsets.left,
|
||||
systemBarInsets.top,
|
||||
systemBarInsets.right,
|
||||
bottomPadding
|
||||
)
|
||||
WindowInsetsCompat.CONSUMED
|
||||
|
|
@ -489,14 +516,27 @@ class ChatActivity :
|
|||
|
||||
conversationInfoViewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java]
|
||||
|
||||
contextChatViewModel = ViewModelProvider(this, viewModelFactory)[ContextChatViewModel::class.java]
|
||||
|
||||
val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken)
|
||||
val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token)
|
||||
chatViewModel.initData(
|
||||
credentials!!,
|
||||
urlForChatting,
|
||||
roomToken
|
||||
roomToken,
|
||||
conversationThreadId
|
||||
)
|
||||
|
||||
conversationThreadId?.let {
|
||||
val threadUrl = ApiUtils.getUrlForThread(
|
||||
version = 1,
|
||||
baseUrl = conversationUser!!.baseUrl,
|
||||
token = roomToken,
|
||||
threadId = it.toInt()
|
||||
)
|
||||
chatViewModel.getThread(credentials, threadUrl)
|
||||
}
|
||||
|
||||
messageInputFragment = getMessageInputFragment()
|
||||
messageInputViewModel = ViewModelProvider(this, viewModelFactory)[MessageInputViewModel::class.java]
|
||||
messageInputViewModel.setData(chatViewModel.getChatRepository())
|
||||
|
|
@ -521,6 +561,7 @@ class ChatActivity :
|
|||
return MessageInputFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(CONVERSATION_INTERNAL_ID, internalId)
|
||||
putString(BundleKeys.KEY_SHARED_TEXT, sharedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -549,6 +590,14 @@ class ChatActivity :
|
|||
|
||||
roomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty()
|
||||
|
||||
conversationThreadId = if (extras?.containsKey(KEY_THREAD_ID) == true) {
|
||||
extras.getLong(KEY_THREAD_ID)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
openedViaNotification = extras?.getBoolean(KEY_OPENED_VIA_NOTIFICATION) ?: false
|
||||
|
||||
sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty()
|
||||
|
||||
Log.d(TAG, " roomToken = $roomToken")
|
||||
|
|
@ -671,7 +720,8 @@ class ChatActivity :
|
|||
joinRoomWithPassword()
|
||||
|
||||
if (conversationUser?.userId != "?" &&
|
||||
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG)
|
||||
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG) &&
|
||||
!isChatThread()
|
||||
) {
|
||||
binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() }
|
||||
}
|
||||
|
|
@ -937,6 +987,7 @@ class ChatActivity :
|
|||
var chatMessageList = triple.third
|
||||
|
||||
chatMessageList = handleSystemMessages(chatMessageList)
|
||||
chatMessageList = handleThreadMessages(chatMessageList)
|
||||
if (chatMessageList.isEmpty()) {
|
||||
return@onEach
|
||||
}
|
||||
|
|
@ -1107,6 +1158,11 @@ class ChatActivity :
|
|||
|
||||
chatViewModel.getVoiceRecordingInProgress.observe(this) { voiceRecordingInProgress ->
|
||||
VibrationUtils.vibrateShort(context)
|
||||
if (voiceRecordingInProgress) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
binding.voiceRecordingLock.visibility = if (
|
||||
voiceRecordingInProgress &&
|
||||
chatViewModel.getVoiceRecordingLocked.value != true
|
||||
|
|
@ -1133,6 +1189,7 @@ class ChatActivity :
|
|||
|
||||
chatMenu?.removeItem(R.id.conversation_event)
|
||||
}
|
||||
|
||||
is ChatViewModel.UnbindRoomUiState.Error -> {
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
|
|
@ -1140,7 +1197,8 @@ class ChatActivity :
|
|||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
else -> { }
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1234,6 +1292,25 @@ class ChatActivity :
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.lifecycleScope.launch {
|
||||
chatViewModel.threadRetrieveState.collect { uiState ->
|
||||
when (uiState) {
|
||||
ChatViewModel.ThreadRetrieveUiState.None -> {
|
||||
}
|
||||
|
||||
is ChatViewModel.ThreadRetrieveUiState.Error -> {
|
||||
Log.e(TAG, "Error when retrieving thread", uiState.exception)
|
||||
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
is ChatViewModel.ThreadRetrieveUiState.Success -> {
|
||||
conversationThreadInfo = uiState.thread
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeUnreadMessagesMarker() {
|
||||
|
|
@ -1503,6 +1580,7 @@ class ChatActivity :
|
|||
} while (true && pos >= 0)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun initMessageHolders(): MessageHolders {
|
||||
val messageHolders = MessageHolders()
|
||||
val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!, viewThemeUtils)
|
||||
|
|
@ -2271,11 +2349,26 @@ class ChatActivity :
|
|||
BuildConfig.APPLICATION_ID,
|
||||
File(file.absolutePath)
|
||||
)
|
||||
uploadFile(shareUri.toString(), false)
|
||||
uploadFile(
|
||||
fileUri = shareUri.toString(),
|
||||
isVoiceMessage = false,
|
||||
caption = "",
|
||||
roomToken = roomToken,
|
||||
replyToMessageId = getReplyToMessageId(),
|
||||
displayName = currentConversation?.displayName ?: ""
|
||||
)
|
||||
}
|
||||
cursor?.close()
|
||||
}
|
||||
|
||||
fun getReplyToMessageId(): Int {
|
||||
var replyMessageId = messageInputViewModel.getReplyChatMessage.value?.id?.toInt()
|
||||
if (replyMessageId == null || replyMessageId == 0) {
|
||||
replyMessageId = conversationThreadInfo?.thread?.id ?: 0
|
||||
}
|
||||
return replyMessageId
|
||||
}
|
||||
|
||||
@Throws(IllegalStateException::class)
|
||||
private fun onPickCameraResult(intent: Intent?) {
|
||||
try {
|
||||
|
|
@ -2447,35 +2540,27 @@ class ChatActivity :
|
|||
private fun uploadFiles(files: MutableList<String>, caption: String = "") {
|
||||
for (i in 0 until files.size) {
|
||||
if (i == files.size - 1) {
|
||||
uploadFile(files[i], false, caption)
|
||||
uploadFile(
|
||||
fileUri = files[i],
|
||||
isVoiceMessage = false,
|
||||
caption = caption,
|
||||
roomToken = roomToken,
|
||||
replyToMessageId = getReplyToMessageId(),
|
||||
displayName = currentConversation?.displayName!!
|
||||
)
|
||||
} else {
|
||||
uploadFile(files[i], false)
|
||||
uploadFile(
|
||||
fileUri = files[i],
|
||||
isVoiceMessage = false,
|
||||
caption = "",
|
||||
roomToken = roomToken,
|
||||
replyToMessageId = getReplyToMessageId(),
|
||||
displayName = currentConversation?.displayName!!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadFile(fileUri: String, isVoiceMessage: Boolean, caption: String = "", token: String = "") {
|
||||
var metaData = ""
|
||||
var room = ""
|
||||
|
||||
if (!participantPermissions.hasChatPermission()) {
|
||||
Log.w(TAG, "uploading file(s) is forbidden because of missing attendee permissions")
|
||||
return
|
||||
}
|
||||
|
||||
if (isVoiceMessage) {
|
||||
metaData = VOICE_MESSAGE_META_DATA
|
||||
}
|
||||
|
||||
if (caption != "") {
|
||||
metaData = "{\"caption\":\"$caption\"}"
|
||||
}
|
||||
|
||||
if (token == "") room = roomToken else room = token
|
||||
|
||||
chatViewModel.uploadFile(fileUri, room, currentConversation?.displayName!!, metaData)
|
||||
}
|
||||
|
||||
fun showGalleryPicker() {
|
||||
pickMultipleMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
|
||||
}
|
||||
|
|
@ -2516,10 +2601,67 @@ class ChatActivity :
|
|||
fun showShareLocationScreen() {
|
||||
Log.d(TAG, "showShareLocationScreen")
|
||||
|
||||
val intent = Intent(this, LocationPickerActivity::class.java)
|
||||
intent.putExtra(KEY_ROOM_TOKEN, roomToken)
|
||||
intent.putExtra(BundleKeys.KEY_CHAT_API_VERSION, chatApiVersion)
|
||||
startActivity(intent)
|
||||
val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
|
||||
val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
|
||||
|
||||
if (!isGpsEnabled) {
|
||||
showLocationServicesDisabledDialog()
|
||||
} else if (!permissionUtil.isLocationPermissionGranted()) {
|
||||
showLocationPermissionDeniedDialog()
|
||||
}
|
||||
|
||||
if (permissionUtil.isLocationPermissionGranted() && isGpsEnabled) {
|
||||
val intent = Intent(this, LocationPickerActivity::class.java)
|
||||
intent.putExtra(KEY_ROOM_TOKEN, roomToken)
|
||||
intent.putExtra(BundleKeys.KEY_CHAT_API_VERSION, chatApiVersion)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLocationServicesDisabledDialog() {
|
||||
val title = resources.getString(R.string.location_services_disabled)
|
||||
val explanation = resources.getString(R.string.location_services_disabled_msg)
|
||||
val positive = resources.getString(R.string.nc_permissions_settings)
|
||||
val cancel = resources.getString(R.string.nc_cancel)
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(explanation)
|
||||
.setPositiveButton(positive) { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
|
||||
startActivity(intent)
|
||||
}
|
||||
.setNegativeButton(cancel, null)
|
||||
|
||||
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder)
|
||||
val dialog = dialogBuilder.show()
|
||||
viewThemeUtils.platform.colorTextButtons(
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE),
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showLocationPermissionDeniedDialog() {
|
||||
val title = resources.getString(R.string.location_permission_denied)
|
||||
val explanation = resources.getString(R.string.location_permission_denied_msg)
|
||||
val positive = resources.getString(R.string.nc_permissions_settings)
|
||||
val cancel = resources.getString(R.string.nc_cancel)
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(explanation)
|
||||
.setPositiveButton(positive) { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
.setNegativeButton(cancel, null)
|
||||
|
||||
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder)
|
||||
val dialog = dialogBuilder.show()
|
||||
viewThemeUtils.platform.colorTextButtons(
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE),
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showConversationInfoScreen() {
|
||||
|
|
@ -2587,6 +2729,7 @@ class ChatActivity :
|
|||
if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) {
|
||||
mentionAutocomplete?.dismissPopup()
|
||||
}
|
||||
adapter = null
|
||||
}
|
||||
|
||||
private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations
|
||||
|
|
@ -2600,7 +2743,9 @@ class ChatActivity :
|
|||
viewThemeUtils.platform.colorTextView(title, ColorRole.ON_SURFACE)
|
||||
|
||||
title.text =
|
||||
if (currentConversation?.displayName != null) {
|
||||
if (isChatThread()) {
|
||||
conversationThreadInfo?.thread?.title
|
||||
} else if (currentConversation?.displayName != null) {
|
||||
try {
|
||||
EmojiCompat.get().process(currentConversation?.displayName as CharSequence).toString()
|
||||
} catch (e: java.lang.IllegalStateException) {
|
||||
|
|
@ -2611,7 +2756,16 @@ class ChatActivity :
|
|||
""
|
||||
}
|
||||
|
||||
if (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
|
||||
if (isChatThread()) {
|
||||
val replyAmount = conversationThreadInfo?.thread?.numReplies ?: 0
|
||||
val repliesAmountTitle = resources.getQuantityString(
|
||||
R.plurals.thread_replies,
|
||||
replyAmount,
|
||||
replyAmount
|
||||
)
|
||||
|
||||
statusMessageViewContents(repliesAmountTitle)
|
||||
} else if (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
|
||||
var statusMessage = ""
|
||||
if (currentConversation?.statusIcon != null) {
|
||||
statusMessage += currentConversation?.statusIcon
|
||||
|
|
@ -3101,11 +3255,21 @@ class ChatActivity :
|
|||
}
|
||||
|
||||
val searchItem = menu.findItem(R.id.conversation_search)
|
||||
|
||||
searchItem.isVisible = CapabilitiesUtil.isUnifiedSearchAvailable(spreedCapabilities) &&
|
||||
currentConversation!!.remoteServer.isNullOrEmpty()
|
||||
currentConversation!!.remoteServer.isNullOrEmpty() &&
|
||||
!isChatThread()
|
||||
|
||||
if (CapabilitiesUtil.isAbleToCall(spreedCapabilities)) {
|
||||
val sharedItemsItem = menu.findItem(R.id.shared_items)
|
||||
sharedItemsItem.isVisible = !isChatThread()
|
||||
|
||||
val conversationInfoItem = menu.findItem(R.id.conversation_info)
|
||||
conversationInfoItem.isVisible = !isChatThread()
|
||||
|
||||
val showThreadsItem = menu.findItem(R.id.show_threads)
|
||||
showThreadsItem.isVisible = !isChatThread() &&
|
||||
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS)
|
||||
|
||||
if (CapabilitiesUtil.isAbleToCall(spreedCapabilities) && !isChatThread()) {
|
||||
conversationVoiceCallMenuItem = menu.findItem(R.id.conversation_voice_call)
|
||||
conversationVideoMenuItem = menu.findItem(R.id.conversation_video_call)
|
||||
|
||||
|
|
@ -3136,10 +3300,24 @@ class ChatActivity :
|
|||
menu.removeItem(R.id.conversation_video_call)
|
||||
menu.removeItem(R.id.conversation_voice_call)
|
||||
}
|
||||
|
||||
handleThreadNotificationIcon(menu.findItem(R.id.thread_notifications))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleThreadNotificationIcon(threadNotificationItem: MenuItem) {
|
||||
threadNotificationItem.isVisible = isChatThread() &&
|
||||
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS)
|
||||
|
||||
val threadNotificationIcon = when (conversationThreadInfo?.attendee?.notificationLevel) {
|
||||
1 -> R.drawable.outline_notifications_active_24
|
||||
3 -> R.drawable.ic_baseline_notifications_off_24
|
||||
else -> R.drawable.baseline_notifications_24
|
||||
}
|
||||
threadNotificationItem.icon = ContextCompat.getDrawable(context, threadNotificationIcon)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.conversation_video_call -> {
|
||||
|
|
@ -3169,15 +3347,105 @@ class ChatActivity :
|
|||
|
||||
R.id.conversation_event -> {
|
||||
val anchorView = findViewById<View>(R.id.conversation_event)
|
||||
showPopupWindow(anchorView)
|
||||
showConversationEventMenu(anchorView)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.show_threads -> {
|
||||
openThreadsOverview()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.thread_notifications -> {
|
||||
showThreadNotificationMenu()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.LongMethod")
|
||||
private fun showThreadNotificationMenu() {
|
||||
fun setThreadNotificationLevel(level: Int) {
|
||||
val threadNotificationUrl = ApiUtils.getUrlForThreadNotificationLevel(
|
||||
version = 1,
|
||||
baseUrl = conversationUser!!.baseUrl,
|
||||
token = roomToken,
|
||||
threadId = conversationThreadId!!.toInt()
|
||||
)
|
||||
chatViewModel.setThreadNotificationLevel(credentials!!, threadNotificationUrl, level)
|
||||
}
|
||||
|
||||
if (overflowMenuHostView == null) {
|
||||
val threadNotificationsAnchor: View? = findViewById(R.id.thread_notifications)
|
||||
|
||||
val colorScheme = viewThemeUtils.getColorScheme(this)
|
||||
|
||||
overflowMenuHostView = ComposeView(this).apply {
|
||||
setContent {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme
|
||||
) {
|
||||
val items = listOf(
|
||||
MenuItemData(
|
||||
title = context.resources.getString(R.string.notifications_default),
|
||||
subtitle = context.resources.getString(
|
||||
R.string.notifications_default_description
|
||||
),
|
||||
icon = R.drawable.baseline_notifications_24,
|
||||
onClick = {
|
||||
setThreadNotificationLevel(0)
|
||||
}
|
||||
),
|
||||
MenuItemData(
|
||||
title = context.resources.getString(R.string.notification_all_messages),
|
||||
subtitle = null,
|
||||
icon = R.drawable.outline_notifications_active_24,
|
||||
onClick = {
|
||||
setThreadNotificationLevel(1)
|
||||
}
|
||||
),
|
||||
MenuItemData(
|
||||
title = context.resources.getString(R.string.notification_mention_only),
|
||||
subtitle = null,
|
||||
icon = R.drawable.baseline_notifications_24,
|
||||
onClick = {
|
||||
setThreadNotificationLevel(2)
|
||||
}
|
||||
),
|
||||
MenuItemData(
|
||||
title = context.resources.getString(R.string.notification_off),
|
||||
subtitle = null,
|
||||
icon = R.drawable.ic_baseline_notifications_off_24,
|
||||
onClick = {
|
||||
setThreadNotificationLevel(3)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
OverflowMenu(
|
||||
anchor = threadNotificationsAnchor,
|
||||
expanded = isThreadMenuExpanded,
|
||||
items = items,
|
||||
onDismiss = { isThreadMenuExpanded = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addContentView(
|
||||
overflowMenuHostView,
|
||||
CoordinatorLayout.LayoutParams(
|
||||
CoordinatorLayout.LayoutParams.MATCH_PARENT,
|
||||
CoordinatorLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
}
|
||||
isThreadMenuExpanded = true
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private fun showPopupWindow(anchorView: View) {
|
||||
private fun showConversationEventMenu(anchorView: View) {
|
||||
val popupView = layoutInflater.inflate(R.layout.item_event_schedule, null)
|
||||
|
||||
val subtitleTextView = popupView.findViewById<TextView>(R.id.meetingTime)
|
||||
|
|
@ -3366,7 +3634,7 @@ class ChatActivity :
|
|||
}
|
||||
|
||||
private fun handleSystemMessages(chatMessageList: List<ChatMessage>): List<ChatMessage> {
|
||||
val chatMessageMap = chatMessageList.map { it.id to it }.toMap().toMutableMap()
|
||||
val chatMessageMap = chatMessageList.associateBy { it.id }.toMutableMap()
|
||||
|
||||
val chatMessageIterator = chatMessageMap.iterator()
|
||||
while (chatMessageIterator.hasNext()) {
|
||||
|
|
@ -3375,7 +3643,8 @@ class ChatActivity :
|
|||
if (isInfoMessageAboutDeletion(currentMessage) ||
|
||||
isReactionsMessage(currentMessage) ||
|
||||
isPollVotedMessage(currentMessage) ||
|
||||
isEditMessage(currentMessage)
|
||||
isEditMessage(currentMessage) ||
|
||||
isThreadCreatedMessage(currentMessage)
|
||||
) {
|
||||
chatMessageIterator.remove()
|
||||
}
|
||||
|
|
@ -3384,29 +3653,56 @@ class ChatActivity :
|
|||
}
|
||||
|
||||
private fun handleExpandableSystemMessages(chatMessageList: List<ChatMessage>): List<ChatMessage> {
|
||||
val chatMessageMap = chatMessageList.map { it.id to it }.toMap().toMutableMap()
|
||||
val chatMessageMap = chatMessageList.associateBy { it.id }.toMutableMap()
|
||||
val chatMessageIterator = chatMessageMap.iterator()
|
||||
|
||||
while (chatMessageIterator.hasNext()) {
|
||||
val currentMessage = chatMessageIterator.next()
|
||||
|
||||
val previousMessage = chatMessageMap[currentMessage.value.previousMessageId.toString()]
|
||||
if (isSystemMessage(currentMessage.value) &&
|
||||
previousMessage?.systemMessageType == currentMessage.value.systemMessageType
|
||||
) {
|
||||
previousMessage?.expandableParent = true
|
||||
currentMessage.value.expandableParent = false
|
||||
|
||||
if (currentMessage.value.lastItemOfExpandableGroup == 0) {
|
||||
currentMessage.value.lastItemOfExpandableGroup = currentMessage.value.jsonMessageId
|
||||
chatMessageMap[currentMessage.value.previousMessageId.toString()]?.let { previousMessage ->
|
||||
if (isSystemMessage(currentMessage.value) &&
|
||||
previousMessage.systemMessageType == currentMessage.value.systemMessageType &&
|
||||
isSameDayMessages(previousMessage, currentMessage.value)
|
||||
) {
|
||||
groupSystemMessages(previousMessage, currentMessage.value)
|
||||
}
|
||||
|
||||
previousMessage?.lastItemOfExpandableGroup = currentMessage.value.lastItemOfExpandableGroup
|
||||
previousMessage?.expandableChildrenAmount = currentMessage.value.expandableChildrenAmount + 1
|
||||
}
|
||||
}
|
||||
return chatMessageMap.values.toList()
|
||||
}
|
||||
|
||||
private fun groupSystemMessages(previousMessage: ChatMessage, currentMessage: ChatMessage) {
|
||||
previousMessage.expandableParent = true
|
||||
currentMessage.expandableParent = false
|
||||
|
||||
if (currentMessage.lastItemOfExpandableGroup == 0) {
|
||||
currentMessage.lastItemOfExpandableGroup = currentMessage.jsonMessageId
|
||||
}
|
||||
|
||||
previousMessage.lastItemOfExpandableGroup = currentMessage.lastItemOfExpandableGroup
|
||||
previousMessage.expandableChildrenAmount = currentMessage.expandableChildrenAmount + 1
|
||||
}
|
||||
|
||||
private fun handleThreadMessages(chatMessageList: List<ChatMessage>): List<ChatMessage> {
|
||||
fun isThreadChildMessage(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean =
|
||||
currentMessage.value.isThread &&
|
||||
currentMessage.value.threadId?.toInt() != currentMessage.value.jsonMessageId
|
||||
|
||||
val chatMessageMap = chatMessageList.associateBy { it.id }.toMutableMap()
|
||||
|
||||
if (conversationThreadId == null) {
|
||||
val chatMessageIterator = chatMessageMap.iterator()
|
||||
while (chatMessageIterator.hasNext()) {
|
||||
val currentMessage = chatMessageIterator.next()
|
||||
|
||||
if (isThreadChildMessage(currentMessage)) {
|
||||
chatMessageIterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chatMessageMap.values.toList()
|
||||
}
|
||||
|
||||
private fun isInfoMessageAboutDeletion(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean =
|
||||
currentMessage.value.parentMessageId != null &&
|
||||
currentMessage.value.systemMessageType == ChatMessage
|
||||
|
|
@ -3417,6 +3713,9 @@ class ChatActivity :
|
|||
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_DELETED ||
|
||||
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_REVOKED
|
||||
|
||||
private fun isThreadCreatedMessage(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean =
|
||||
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.THREAD_CREATED
|
||||
|
||||
private fun isEditMessage(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean =
|
||||
currentMessage.value.parentMessageId != null &&
|
||||
currentMessage.value.systemMessageType == ChatMessage
|
||||
|
|
@ -3489,6 +3788,10 @@ class ChatActivity :
|
|||
}
|
||||
}
|
||||
|
||||
override fun openThread(chatMessage: ChatMessage) {
|
||||
openThread(chatMessage.jsonMessageId.toLong())
|
||||
}
|
||||
|
||||
override fun onLongClickReactions(chatMessage: ChatMessage) {
|
||||
ShowReactionsDialog(
|
||||
this,
|
||||
|
|
@ -3761,7 +4064,14 @@ class ChatActivity :
|
|||
val type = message.getCalculateMessageType()
|
||||
when (type) {
|
||||
ChatMessage.MessageType.VOICE_MESSAGE -> {
|
||||
uploadFile(shareUri.toString(), true, token = roomToken)
|
||||
uploadFile(
|
||||
shareUri.toString(),
|
||||
true,
|
||||
roomToken = roomToken,
|
||||
caption = "",
|
||||
replyToMessageId = getReplyToMessageId(),
|
||||
displayName = currentConversation?.displayName ?: ""
|
||||
)
|
||||
showSnackBar(roomToken)
|
||||
}
|
||||
|
||||
|
|
@ -3770,12 +4080,26 @@ class ChatActivity :
|
|||
if (null != shareUri) {
|
||||
try {
|
||||
context.contentResolver.openInputStream(shareUri)?.close()
|
||||
uploadFile(shareUri.toString(), false, caption!!, roomToken)
|
||||
uploadFile(
|
||||
fileUri = shareUri.toString(),
|
||||
isVoiceMessage = false,
|
||||
caption = caption!!,
|
||||
roomToken = roomToken,
|
||||
replyToMessageId = getReplyToMessageId(),
|
||||
displayName = currentConversation?.displayName ?: ""
|
||||
)
|
||||
showSnackBar(roomToken)
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.w(TAG, "File corresponding to the uri does not exist $shareUri")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "File corresponding to the uri does not exist $shareUri", e)
|
||||
downloadFileToCache(message, false) {
|
||||
uploadFile(shareUri.toString(), false, caption!!, roomToken)
|
||||
uploadFile(
|
||||
fileUri = shareUri.toString(),
|
||||
isVoiceMessage = false,
|
||||
caption = caption!!,
|
||||
roomToken = roomToken,
|
||||
replyToMessageId = getReplyToMessageId(),
|
||||
displayName = currentConversation?.displayName ?: ""
|
||||
)
|
||||
showSnackBar(roomToken)
|
||||
}
|
||||
}
|
||||
|
|
@ -4097,6 +4421,10 @@ class ChatActivity :
|
|||
pollVoteDialog.show(supportFragmentManager, TAG)
|
||||
}
|
||||
|
||||
fun createThread() {
|
||||
messageInputViewModel.startThreadCreation()
|
||||
}
|
||||
|
||||
fun jumpToQuotedMessage(parentMessage: ChatMessage) {
|
||||
var foundMessage = false
|
||||
for (position in 0 until (adapter!!.items.size)) {
|
||||
|
|
@ -4109,10 +4437,38 @@ class ChatActivity :
|
|||
}
|
||||
if (!foundMessage) {
|
||||
Log.d(TAG, "quoted message with id " + parentMessage.id + " was not found in adapter")
|
||||
startContextChatWindowForMessage(parentMessage.id)
|
||||
startContextChatWindowForMessage(parentMessage.id, conversationThreadId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun isChatThread(): Boolean = conversationThreadId != null && conversationThreadId!! > 0
|
||||
|
||||
fun openThread(messageId: Long) {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_ROOM_TOKEN, roomToken)
|
||||
bundle.putLong(KEY_THREAD_ID, messageId)
|
||||
val chatIntent = Intent(context, ChatActivity::class.java)
|
||||
chatIntent.putExtras(bundle)
|
||||
startActivity(chatIntent)
|
||||
}
|
||||
|
||||
fun openThreadsOverview() {
|
||||
val threadsUrl = ApiUtils.getUrlForRecentThreads(
|
||||
version = 1,
|
||||
baseUrl = conversationUser!!.baseUrl,
|
||||
token = roomToken
|
||||
)
|
||||
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_ROOM_TOKEN, roomToken)
|
||||
bundle.putString(ThreadsOverviewActivity.KEY_APPBAR_TITLE, getString(R.string.recent_threads))
|
||||
bundle.putString(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl)
|
||||
|
||||
val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java)
|
||||
threadsOverviewIntent.putExtras(bundle)
|
||||
startActivity(threadsOverviewIntent)
|
||||
}
|
||||
|
||||
override fun joinAudioCall() {
|
||||
startACall(true, false)
|
||||
}
|
||||
|
|
@ -4158,6 +4514,37 @@ class ChatActivity :
|
|||
)
|
||||
}
|
||||
|
||||
fun uploadFile(
|
||||
fileUri: String,
|
||||
isVoiceMessage: Boolean,
|
||||
caption: String = "",
|
||||
roomToken: String = "",
|
||||
replyToMessageId: Int? = null,
|
||||
displayName: String
|
||||
) {
|
||||
chatViewModel.uploadFile(
|
||||
fileUri,
|
||||
isVoiceMessage,
|
||||
caption,
|
||||
roomToken,
|
||||
replyToMessageId,
|
||||
displayName
|
||||
)
|
||||
cancelReply()
|
||||
}
|
||||
|
||||
fun cancelReply() {
|
||||
messageInputViewModel.reply(null)
|
||||
chatViewModel.messageDraft.quotedMessageText = null
|
||||
chatViewModel.messageDraft.quotedDisplayName = null
|
||||
chatViewModel.messageDraft.quotedImageUrl = null
|
||||
chatViewModel.messageDraft.quotedJsonId = null
|
||||
}
|
||||
|
||||
fun cancelCreateThread() {
|
||||
chatViewModel.clearThreadTitle()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = ChatActivity::class.simpleName
|
||||
private const val CONTENT_TYPE_CALL_STARTED: Byte = 1
|
||||
|
|
@ -4177,7 +4564,6 @@ class ChatActivity :
|
|||
private const val REQUEST_RECORD_AUDIO_PERMISSION = 222
|
||||
private const val REQUEST_READ_CONTACT_PERMISSION = 234
|
||||
private const val REQUEST_CAMERA_PERMISSION = 223
|
||||
private const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}"
|
||||
private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss"
|
||||
private const val VIDEO_SUFFIX = ".mp4"
|
||||
private const val FULLY_OPAQUE_INT: Int = 255
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import android.widget.LinearLayout
|
|||
import android.widget.PopupMenu
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.SeekBar
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.core.content.ContextCompat
|
||||
|
|
@ -61,6 +60,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA
|
|||
import com.nextcloud.talk.callbacks.MentionAutocompleteCallback
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
|
||||
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
|
||||
import com.nextcloud.talk.data.network.NetworkMonitor
|
||||
import com.nextcloud.talk.databinding.FragmentMessageInputBinding
|
||||
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
|
||||
|
|
@ -75,45 +75,28 @@ import com.nextcloud.talk.users.UserManager
|
|||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.CapabilitiesUtil
|
||||
import com.nextcloud.talk.utils.CharPolicy
|
||||
import com.nextcloud.talk.utils.EmojiTextInputEditText
|
||||
import com.nextcloud.talk.utils.ImageEmojiEditText
|
||||
import com.nextcloud.talk.utils.SpreedFeatures
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import com.nextcloud.talk.utils.text.Spans
|
||||
import com.otaliastudios.autocomplete.Autocomplete
|
||||
import com.stfalcon.chatkit.commons.models.IMessage
|
||||
import com.vanniktech.emoji.EmojiPopup
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Objects
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "LongMethod")
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class MessageInputFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance() = MessageInputFragment()
|
||||
private val TAG: String = MessageInputFragment::class.java.simpleName
|
||||
private const val TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE = 10000L
|
||||
private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L
|
||||
private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
|
||||
private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping"
|
||||
const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}"
|
||||
private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f
|
||||
private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f
|
||||
private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000
|
||||
private const val ANIMATION_DURATION: Long = 750
|
||||
private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -150
|
||||
private const val VOICE_RECORD_LOCK_THRESHOLD: Float = 100f
|
||||
private const val INCREMENT = 8f
|
||||
private const val CURSOR_KEY = "_cursor"
|
||||
private const val CONNECTION_ESTABLISHED_ANIM_DURATION: Long = 3000
|
||||
private const val FULLY_OPAQUE: Float = 1.0f
|
||||
private const val FULLY_TRANSPARENT: Float = 0.0f
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
|
|
@ -144,6 +127,12 @@ class MessageInputFragment : Fragment() {
|
|||
super.onCreate(savedInstanceState)
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
conversationInternalId = arguments?.getString(ChatActivity.CONVERSATION_INTERNAL_ID).orEmpty()
|
||||
chatActivity = requireActivity() as ChatActivity
|
||||
val sharedText = arguments?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty()
|
||||
if (sharedText.isNotEmpty()) {
|
||||
chatActivity.chatViewModel.messageDraft.messageText = sharedText
|
||||
chatActivity.chatViewModel.saveMessageDraft()
|
||||
}
|
||||
if (conversationInternalId.isEmpty()) {
|
||||
Log.e(TAG, "internalId for conversation passed to MessageInputFragment is empty")
|
||||
}
|
||||
|
|
@ -151,45 +140,75 @@ class MessageInputFragment : Fragment() {
|
|||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
binding = FragmentMessageInputBinding.inflate(inflater)
|
||||
chatActivity = requireActivity() as ChatActivity
|
||||
themeMessageInputView()
|
||||
initMessageInputView()
|
||||
initSmileyKeyboardToggler()
|
||||
setupMentionAutocomplete()
|
||||
initVoiceRecordButton()
|
||||
initThreadHandling()
|
||||
restoreState()
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
saveState()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) {
|
||||
mentionAutocomplete?.dismissPopup()
|
||||
}
|
||||
clearEditUI()
|
||||
cancelReply()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
initObservers()
|
||||
|
||||
binding.fragmentCreateThreadView.createThreadView.findViewById<EmojiTextInputEditText>(
|
||||
R.id
|
||||
.createThreadInput
|
||||
).doAfterTextChanged { text ->
|
||||
val threadTitle = text.toString()
|
||||
chatActivity.chatViewModel.messageDraft.threadTitle = threadTitle
|
||||
}
|
||||
}
|
||||
|
||||
private fun initObservers() {
|
||||
Log.d(TAG, "LifeCyclerOwner is: ${viewLifecycleOwner.lifecycle}")
|
||||
chatActivity.messageInputViewModel.getReplyChatMessage.observe(viewLifecycleOwner) { message ->
|
||||
message?.let { replyToMessage(message) }
|
||||
message?.let {
|
||||
chatActivity.chatViewModel.messageDraft.quotedMessageText = message.text
|
||||
chatActivity.chatViewModel.messageDraft.quotedDisplayName = message.actorDisplayName
|
||||
chatActivity.chatViewModel.messageDraft.quotedImageUrl = message.imageUrl
|
||||
chatActivity.chatViewModel.messageDraft.quotedJsonId = message.jsonMessageId
|
||||
replyToMessage(
|
||||
message.text,
|
||||
message.actorDisplayName,
|
||||
message.imageUrl
|
||||
)
|
||||
} ?: clearReplyUi()
|
||||
}
|
||||
|
||||
chatActivity.messageInputViewModel.getEditChatMessage.observe(viewLifecycleOwner) { message ->
|
||||
message?.let { setEditUI(it as ChatMessage) }
|
||||
}
|
||||
|
||||
chatActivity.messageInputViewModel.createThreadViewState.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is MessageInputViewModel.CreateThreadStartState ->
|
||||
binding.fragmentCreateThreadView.createThreadView.visibility = View.GONE
|
||||
|
||||
is MessageInputViewModel.CreateThreadEditState -> {
|
||||
binding.fragmentCreateThreadView.createThreadView.visibility = View.VISIBLE
|
||||
binding.fragmentCreateThreadView.createThreadView
|
||||
.findViewById<EmojiTextInputEditText>(R.id.createThreadInput)?.setText(
|
||||
chatActivity.chatViewModel.messageDraft.threadTitle
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
initVoiceRecordButton()
|
||||
}
|
||||
|
||||
chatActivity.chatViewModel.leaveRoomViewState.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is ChatViewModel.LeaveRoomSuccessState -> sendStopTypingMessage()
|
||||
|
|
@ -299,33 +318,29 @@ class MessageInputFragment : Fragment() {
|
|||
}
|
||||
|
||||
private fun restoreState() {
|
||||
if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) {
|
||||
requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply {
|
||||
val text = getString(chatActivity.roomToken, "")
|
||||
val cursor = getInt(chatActivity.roomToken + CURSOR_KEY, 0)
|
||||
binding.fragmentMessageInputView.messageInput.setText(text)
|
||||
binding.fragmentMessageInputView.messageInput.setSelection(cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
chatActivity.chatViewModel.updateMessageDraft()
|
||||
|
||||
private fun saveState() {
|
||||
val text = binding.fragmentMessageInputView.messageInput.text.toString()
|
||||
val cursor = binding.fragmentMessageInputView.messageInput.selectionStart
|
||||
val previous = requireContext().getSharedPreferences(
|
||||
chatActivity.localClassName,
|
||||
AppCompatActivity
|
||||
.MODE_PRIVATE
|
||||
).getString(chatActivity.roomToken, "null")
|
||||
withContext(Dispatchers.Main) {
|
||||
val draft = chatActivity.chatViewModel.messageDraft
|
||||
binding.fragmentMessageInputView.messageInput.setText(draft.messageText)
|
||||
binding.fragmentMessageInputView.messageInput.setSelection(draft.messageCursor)
|
||||
|
||||
if (text != previous) {
|
||||
requireContext().getSharedPreferences(
|
||||
chatActivity.localClassName,
|
||||
AppCompatActivity.MODE_PRIVATE
|
||||
).edit().apply {
|
||||
putString(chatActivity.roomToken, text)
|
||||
putInt(chatActivity.roomToken + CURSOR_KEY, cursor)
|
||||
apply()
|
||||
if (draft.threadTitle?.isNotEmpty() == true) {
|
||||
chatActivity.messageInputViewModel.startThreadCreation()
|
||||
}
|
||||
|
||||
if (draft.messageText != "") {
|
||||
binding.fragmentMessageInputView.messageInput.requestFocus()
|
||||
}
|
||||
|
||||
if (isInReplyState()) {
|
||||
replyToMessage(
|
||||
chatActivity.chatViewModel.messageDraft.quotedMessageText,
|
||||
chatActivity.chatViewModel.messageDraft.quotedDisplayName,
|
||||
chatActivity.chatViewModel.messageDraft.quotedImageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -388,7 +403,11 @@ class MessageInputFragment : Fragment() {
|
|||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
// unused atm
|
||||
val cursor = binding.fragmentMessageInputView.messageInput.selectionStart
|
||||
val text = binding.fragmentMessageInputView.messageInput.text.toString()
|
||||
chatActivity.chatViewModel.messageDraft.messageCursor = cursor
|
||||
chatActivity.chatViewModel.messageDraft.messageText = text
|
||||
handleButtonsVisibility()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -396,11 +415,14 @@ class MessageInputFragment : Fragment() {
|
|||
// See: https://developer.android.com/guide/topics/text/image-keyboard
|
||||
|
||||
(binding.fragmentMessageInputView.inputEditText as ImageEmojiEditText).onCommitContentListener = {
|
||||
uploadFile(it.toString(), false)
|
||||
}
|
||||
|
||||
if (chatActivity.sharedText.isNotEmpty()) {
|
||||
binding.fragmentMessageInputView.inputEditText?.setText(chatActivity.sharedText)
|
||||
chatActivity.chatViewModel.uploadFile(
|
||||
fileUri = it.toString(),
|
||||
isVoiceMessage = false,
|
||||
caption = "",
|
||||
roomToken = chatActivity.roomToken,
|
||||
replyToMessageId = chatActivity.getReplyToMessageId(),
|
||||
displayName = chatActivity.currentConversation?.displayName!!
|
||||
)
|
||||
}
|
||||
|
||||
binding.fragmentMessageInputView.setAttachmentsListener {
|
||||
|
|
@ -439,6 +461,9 @@ class MessageInputFragment : Fragment() {
|
|||
binding.fragmentEditView.clearEdit.setOnClickListener {
|
||||
clearEditUI()
|
||||
}
|
||||
binding.fragmentCreateThreadView.abortCreateThread.setOnClickListener {
|
||||
cancelCreateThread()
|
||||
}
|
||||
|
||||
if (CapabilitiesUtil.hasSpreedFeatureCapability(chatActivity.spreedCapabilities, SpreedFeatures.SILENT_SEND)) {
|
||||
binding.fragmentMessageInputView.button?.setOnLongClickListener {
|
||||
|
|
@ -466,6 +491,10 @@ class MessageInputFragment : Fragment() {
|
|||
binding.fragmentCallStarted.callStartedSecondaryText.visibility = if (collapsed) View.VISIBLE else View.GONE
|
||||
setDropDown(collapsed)
|
||||
}
|
||||
|
||||
binding.fragmentMessageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.setOnClickListener {
|
||||
cancelReply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setDropDown(collapsed: Boolean) {
|
||||
|
|
@ -480,32 +509,7 @@ class MessageInputFragment : Fragment() {
|
|||
|
||||
@Suppress("ClickableViewAccessibility", "CyclomaticComplexMethod", "LongMethod")
|
||||
private fun initVoiceRecordButton() {
|
||||
if (binding.fragmentMessageInputView.messageInput.text.isNullOrBlank()) {
|
||||
binding.fragmentMessageInputView.messageSendButton.visibility = View.GONE
|
||||
binding.fragmentMessageInputView.recordAudioButton.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.fragmentMessageInputView.messageSendButton.visibility = View.VISIBLE
|
||||
binding.fragmentMessageInputView.recordAudioButton.visibility = View.GONE
|
||||
}
|
||||
binding.fragmentMessageInputView.inputEditText.doAfterTextChanged {
|
||||
binding.fragmentMessageInputView.recordAudioButton.visibility =
|
||||
if (binding.fragmentMessageInputView.inputEditText.text.isEmpty() &&
|
||||
chatActivity.messageInputViewModel.getEditChatMessage.value == null
|
||||
) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
|
||||
binding.fragmentMessageInputView.messageSendButton.visibility =
|
||||
if (binding.fragmentMessageInputView.inputEditText.text.isEmpty() ||
|
||||
binding.fragmentEditView.editMessageView.isVisible
|
||||
) {
|
||||
View.GONE
|
||||
} else {
|
||||
View.VISIBLE
|
||||
}
|
||||
}
|
||||
handleButtonsVisibility()
|
||||
|
||||
var prevDx = 0f
|
||||
var voiceRecordStartTime = 0L
|
||||
|
|
@ -568,9 +572,9 @@ class MessageInputFragment : Fragment() {
|
|||
return@setOnTouchListener false
|
||||
} else {
|
||||
chatActivity.chatViewModel.stopAndSendAudioRecording(
|
||||
chatActivity.roomToken,
|
||||
chatActivity.currentConversation!!.displayName,
|
||||
VOICE_MESSAGE_META_DATA
|
||||
roomToken = chatActivity.roomToken,
|
||||
replyToMessageId = chatActivity.getReplyToMessageId(),
|
||||
displayName = chatActivity.currentConversation!!.displayName
|
||||
)
|
||||
}
|
||||
resetSlider()
|
||||
|
|
@ -615,7 +619,67 @@ class MessageInputFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
v?.onTouchEvent(event) ?: true
|
||||
v?.onTouchEvent(event) != false
|
||||
}
|
||||
}
|
||||
|
||||
private fun initThreadHandling() {
|
||||
binding.fragmentMessageInputView.submitThreadButton.setOnClickListener {
|
||||
submitMessage(false)
|
||||
}
|
||||
|
||||
binding.fragmentCreateThreadView.createThreadInput.doAfterTextChanged {
|
||||
handleButtonsVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleButtonsVisibility() {
|
||||
fun View.setVisible(isVisible: Boolean) {
|
||||
visibility = if (isVisible) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
val isEditModeActive = binding.fragmentEditView.editMessageView.isVisible
|
||||
val isThreadCreateModeActive = binding.fragmentCreateThreadView.createThreadView.isVisible
|
||||
val inputContainsText = binding.fragmentMessageInputView.messageInput.text.isNotEmpty()
|
||||
val threadTitleContainsText = binding.fragmentCreateThreadView.createThreadInput.text?.isNotEmpty() ?: false
|
||||
|
||||
binding.fragmentMessageInputView.apply {
|
||||
when {
|
||||
isEditModeActive -> {
|
||||
messageSendButton.setVisible(false)
|
||||
recordAudioButton.setVisible(false)
|
||||
submitThreadButton.setVisible(false)
|
||||
attachmentButton.setVisible(true)
|
||||
}
|
||||
|
||||
isThreadCreateModeActive -> {
|
||||
messageSendButton.setVisible(false)
|
||||
recordAudioButton.setVisible(false)
|
||||
attachmentButton.setVisible(false)
|
||||
submitThreadButton.setVisible(true)
|
||||
if (inputContainsText && threadTitleContainsText) {
|
||||
submitThreadButton.isEnabled = true
|
||||
submitThreadButton.alpha = FULLY_OPAQUE
|
||||
} else {
|
||||
submitThreadButton.isEnabled = false
|
||||
submitThreadButton.alpha = OPACITY_DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
inputContainsText -> {
|
||||
recordAudioButton.setVisible(false)
|
||||
submitThreadButton.setVisible(false)
|
||||
messageSendButton.setVisible(true)
|
||||
attachmentButton.setVisible(true)
|
||||
}
|
||||
|
||||
else -> {
|
||||
messageSendButton.setVisible(false)
|
||||
submitThreadButton.setVisible(false)
|
||||
recordAudioButton.setVisible(true)
|
||||
attachmentButton.setVisible(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -717,52 +781,45 @@ class MessageInputFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun replyToMessage(message: IMessage?) {
|
||||
private fun replyToMessage(quotedMessageText: String?, quotedActorDisplayName: String?, quotedImageUrl: String?) {
|
||||
Log.d(TAG, "Reply")
|
||||
val chatMessage = message as ChatMessage?
|
||||
chatMessage?.let {
|
||||
val view = binding.fragmentMessageInputView
|
||||
view.findViewById<ImageButton>(R.id.attachmentButton)?.visibility =
|
||||
View.GONE
|
||||
view.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
|
||||
View.VISIBLE
|
||||
val view = binding.fragmentMessageInputView
|
||||
view.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
|
||||
View.VISIBLE
|
||||
|
||||
val quotedMessage = view.findViewById<EmojiTextView>(R.id.quotedMessage)
|
||||
val quotedMessage = view.findViewById<EmojiTextView>(R.id.quotedMessage)
|
||||
|
||||
quotedMessage?.maxLines = 2
|
||||
quotedMessage?.ellipsize = TextUtils.TruncateAt.END
|
||||
quotedMessage?.text = it.text
|
||||
view.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
|
||||
it.actorDisplayName ?: requireContext().getText(R.string.nc_nick_guest)
|
||||
quotedMessage?.maxLines = 2
|
||||
quotedMessage?.ellipsize = TextUtils.TruncateAt.END
|
||||
quotedMessage?.text = quotedMessageText
|
||||
view.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
|
||||
quotedActorDisplayName ?: requireContext().getText(R.string.nc_nick_guest)
|
||||
|
||||
chatActivity.conversationUser?.let {
|
||||
val quotedMessageImage = view.findViewById<ImageView>(R.id.quotedMessageImage)
|
||||
chatMessage.imageUrl?.let { previewImageUrl ->
|
||||
quotedMessageImage?.visibility = View.VISIBLE
|
||||
chatActivity.conversationUser?.let {
|
||||
val quotedMessageImage = view.findViewById<ImageView>(R.id.quotedMessageImage)
|
||||
quotedImageUrl?.let { previewImageUrl ->
|
||||
quotedMessageImage?.visibility = View.VISIBLE
|
||||
|
||||
val px = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
QUOTED_MESSAGE_IMAGE_MAX_HEIGHT,
|
||||
resources.displayMetrics
|
||||
)
|
||||
val px = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
QUOTED_MESSAGE_IMAGE_MAX_HEIGHT,
|
||||
resources.displayMetrics
|
||||
)
|
||||
|
||||
quotedMessageImage?.maxHeight = px.toInt()
|
||||
val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams
|
||||
layoutParams.flexGrow = 0f
|
||||
quotedMessageImage.layoutParams = layoutParams
|
||||
quotedMessageImage.load(previewImageUrl) {
|
||||
addHeader("Authorization", chatActivity.credentials!!)
|
||||
}
|
||||
} ?: run {
|
||||
view.findViewById<ImageView>(R.id.quotedMessageImage)?.visibility = View.GONE
|
||||
quotedMessageImage?.maxHeight = px.toInt()
|
||||
val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams
|
||||
layoutParams.flexGrow = 0f
|
||||
quotedMessageImage.layoutParams = layoutParams
|
||||
quotedMessageImage.load(previewImageUrl) {
|
||||
addHeader("Authorization", chatActivity.credentials!!)
|
||||
}
|
||||
} ?: run {
|
||||
view.findViewById<ImageView>(R.id.quotedMessageImage)?.visibility = View.GONE
|
||||
}
|
||||
|
||||
val quotedChatMessageView =
|
||||
view.findViewById<RelativeLayout>(R.id.quotedChatMessageView)
|
||||
quotedChatMessageView?.tag = message?.jsonMessageId
|
||||
quotedChatMessageView?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
val quotedChatMessageView = view.findViewById<RelativeLayout>(R.id.quotedChatMessageView)
|
||||
quotedChatMessageView?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
fun updateOwnTypingStatus(typedText: CharSequence) {
|
||||
|
|
@ -829,58 +886,34 @@ class MessageInputFragment : Fragment() {
|
|||
private fun isTypingStatusEnabled(): Boolean =
|
||||
!CapabilitiesUtil.isTypingStatusPrivate(chatActivity.conversationUser!!)
|
||||
|
||||
private fun uploadFile(fileUri: String, isVoiceMessage: Boolean, caption: String = "", token: String = "") {
|
||||
var metaData = ""
|
||||
val room: String
|
||||
|
||||
if (!chatActivity.participantPermissions.hasChatPermission()) {
|
||||
Log.w(ChatActivity.TAG, "uploading file(s) is forbidden because of missing attendee permissions")
|
||||
return
|
||||
}
|
||||
|
||||
if (isVoiceMessage) {
|
||||
metaData = VOICE_MESSAGE_META_DATA
|
||||
}
|
||||
|
||||
if (caption != "") {
|
||||
metaData = "{\"caption\":\"$caption\"}"
|
||||
}
|
||||
|
||||
if (token == "") room = chatActivity.roomToken else room = token
|
||||
|
||||
chatActivity.chatViewModel.uploadFile(fileUri, room, chatActivity.currentConversation!!.displayName, metaData)
|
||||
}
|
||||
|
||||
private fun submitMessage(sendWithoutNotification: Boolean) {
|
||||
if (binding.fragmentMessageInputView.inputEditText != null) {
|
||||
val editable = binding.fragmentMessageInputView.inputEditText!!.editableText
|
||||
replaceMentionChipSpans(editable)
|
||||
binding.fragmentMessageInputView.inputEditText?.setText("")
|
||||
sendStopTypingMessage()
|
||||
val replyMessageId = binding.fragmentMessageInputView
|
||||
.findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.tag as Int? ?: 0
|
||||
|
||||
sendMessage(
|
||||
editable.toString(),
|
||||
replyMessageId,
|
||||
sendWithoutNotification
|
||||
)
|
||||
cancelReply()
|
||||
cancelCreateThread()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage(message: String, replyTo: Int?, sendWithoutNotification: Boolean) {
|
||||
private fun sendMessage(message: String, sendWithoutNotification: Boolean) {
|
||||
chatActivity.messageInputViewModel.sendChatMessage(
|
||||
chatActivity.conversationUser!!.getCredentials(),
|
||||
ApiUtils.getUrlForChat(
|
||||
credentials = chatActivity.conversationUser!!.getCredentials(),
|
||||
url = ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
chatActivity.conversationUser!!.baseUrl!!,
|
||||
chatActivity.roomToken
|
||||
),
|
||||
message,
|
||||
chatActivity.conversationUser!!.displayName ?: "",
|
||||
replyTo ?: 0,
|
||||
sendWithoutNotification
|
||||
message = message,
|
||||
displayName = chatActivity.conversationUser!!.displayName ?: "",
|
||||
replyTo = chatActivity.getReplyToMessageId(),
|
||||
sendWithoutNotification = sendWithoutNotification,
|
||||
threadTitle = chatActivity.chatViewModel.messageDraft.threadTitle
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -964,6 +997,7 @@ class MessageInputFragment : Fragment() {
|
|||
binding.fragmentMessageInputView.inputEditText.setSelection(end)
|
||||
binding.fragmentMessageInputView.messageSendButton.visibility = View.GONE
|
||||
binding.fragmentMessageInputView.recordAudioButton.visibility = View.GONE
|
||||
binding.fragmentMessageInputView.submitThreadButton.visibility = View.GONE
|
||||
binding.fragmentMessageInputView.editMessageButton.visibility = View.VISIBLE
|
||||
binding.fragmentEditView.editMessageView.visibility = View.VISIBLE
|
||||
binding.fragmentMessageInputView.attachmentButton.visibility = View.GONE
|
||||
|
|
@ -975,15 +1009,12 @@ class MessageInputFragment : Fragment() {
|
|||
binding.fragmentEditView.editMessageView.visibility = View.GONE
|
||||
binding.fragmentMessageInputView.attachmentButton.visibility = View.VISIBLE
|
||||
chatActivity.messageInputViewModel.edit(null)
|
||||
handleButtonsVisibility()
|
||||
}
|
||||
|
||||
private fun themeMessageInputView() {
|
||||
binding.fragmentMessageInputView.button?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) }
|
||||
|
||||
binding.fragmentMessageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.setOnClickListener {
|
||||
cancelReply()
|
||||
}
|
||||
|
||||
binding.fragmentMessageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.let {
|
||||
viewThemeUtils.platform
|
||||
.themeImageButton(it)
|
||||
|
|
@ -1021,6 +1052,9 @@ class MessageInputFragment : Fragment() {
|
|||
binding.fragmentEditView.clearEdit.let {
|
||||
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
|
||||
}
|
||||
binding.fragmentCreateThreadView.abortCreateThread.let {
|
||||
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
|
||||
}
|
||||
|
||||
binding.fragmentCallStarted.callStartedBackground.apply {
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(this, grouped = true, false)
|
||||
|
|
@ -1037,14 +1071,56 @@ class MessageInputFragment : Fragment() {
|
|||
binding.fragmentCallStarted.callStartedCloseBtn.apply {
|
||||
viewThemeUtils.platform.colorImageView(this, ColorRole.PRIMARY)
|
||||
}
|
||||
|
||||
binding.fragmentMessageInputView.submitThreadButton.apply {
|
||||
viewThemeUtils.platform.colorImageView(this, ColorRole.SECONDARY)
|
||||
}
|
||||
|
||||
binding.fragmentCreateThreadView.createThreadInput.apply {
|
||||
viewThemeUtils.platform.colorEditText(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelCreateThread() {
|
||||
chatActivity.cancelCreateThread()
|
||||
chatActivity.messageInputViewModel.stopThreadCreation()
|
||||
binding.fragmentCreateThreadView.createThreadView.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun cancelReply() {
|
||||
val quote = binding.fragmentMessageInputView
|
||||
.findViewById<RelativeLayout>(R.id.quotedChatMessageView)
|
||||
chatActivity.cancelReply()
|
||||
clearReplyUi()
|
||||
}
|
||||
|
||||
private fun clearReplyUi() {
|
||||
val quote = binding.fragmentMessageInputView.findViewById<RelativeLayout>(R.id.quotedChatMessageView)
|
||||
quote.visibility = View.GONE
|
||||
quote.tag = null
|
||||
binding.fragmentMessageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility = View.VISIBLE
|
||||
chatActivity.messageInputViewModel.reply(null)
|
||||
}
|
||||
|
||||
private fun isInReplyState(): Boolean {
|
||||
val jsonId = chatActivity.chatViewModel.messageDraft.quotedJsonId
|
||||
return jsonId != null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = MessageInputFragment()
|
||||
private val TAG: String = MessageInputFragment::class.java.simpleName
|
||||
private const val TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE = 10000L
|
||||
private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L
|
||||
private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
|
||||
private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping"
|
||||
private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f
|
||||
private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f
|
||||
private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000
|
||||
private const val ANIMATION_DURATION: Long = 750
|
||||
private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -150
|
||||
private const val VOICE_RECORD_LOCK_THRESHOLD: Float = 100f
|
||||
private const val INCREMENT = 8f
|
||||
private const val CURSOR_KEY = "_cursor"
|
||||
private const val CONNECTION_ESTABLISHED_ANIM_DURATION: Long = 3000
|
||||
private const val FULLY_OPAQUE: Float = 1.0f
|
||||
private const val FULLY_TRANSPARENT: Float = 0.0f
|
||||
private const val OPACITY_DISABLED = 0.7f
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,9 +114,9 @@ class MessageInputVoiceRecordingFragment : Fragment() {
|
|||
|
||||
binding.sendVoiceRecording.setOnClickListener {
|
||||
chatActivity.chatViewModel.stopAndSendAudioRecording(
|
||||
chatActivity.roomToken,
|
||||
chatActivity.currentConversation!!.displayName,
|
||||
MessageInputFragment.VOICE_MESSAGE_META_DATA
|
||||
roomToken = chatActivity.roomToken,
|
||||
replyToMessageId = chatActivity.getReplyToMessageId(),
|
||||
displayName = chatActivity.currentConversation!!.displayName
|
||||
)
|
||||
clear()
|
||||
}
|
||||
|
|
|
|||
160
app/src/main/java/com/nextcloud/talk/chat/OverflowMenu.kt
Normal file
160
app/src/main/java/com/nextcloud/talk/chat/OverflowMenu.kt
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.chat
|
||||
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import com.nextcloud.talk.R
|
||||
|
||||
data class MenuItemData(val title: String, val subtitle: String? = null, val icon: Int? = null, val onClick: () -> Unit)
|
||||
|
||||
@Composable
|
||||
fun OverflowMenu(anchor: View?, expanded: Boolean, items: List<MenuItemData>, onDismiss: () -> Unit) {
|
||||
if (!expanded) return
|
||||
|
||||
val rect = anchor?.boundsInWindow()
|
||||
val xOffset = rect?.left ?: 0
|
||||
val yOffset = rect?.bottom ?: 0
|
||||
|
||||
Popup(
|
||||
onDismissRequest = onDismiss,
|
||||
offset = IntOffset(xOffset, yOffset)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.background(
|
||||
color = colorResource(id = R.color.bg_default),
|
||||
shape = RoundedCornerShape(1.dp)
|
||||
)
|
||||
.shadow(
|
||||
elevation = 1.dp,
|
||||
shape = RoundedCornerShape(1.dp),
|
||||
clip = false
|
||||
)
|
||||
) {
|
||||
items.forEach { item ->
|
||||
DynamicMenuItem(
|
||||
item.copy(
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DynamicMenuItem(item: MenuItemData) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = item.onClick,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
) {
|
||||
item.icon?.let { icon ->
|
||||
Icon(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(item.title, color = MaterialTheme.colorScheme.onSurface)
|
||||
item.subtitle?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun View.boundsInWindow(): android.graphics.Rect {
|
||||
val location = IntArray(2)
|
||||
getLocationOnScreen(location)
|
||||
return android.graphics.Rect(
|
||||
location[0],
|
||||
location[1],
|
||||
location[0] + width,
|
||||
location[1] + height
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OverflowMenuPreview() {
|
||||
val items = listOf(
|
||||
MenuItemData(
|
||||
title = "first item title",
|
||||
subtitle = "first item subtitle",
|
||||
icon = R.drawable.baseline_notifications_24,
|
||||
onClick = {}
|
||||
),
|
||||
MenuItemData(
|
||||
title = "second item title",
|
||||
subtitle = null,
|
||||
icon = R.drawable.outline_notifications_active_24,
|
||||
onClick = {}
|
||||
),
|
||||
MenuItemData(
|
||||
title = "third item title",
|
||||
subtitle = null,
|
||||
icon = R.drawable.baseline_notifications_24,
|
||||
onClick = {}
|
||||
),
|
||||
MenuItemData(
|
||||
title = "fourth item title",
|
||||
subtitle = null,
|
||||
icon = R.drawable.baseline_notifications_24,
|
||||
onClick = {}
|
||||
)
|
||||
)
|
||||
|
||||
OverflowMenu(
|
||||
anchor = null,
|
||||
expanded = true,
|
||||
items = items,
|
||||
onDismiss = { }
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ interface ChatMessageRepository : LifecycleAwareManager {
|
|||
|
||||
val removeMessageFlow: Flow<ChatMessage>
|
||||
|
||||
fun initData(credentials: String, urlForChatting: String, roomToken: String)
|
||||
fun initData(credentials: String, urlForChatting: String, roomToken: String, threadId: Long?)
|
||||
|
||||
fun updateConversation(conversationModel: ConversationModel)
|
||||
|
||||
|
|
@ -76,6 +76,8 @@ interface ChatMessageRepository : LifecycleAwareManager {
|
|||
*/
|
||||
suspend fun getMessage(messageId: Long, bundle: Bundle): Flow<ChatMessage>
|
||||
|
||||
suspend fun getNumberOfThreadReplies(threadId: Long): Int
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun sendChatMessage(
|
||||
credentials: String,
|
||||
|
|
@ -84,7 +86,8 @@ interface ChatMessageRepository : LifecycleAwareManager {
|
|||
displayName: String,
|
||||
replyTo: Int,
|
||||
sendWithoutNotification: Boolean,
|
||||
referenceId: String
|
||||
referenceId: String,
|
||||
threadTitle: String?
|
||||
): Flow<Result<ChatMessage?>>
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ data class ChatMessage(
|
|||
|
||||
var token: String? = null,
|
||||
|
||||
var threadId: Long? = null,
|
||||
|
||||
var isThread: Boolean = false,
|
||||
|
||||
var threadTitle: String? = null,
|
||||
|
||||
var threadReplies: Int? = 0,
|
||||
|
||||
// guests or users
|
||||
var actorType: String? = null,
|
||||
|
||||
|
|
@ -424,7 +432,8 @@ data class ChatMessage(
|
|||
AVATAR_REMOVED,
|
||||
FEDERATED_USER_ADDED,
|
||||
FEDERATED_USER_REMOVED,
|
||||
PHONE_ADDED
|
||||
PHONE_ADDED,
|
||||
THREAD_CREATED
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ interface ChatNetworkDataSource {
|
|||
displayName: String,
|
||||
replyTo: Int,
|
||||
sendWithoutNotification: Boolean,
|
||||
referenceId: String
|
||||
referenceId: String,
|
||||
threadTitle: String?
|
||||
): ChatOverallSingleMessage
|
||||
|
||||
fun pullChatMessages(credentials: String, url: String, fieldMap: HashMap<String, Int>): Observable<Response<*>>
|
||||
|
|
@ -73,7 +74,8 @@ interface ChatNetworkDataSource {
|
|||
baseUrl: String,
|
||||
token: String,
|
||||
messageId: String,
|
||||
limit: Int
|
||||
limit: Int,
|
||||
threadId: Int?
|
||||
): List<ChatMessageJson>
|
||||
suspend fun getOpenGraph(credentials: String, baseUrl: String, extractedLinkToPreview: String): Reference?
|
||||
suspend fun unbindRoom(credentials: String, baseUrl: String, roomToken: String): GenericOverall
|
||||
|
|
|
|||
|
|
@ -119,11 +119,13 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
private lateinit var conversationModel: ConversationModel
|
||||
private lateinit var credentials: String
|
||||
private lateinit var urlForChatting: String
|
||||
private var threadId: Long? = null
|
||||
|
||||
override fun initData(credentials: String, urlForChatting: String, roomToken: String) {
|
||||
override fun initData(credentials: String, urlForChatting: String, roomToken: String, threadId: Long?) {
|
||||
internalConversationId = currentUser.id.toString() + "@" + roomToken
|
||||
this.credentials = credentials
|
||||
this.urlForChatting = urlForChatting
|
||||
this.threadId = threadId
|
||||
}
|
||||
|
||||
override fun updateConversation(conversationModel: ConversationModel) {
|
||||
|
|
@ -143,7 +145,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
Log.d(TAG, "conversationModel.internalId: " + conversationModel.internalId)
|
||||
Log.d(TAG, "conversationModel.lastReadMessage:" + conversationModel.lastReadMessage)
|
||||
|
||||
var newestMessageIdFromDb = chatDao.getNewestMessageId(internalConversationId)
|
||||
var newestMessageIdFromDb = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId)
|
||||
Log.d(TAG, "newestMessageIdFromDb: $newestMessageIdFromDb")
|
||||
|
||||
val weAlreadyHaveSomeOfflineMessages = newestMessageIdFromDb > 0
|
||||
|
|
@ -189,7 +191,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
Log.e(TAG, "initial loading of messages failed")
|
||||
}
|
||||
|
||||
newestMessageIdFromDb = chatDao.getNewestMessageId(internalConversationId)
|
||||
newestMessageIdFromDb = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId)
|
||||
Log.d(TAG, "newestMessageIdFromDb after sync: $newestMessageIdFromDb")
|
||||
}
|
||||
|
||||
|
|
@ -203,9 +205,9 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb)
|
||||
|
||||
val list = getMessagesBeforeAndEqual(
|
||||
newestMessageIdFromDb,
|
||||
internalConversationId,
|
||||
limit
|
||||
messageId = newestMessageIdFromDb,
|
||||
internalConversationId = internalConversationId,
|
||||
messageLimit = limit
|
||||
)
|
||||
if (list.isNotEmpty()) {
|
||||
handleNewAndTempMessages(
|
||||
|
|
@ -234,7 +236,8 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
val amountBetween = chatDao.getCountBetweenMessageIds(
|
||||
internalConversationId,
|
||||
messageId,
|
||||
chatBlock.oldestMessageId
|
||||
chatBlock.oldestMessageId,
|
||||
threadId
|
||||
)
|
||||
|
||||
Log.d(TAG, "amount of messages between newestMessageId and oldest message of same ChatBlock:$amountBetween")
|
||||
|
|
@ -284,7 +287,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
)
|
||||
withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap)
|
||||
|
||||
val loadFromServer = hasToLoadPreviousMessagesFromServer(beforeMessageId)
|
||||
val loadFromServer = hasToLoadPreviousMessagesFromServer(beforeMessageId, DEFAULT_MESSAGES_LIMIT)
|
||||
|
||||
if (loadFromServer) {
|
||||
Log.d(TAG, "Starting online request for loadMoreMessages")
|
||||
|
|
@ -346,7 +349,10 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
|
||||
updateUiForLastCommonRead()
|
||||
|
||||
val newestMessage = chatDao.getNewestMessageId(internalConversationId).toInt()
|
||||
val newestMessage = chatBlocksDao.getNewestMessageIdFromChatBlocks(
|
||||
internalConversationId,
|
||||
threadId
|
||||
).toInt()
|
||||
|
||||
// update field map vars for next cycle
|
||||
fieldMap = getFieldMap(
|
||||
|
|
@ -409,7 +415,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
_messageFlow.emit(triple)
|
||||
}
|
||||
|
||||
private suspend fun hasToLoadPreviousMessagesFromServer(beforeMessageId: Long): Boolean {
|
||||
private suspend fun hasToLoadPreviousMessagesFromServer(beforeMessageId: Long, amountToCheck: Int): Boolean {
|
||||
val loadFromServer: Boolean
|
||||
|
||||
val blockForMessage = getBlockOfMessage(beforeMessageId.toInt())
|
||||
|
|
@ -421,26 +427,25 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
Log.d(TAG, "The last chatBlock is reached so we won't request server for older messages")
|
||||
loadFromServer = false
|
||||
} else {
|
||||
// we know that beforeMessageId and blockForMessage.oldestMessageId are in the same block.
|
||||
// As we want the last DEFAULT_MESSAGES_LIMIT entries before beforeMessageId, we calculate if these
|
||||
// messages are DEFAULT_MESSAGES_LIMIT entries apart from each other
|
||||
|
||||
val amountBetween = chatDao.getCountBetweenMessageIds(
|
||||
internalConversationId,
|
||||
beforeMessageId,
|
||||
blockForMessage.oldestMessageId
|
||||
blockForMessage.oldestMessageId,
|
||||
threadId
|
||||
)
|
||||
loadFromServer = amountBetween < DEFAULT_MESSAGES_LIMIT
|
||||
loadFromServer = amountBetween < amountToCheck
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Amount between messageId " + beforeMessageId + " and " + blockForMessage.oldestMessageId +
|
||||
" is: " + amountBetween + " so 'loadFromServer' is " + loadFromServer
|
||||
" is: " + amountBetween + " and $amountToCheck were needed, so 'loadFromServer' is " +
|
||||
loadFromServer
|
||||
)
|
||||
}
|
||||
return loadFromServer
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun getFieldMap(
|
||||
lookIntoFuture: Boolean,
|
||||
timeout: Int,
|
||||
|
|
@ -461,17 +466,23 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
fieldMap["lastCommonReadId"] = it
|
||||
}
|
||||
|
||||
threadId?.let { fieldMap["threadId"] = it.toInt() }
|
||||
|
||||
fieldMap["timeout"] = timeout
|
||||
fieldMap["limit"] = limit
|
||||
|
||||
fieldMap["lookIntoFuture"] = if (lookIntoFuture) 1 else 0
|
||||
fieldMap["setReadMarker"] = if (setReadMarker) 1 else 0
|
||||
|
||||
return fieldMap
|
||||
}
|
||||
|
||||
override suspend fun getNumberOfThreadReplies(threadId: Long): Int =
|
||||
chatDao.getNumberOfThreadReplies(internalConversationId, threadId)
|
||||
|
||||
override suspend fun getMessage(messageId: Long, bundle: Bundle): Flow<ChatMessage> {
|
||||
Log.d(TAG, "Get message with id $messageId")
|
||||
val loadFromServer = hasToLoadPreviousMessagesFromServer(messageId)
|
||||
val loadFromServer = hasToLoadPreviousMessagesFromServer(messageId, 1)
|
||||
|
||||
if (loadFromServer) {
|
||||
val fieldMap = getFieldMap(
|
||||
|
|
@ -487,8 +498,10 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
Log.d(TAG, "Starting online request for single message (e.g. a reply)")
|
||||
sync(bundle)
|
||||
}
|
||||
return chatDao.getChatMessageForConversation(internalConversationId, messageId)
|
||||
.map(ChatMessageEntity::asModel)
|
||||
return chatDao.getChatMessageForConversation(
|
||||
internalConversationId,
|
||||
messageId
|
||||
).map(ChatMessageEntity::asModel)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST", "MagicNumber", "Detekt.TooGenericExceptionCaught")
|
||||
|
|
@ -652,11 +665,12 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
internalConversationId = internalConversationId,
|
||||
accountId = conversationModel.accountId,
|
||||
token = conversationModel.token,
|
||||
threadId = threadId,
|
||||
oldestMessageId = oldestMessageIdForNewChatBlock,
|
||||
newestMessageId = newestMessageIdForNewChatBlock,
|
||||
hasHistory = hasHistory
|
||||
)
|
||||
chatBlocksDao.upsertChatBlock(newChatBlock)
|
||||
chatBlocksDao.upsertChatBlock(newChatBlock) // crash when no conversation thread exists!
|
||||
|
||||
updateBlocks(newChatBlock)
|
||||
return chatMessagesFromSyncToProcess
|
||||
|
|
@ -713,7 +727,11 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
var blockContainingQueriedMessage: ChatBlockEntity? = null
|
||||
if (queriedMessageId != null) {
|
||||
val blocksContainingQueriedMessage =
|
||||
chatBlocksDao.getChatBlocksContainingMessageId(internalConversationId, queriedMessageId.toLong())
|
||||
chatBlocksDao.getChatBlocksContainingMessageId(
|
||||
internalConversationId = internalConversationId,
|
||||
threadId = threadId,
|
||||
messageId = queriedMessageId.toLong()
|
||||
)
|
||||
|
||||
val chatBlocks = blocksContainingQueriedMessage.first()
|
||||
if (chatBlocks.size > 1) {
|
||||
|
|
@ -732,9 +750,10 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
private suspend fun updateBlocks(chatBlock: ChatBlockEntity): ChatBlockEntity? {
|
||||
val connectedChatBlocks =
|
||||
chatBlocksDao.getConnectedChatBlocks(
|
||||
internalConversationId,
|
||||
chatBlock.oldestMessageId,
|
||||
chatBlock.newestMessageId
|
||||
internalConversationId = internalConversationId,
|
||||
threadId = threadId,
|
||||
oldestMessageId = chatBlock.oldestMessageId,
|
||||
newestMessageId = chatBlock.newestMessageId
|
||||
).first()
|
||||
|
||||
return if (connectedChatBlocks.size == 1) {
|
||||
|
|
@ -761,6 +780,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
internalConversationId = internalConversationId,
|
||||
accountId = conversationModel.accountId,
|
||||
token = conversationModel.token,
|
||||
threadId = threadId,
|
||||
oldestMessageId = oldestIdFromDbChatBlocks,
|
||||
newestMessageId = newestIdFromDbChatBlocks,
|
||||
hasHistory = hasHistory
|
||||
|
|
@ -784,7 +804,8 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
chatDao.getMessagesForConversationBeforeAndEqual(
|
||||
internalConversationId,
|
||||
messageId,
|
||||
messageLimit
|
||||
messageLimit,
|
||||
threadId
|
||||
).map {
|
||||
it.map(ChatMessageEntity::asModel)
|
||||
}.first()
|
||||
|
|
@ -798,7 +819,8 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
chatDao.getMessagesForConversationBefore(
|
||||
internalConversationId,
|
||||
messageId,
|
||||
messageLimit
|
||||
messageLimit,
|
||||
threadId
|
||||
).map {
|
||||
it.map(ChatMessageEntity::asModel)
|
||||
}.first()
|
||||
|
|
@ -838,7 +860,8 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
displayName: String,
|
||||
replyTo: Int,
|
||||
sendWithoutNotification: Boolean,
|
||||
referenceId: String
|
||||
referenceId: String,
|
||||
threadTitle: String?
|
||||
): Flow<Result<ChatMessage?>> {
|
||||
if (!networkMonitor.isOnline.value) {
|
||||
return flow {
|
||||
|
|
@ -854,14 +877,16 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
displayName,
|
||||
replyTo,
|
||||
sendWithoutNotification,
|
||||
referenceId
|
||||
referenceId,
|
||||
threadTitle
|
||||
)
|
||||
|
||||
val chatMessageModel = response.ocs?.data?.asModel()
|
||||
|
||||
val sentMessage = chatDao.getTempMessageForConversation(
|
||||
internalConversationId,
|
||||
referenceId
|
||||
referenceId,
|
||||
threadId
|
||||
).firstOrNull()
|
||||
|
||||
sentMessage?.let {
|
||||
|
|
@ -877,7 +902,8 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
|
||||
val failedMessage = chatDao.getTempMessageForConversation(
|
||||
internalConversationId,
|
||||
referenceId
|
||||
referenceId,
|
||||
threadId
|
||||
).firstOrNull()
|
||||
failedMessage?.let {
|
||||
it.sendStatus = SendStatus.FAILED
|
||||
|
|
@ -900,7 +926,11 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
sendWithoutNotification: Boolean,
|
||||
referenceId: String
|
||||
): Flow<Result<ChatMessage?>> {
|
||||
val messageToResend = chatDao.getTempMessageForConversation(internalConversationId, referenceId).firstOrNull()
|
||||
val messageToResend = chatDao.getTempMessageForConversation(
|
||||
internalConversationId,
|
||||
referenceId,
|
||||
threadId
|
||||
).firstOrNull()
|
||||
return if (messageToResend != null) {
|
||||
messageToResend.sendStatus = SendStatus.PENDING
|
||||
chatDao.updateChatMessage(messageToResend)
|
||||
|
|
@ -909,13 +939,14 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
_updateMessageFlow.emit(messageToResendModel)
|
||||
|
||||
sendChatMessage(
|
||||
credentials,
|
||||
url,
|
||||
message,
|
||||
displayName,
|
||||
replyTo,
|
||||
sendWithoutNotification,
|
||||
referenceId
|
||||
credentials = credentials,
|
||||
url = url,
|
||||
message = message,
|
||||
displayName = displayName,
|
||||
replyTo = replyTo,
|
||||
sendWithoutNotification = sendWithoutNotification,
|
||||
referenceId = referenceId,
|
||||
threadTitle = null
|
||||
)
|
||||
} else {
|
||||
flow {
|
||||
|
|
@ -949,8 +980,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
try {
|
||||
val messageToEdit = chatDao.getChatMessageForConversation(
|
||||
internalConversationId,
|
||||
message.jsonMessageId
|
||||
.toLong()
|
||||
message.jsonMessageId.toLong()
|
||||
).first()
|
||||
messageToEdit.message = editedMessageText
|
||||
chatDao.upsertChatMessage(messageToEdit)
|
||||
|
|
@ -964,7 +994,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
}
|
||||
|
||||
override suspend fun sendUnsentChatMessages(credentials: String, url: String) {
|
||||
val tempMessages = chatDao.getTempUnsentMessagesForConversation(internalConversationId).first()
|
||||
val tempMessages = chatDao.getTempUnsentMessagesForConversation(internalConversationId, threadId).first()
|
||||
tempMessages.sortedBy { it.internalId }.onEach {
|
||||
sendChatMessage(
|
||||
credentials,
|
||||
|
|
@ -973,7 +1003,8 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
it.actorDisplayName,
|
||||
it.parentMessageId?.toIntOrZero() ?: 0,
|
||||
it.silent,
|
||||
it.referenceId.orEmpty()
|
||||
it.referenceId.orEmpty(),
|
||||
null
|
||||
).collect { result ->
|
||||
if (result.isSuccess) {
|
||||
Log.d(TAG, "Sent temp message")
|
||||
|
|
@ -1042,6 +1073,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
|||
internalId = "$internalConversationId@_temp_$currentTimeMillies",
|
||||
internalConversationId = internalConversationId,
|
||||
id = currentTimeWithoutYear.toLong(),
|
||||
threadId = threadId,
|
||||
message = message,
|
||||
deleted = false,
|
||||
token = conversationModel.token,
|
||||
|
|
|
|||
|
|
@ -144,7 +144,8 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines:
|
|||
displayName: String,
|
||||
replyTo: Int,
|
||||
sendWithoutNotification: Boolean,
|
||||
referenceId: String
|
||||
referenceId: String,
|
||||
threadTitle: String?
|
||||
): ChatOverallSingleMessage =
|
||||
ncApiCoroutines.sendChatMessage(
|
||||
credentials,
|
||||
|
|
@ -153,7 +154,8 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines:
|
|||
displayName,
|
||||
replyTo,
|
||||
sendWithoutNotification,
|
||||
referenceId
|
||||
referenceId,
|
||||
threadTitle
|
||||
)
|
||||
|
||||
override fun pullChatMessages(
|
||||
|
|
@ -196,10 +198,11 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines:
|
|||
baseUrl: String,
|
||||
token: String,
|
||||
messageId: String,
|
||||
limit: Int
|
||||
limit: Int,
|
||||
threadId: Int?
|
||||
): List<ChatMessageJson> {
|
||||
val url = ApiUtils.getUrlForChatMessageContext(baseUrl, token, messageId)
|
||||
return ncApiCoroutines.getContextOfChatMessage(credentials, url, limit).ocs?.data ?: listOf()
|
||||
return ncApiCoroutines.getContextOfChatMessage(credentials, url, limit, threadId).ocs?.data ?: listOf()
|
||||
}
|
||||
|
||||
override suspend fun getOpenGraph(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.gson.Gson
|
||||
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
|
||||
import com.nextcloud.talk.chat.data.ChatMessageRepository
|
||||
import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager
|
||||
import com.nextcloud.talk.chat.data.io.MediaPlayerManager
|
||||
|
|
@ -24,22 +26,27 @@ import com.nextcloud.talk.chat.data.io.MediaRecorderManager
|
|||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
|
||||
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
|
||||
import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel.Companion.FOLLOWED_THREADS_EXIST
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.extensions.toIntOrZero
|
||||
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
|
||||
import com.nextcloud.talk.models.MessageDraft
|
||||
import com.nextcloud.talk.models.domain.ConversationModel
|
||||
import com.nextcloud.talk.models.domain.ReactionAddedModel
|
||||
import com.nextcloud.talk.models.domain.ReactionDeletedModel
|
||||
import com.nextcloud.talk.models.json.capabilities.SpreedCapability
|
||||
import com.nextcloud.talk.models.json.chat.ChatMessageJson
|
||||
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
|
||||
import com.nextcloud.talk.models.json.conversations.RoomOverall
|
||||
import com.nextcloud.talk.models.json.generic.GenericOverall
|
||||
import com.nextcloud.talk.models.json.opengraph.Reference
|
||||
import com.nextcloud.talk.models.json.reminder.Reminder
|
||||
import com.nextcloud.talk.models.json.threads.ThreadInfo
|
||||
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceData
|
||||
import com.nextcloud.talk.repositories.reactions.ReactionsRepository
|
||||
import com.nextcloud.talk.threadsoverview.data.ThreadsRepository
|
||||
import com.nextcloud.talk.ui.PlaybackSpeed
|
||||
import com.nextcloud.talk.utils.ParticipantPermissions
|
||||
import com.nextcloud.talk.utils.UserIdUtils
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
|
|
@ -51,6 +58,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
|
@ -65,6 +74,7 @@ class ChatViewModel @Inject constructor(
|
|||
private val appPreferences: AppPreferences,
|
||||
private val chatNetworkDataSource: ChatNetworkDataSource,
|
||||
private val chatRepository: ChatMessageRepository,
|
||||
private val threadsRepository: ThreadsRepository,
|
||||
private val conversationRepository: OfflineConversationsRepository,
|
||||
private val reactionsRepository: ReactionsRepository,
|
||||
private val mediaRecorderManager: MediaRecorderManager,
|
||||
|
|
@ -73,6 +83,9 @@ class ChatViewModel @Inject constructor(
|
|||
) : ViewModel(),
|
||||
DefaultLifecycleObserver {
|
||||
|
||||
@Inject
|
||||
lateinit var arbitraryStorageManager: ArbitraryStorageManager
|
||||
|
||||
enum class LifeCycleFlag {
|
||||
PAUSED,
|
||||
RESUMED,
|
||||
|
|
@ -84,6 +97,9 @@ class ChatViewModel @Inject constructor(
|
|||
val disposableSet = mutableSetOf<Disposable>()
|
||||
var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration
|
||||
val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition
|
||||
var chatRoomToken: String = ""
|
||||
var messageDraft: MessageDraft = MessageDraft()
|
||||
lateinit var participantPermissions: ParticipantPermissions
|
||||
|
||||
fun getChatRepository(): ChatMessageRepository = chatRepository
|
||||
|
||||
|
|
@ -103,6 +119,8 @@ class ChatViewModel @Inject constructor(
|
|||
mediaRecorderManager.handleOnPause()
|
||||
chatRepository.handleOnPause()
|
||||
mediaPlayerManager.handleOnPause()
|
||||
|
||||
saveMessageDraft()
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
|
|
@ -152,9 +170,8 @@ class ChatViewModel @Inject constructor(
|
|||
val voiceMessagePlaybackSpeedPreferences: LiveData<Map<String, PlaybackSpeed>>
|
||||
get() = _voiceMessagePlaybackSpeedPreferences
|
||||
|
||||
private val _getContextChatMessages: MutableLiveData<List<ChatMessageJson>> = MutableLiveData()
|
||||
val getContextChatMessages: LiveData<List<ChatMessageJson>>
|
||||
get() = _getContextChatMessages
|
||||
private val _threadRetrieveState = MutableStateFlow<ThreadRetrieveUiState>(ThreadRetrieveUiState.None)
|
||||
val threadRetrieveState: StateFlow<ThreadRetrieveUiState> = _threadRetrieveState
|
||||
|
||||
val getOpenGraph: LiveData<Reference>
|
||||
get() = _getOpenGraph
|
||||
|
|
@ -270,8 +287,9 @@ class ChatViewModel @Inject constructor(
|
|||
val reactionDeletedViewState: LiveData<ViewState>
|
||||
get() = _reactionDeletedViewState
|
||||
|
||||
fun initData(credentials: String, urlForChatting: String, roomToken: String) {
|
||||
chatRepository.initData(credentials, urlForChatting, roomToken)
|
||||
fun initData(credentials: String, urlForChatting: String, roomToken: String, threadId: Long?) {
|
||||
chatRepository.initData(credentials, urlForChatting, roomToken, threadId)
|
||||
chatRoomToken = roomToken
|
||||
}
|
||||
|
||||
fun updateConversation(currentConversation: ConversationModel) {
|
||||
|
|
@ -293,6 +311,10 @@ class ChatViewModel @Inject constructor(
|
|||
} else {
|
||||
_getCapabilitiesViewState.value = GetCapabilitiesUpdateState(user.capabilities!!.spreedCapability!!)
|
||||
}
|
||||
participantPermissions = ParticipantPermissions(
|
||||
user.capabilities!!.spreedCapability!!,
|
||||
conversationModel
|
||||
)
|
||||
} else {
|
||||
chatNetworkDataSource.getCapabilities(user, token)
|
||||
.subscribeOn(Schedulers.io())
|
||||
|
|
@ -308,6 +330,10 @@ class ChatViewModel @Inject constructor(
|
|||
} else {
|
||||
_getCapabilitiesViewState.value = GetCapabilitiesUpdateState(spreedCapabilities)
|
||||
}
|
||||
participantPermissions = ParticipantPermissions(
|
||||
spreedCapabilities,
|
||||
conversationModel
|
||||
)
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
|
|
@ -422,6 +448,45 @@ class ChatViewModel @Inject constructor(
|
|||
})
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
fun getThread(credentials: String, url: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val thread = threadsRepository.getThread(credentials, url)
|
||||
_threadRetrieveState.value = ThreadRetrieveUiState.Success(thread.ocs?.data)
|
||||
} catch (exception: Exception) {
|
||||
_threadRetrieveState.value = ThreadRetrieveUiState.Error(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught", "MagicNumber")
|
||||
fun setThreadNotificationLevel(credentials: String, url: String, level: Int) {
|
||||
fun updateFollowedThreadsIndicator(notificationLevel: Int?) {
|
||||
when (notificationLevel) {
|
||||
1, 2 -> {
|
||||
val accountId = UserIdUtils.getIdForUser(userProvider.currentUser.blockingGet())
|
||||
arbitraryStorageManager.storeStorageSetting(
|
||||
accountId,
|
||||
FOLLOWED_THREADS_EXIST,
|
||||
true.toString(),
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val thread = threadsRepository.setThreadNotificationLevel(credentials, url, level)
|
||||
updateFollowedThreadsIndicator(thread.ocs?.data?.attendee?.notificationLevel)
|
||||
_threadRetrieveState.value = ThreadRetrieveUiState.Success(thread.ocs?.data)
|
||||
} catch (exception: Exception) {
|
||||
_threadRetrieveState.value = ThreadRetrieveUiState.Error(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMessages(withCredentials: String, withUrl: String) {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl)
|
||||
|
|
@ -633,13 +698,20 @@ class ChatViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun stopAndSendAudioRecording(room: String, displayName: String, metaData: String) {
|
||||
fun stopAndSendAudioRecording(roomToken: String = "", replyToMessageId: Int? = null, displayName: String) {
|
||||
stopAudioRecording()
|
||||
|
||||
if (mediaRecorderManager.mediaRecorderState != MediaRecorderManager.MediaRecorderState.ERROR) {
|
||||
val uri = Uri.fromFile(File(mediaRecorderManager.currentVoiceRecordFile))
|
||||
Log.d(TAG, "File uploaded")
|
||||
uploadFile(uri.toString(), room, displayName, metaData)
|
||||
uploadFile(
|
||||
fileUri = uri.toString(),
|
||||
isVoiceMessage = true,
|
||||
caption = "",
|
||||
roomToken = roomToken,
|
||||
replyToMessageId = replyToMessageId,
|
||||
displayName = displayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -652,7 +724,38 @@ class ChatViewModel @Inject constructor(
|
|||
|
||||
fun getCurrentVoiceRecordFile(): String = mediaRecorderManager.currentVoiceRecordFile
|
||||
|
||||
fun uploadFile(fileUri: String, room: String, displayName: String, metaData: String) {
|
||||
fun uploadFile(
|
||||
fileUri: String,
|
||||
isVoiceMessage: Boolean,
|
||||
caption: String = "",
|
||||
roomToken: String = "",
|
||||
replyToMessageId: Int? = null,
|
||||
displayName: String
|
||||
) {
|
||||
val metaDataMap = mutableMapOf<String, Any>()
|
||||
var room = ""
|
||||
|
||||
if (!participantPermissions.hasChatPermission()) {
|
||||
Log.w(TAG, "uploading file(s) is forbidden because of missing attendee permissions")
|
||||
return
|
||||
}
|
||||
|
||||
if (replyToMessageId != 0) {
|
||||
metaDataMap["replyTo"] = replyToMessageId.toString()
|
||||
}
|
||||
|
||||
if (isVoiceMessage) {
|
||||
metaDataMap["messageType"] = "voice-message"
|
||||
}
|
||||
|
||||
if (caption != "") {
|
||||
metaDataMap["caption"] = caption
|
||||
}
|
||||
|
||||
val metaData = Gson().toJson(metaDataMap)
|
||||
|
||||
room = if (roomToken == "") chatRoomToken else roomToken
|
||||
|
||||
try {
|
||||
require(fileUri.isNotEmpty())
|
||||
UploadAndShareFilesWorker.upload(
|
||||
|
|
@ -699,6 +802,8 @@ class ChatViewModel @Inject constructor(
|
|||
emit(message.first())
|
||||
}
|
||||
|
||||
suspend fun getNumberOfThreadReplies(threadId: Long): Int = chatRepository.getNumberOfThreadReplies(threadId)
|
||||
|
||||
fun setPlayBack(speed: PlaybackSpeed) {
|
||||
mediaPlayerManager.setPlayBackSpeed(speed)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
|
|
@ -834,26 +939,34 @@ class ChatViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun getContextForChatMessages(credentials: String, baseUrl: String, token: String, messageId: String, limit: Int) {
|
||||
viewModelScope.launch {
|
||||
val messages = chatNetworkDataSource.getContextForChatMessage(
|
||||
credentials,
|
||||
baseUrl,
|
||||
token,
|
||||
messageId,
|
||||
limit
|
||||
)
|
||||
|
||||
_getContextChatMessages.value = messages
|
||||
}
|
||||
}
|
||||
|
||||
fun getOpenGraph(credentials: String, baseUrl: String, urlToPreview: String) {
|
||||
viewModelScope.launch {
|
||||
_getOpenGraph.value = chatNetworkDataSource.getOpenGraph(credentials, baseUrl, urlToPreview)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateMessageDraft() {
|
||||
val model = conversationRepository.getLocallyStoredConversation(chatRoomToken)
|
||||
model?.messageDraft?.let {
|
||||
messageDraft = it
|
||||
}
|
||||
}
|
||||
|
||||
fun saveMessageDraft() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val model = conversationRepository.getLocallyStoredConversation(chatRoomToken)
|
||||
model?.let {
|
||||
it.messageDraft = messageDraft
|
||||
conversationRepository.updateConversation(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearThreadTitle() {
|
||||
messageDraft.threadTitle = ""
|
||||
saveMessageDraft()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = ChatViewModel::class.simpleName
|
||||
const val JOIN_ROOM_RETRY_COUNT: Long = 3
|
||||
|
|
@ -871,4 +984,10 @@ class ChatViewModel @Inject constructor(
|
|||
data class Success(val statusCode: Int) : UnbindRoomUiState()
|
||||
data class Error(val message: String) : UnbindRoomUiState()
|
||||
}
|
||||
|
||||
sealed class ThreadRetrieveUiState {
|
||||
data object None : ThreadRetrieveUiState()
|
||||
data class Success(val thread: ThreadInfo?) : ThreadRetrieveUiState()
|
||||
data class Error(val exception: Exception) : ThreadRetrieveUiState()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,11 +90,19 @@ class MessageInputViewModel @Inject constructor(
|
|||
val getEditChatMessage: LiveData<IMessage?>
|
||||
get() = _getEditChatMessage
|
||||
|
||||
private val _getReplyChatMessage: MutableLiveData<IMessage?> = MutableLiveData()
|
||||
val getReplyChatMessage: LiveData<IMessage?>
|
||||
private val _getReplyChatMessage: MutableLiveData<ChatMessage?> = MutableLiveData()
|
||||
val getReplyChatMessage: LiveData<ChatMessage?>
|
||||
get() = _getReplyChatMessage
|
||||
|
||||
object CreateThreadStartState : ViewState
|
||||
class CreateThreadEditState : ViewState
|
||||
|
||||
private val _createThreadViewState: MutableLiveData<ViewState> = MutableLiveData(CreateThreadStartState)
|
||||
val createThreadViewState: LiveData<ViewState>
|
||||
get() = _createThreadViewState
|
||||
|
||||
sealed interface ViewState
|
||||
|
||||
object SendChatMessageStartState : ViewState
|
||||
class SendChatMessageSuccessState(val message: CharSequence) : ViewState
|
||||
class SendChatMessageErrorState(val message: CharSequence) : ViewState
|
||||
|
|
@ -125,7 +133,8 @@ class MessageInputViewModel @Inject constructor(
|
|||
message: String,
|
||||
displayName: String,
|
||||
replyTo: Int,
|
||||
sendWithoutNotification: Boolean
|
||||
sendWithoutNotification: Boolean,
|
||||
threadTitle: String?
|
||||
) {
|
||||
val referenceId = SendMessageUtils().generateReferenceId()
|
||||
Log.d(TAG, "Random SHA-256 Hash: $referenceId")
|
||||
|
|
@ -156,7 +165,8 @@ class MessageInputViewModel @Inject constructor(
|
|||
displayName,
|
||||
replyTo,
|
||||
sendWithoutNotification,
|
||||
referenceId
|
||||
referenceId,
|
||||
threadTitle
|
||||
).collect { result ->
|
||||
if (result.isSuccess) {
|
||||
Log.d(TAG, "received ref id: " + (result.getOrNull()?.referenceId ?: "none"))
|
||||
|
|
@ -203,7 +213,7 @@ class MessageInputViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun reply(message: IMessage?) {
|
||||
fun reply(message: ChatMessage?) {
|
||||
_getReplyChatMessage.postValue(message)
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +266,14 @@ class MessageInputViewModel @Inject constructor(
|
|||
_callStartedFlow.postValue(Pair(recent, show))
|
||||
}
|
||||
|
||||
fun startThreadCreation() {
|
||||
_createThreadViewState.postValue(CreateThreadEditState())
|
||||
}
|
||||
|
||||
fun stopThreadCreation() {
|
||||
_createThreadViewState.postValue(CreateThreadStartState)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = MessageInputViewModel::class.java.simpleName
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,28 +51,30 @@ fun StandardAppBar(title: String, menuItems: List<Pair<String, () -> Unit>>?) {
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.nc_common_more_options)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.background(color = colorResource(id = R.color.bg_default))
|
||||
) {
|
||||
menuItems?.forEach { (label, action) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
action()
|
||||
expanded = false
|
||||
}
|
||||
if (!menuItems.isNullOrEmpty()) {
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.nc_common_more_options)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.background(color = colorResource(id = R.color.bg_default))
|
||||
) {
|
||||
menuItems?.forEach { (label, action) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
action()
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
|
||||
* SPDX-FileCopyrightText: 2024 Sowjanya Kota <sowjanya.kch@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package com.nextcloud.talk.contacts
|
|||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -35,7 +36,8 @@ fun ContactsScreen(contactsViewModel: ContactsViewModel, uiState: ContactsUiStat
|
|||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding(),
|
||||
.statusBarsPadding()
|
||||
.displayCutoutPadding(),
|
||||
topBar = {
|
||||
if (isSearchActive) {
|
||||
ContactsSearchAppBar(
|
||||
|
|
@ -64,8 +66,8 @@ fun ContactsScreen(contactsViewModel: ContactsViewModel, uiState: ContactsUiStat
|
|||
content = { paddingValues ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp)
|
||||
.background(colorResource(id = R.color.bg_default))
|
||||
.padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, paddingValues.calculateBottomPadding())
|
||||
) {
|
||||
if (!isAddParticipants) {
|
||||
ConversationCreationOptions()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.contextchat
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.data.database.mappers.asModel
|
||||
import com.nextcloud.talk.models.json.chat.ChatMessageJson
|
||||
import com.nextcloud.talk.ui.ComposeChatAdapter
|
||||
import com.nextcloud.talk.utils.preview.ComposePreviewUtils
|
||||
|
||||
@Composable
|
||||
fun ContextChatView(context: Context, contextViewModel: ContextChatViewModel) {
|
||||
val contextChatMessagesState = contextViewModel.getContextChatMessagesState.collectAsState().value
|
||||
|
||||
when (contextChatMessagesState) {
|
||||
ContextChatViewModel.ContextChatRetrieveUiState.None -> {}
|
||||
is ContextChatViewModel.ContextChatRetrieveUiState.Success -> {
|
||||
ContextChatSuccessView(
|
||||
visible = true,
|
||||
context = context,
|
||||
contextChatRetrieveUiStateSuccess = contextChatMessagesState,
|
||||
onDismiss = {
|
||||
contextViewModel.clearContextChatState()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ContextChatViewModel.ContextChatRetrieveUiState.Error -> {
|
||||
ContextChatErrorView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContextChatErrorView() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Info,
|
||||
contentDescription = "Info Icon"
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.nc_capabilities_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContextChatSuccessView(
|
||||
visible: Boolean,
|
||||
context: Context,
|
||||
contextChatRetrieveUiStateSuccess: ContextChatViewModel.ContextChatRetrieveUiState.Success,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val previewUtils = ComposePreviewUtils.getInstance(LocalContext.current)
|
||||
val colorScheme = previewUtils.viewThemeUtils.getColorScheme(context)
|
||||
|
||||
if (visible) {
|
||||
MaterialTheme(colorScheme) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
modifier = Modifier.Companion
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.Companion.align(Alignment.Companion.Start),
|
||||
verticalAlignment = Alignment.Companion.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
stringResource(R.string.close),
|
||||
modifier = Modifier.Companion
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
Text(contextChatRetrieveUiStateSuccess.title ?: "", fontSize = 18.sp)
|
||||
|
||||
if (!contextChatRetrieveUiStateSuccess.subTitle.isNullOrEmpty()) {
|
||||
Text(contextChatRetrieveUiStateSuccess.subTitle, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// This code was written back then but not needed yet, but it's not deleted yet
|
||||
// because it may be used soon when further migrating to Compose...
|
||||
|
||||
// Spacer(modifier = Modifier.weight(1f))
|
||||
// val cInt = context.resources.getColor(R.color.high_emphasis_text, null)
|
||||
// Icon(
|
||||
// painterResource(R.drawable.ic_call_black_24dp),
|
||||
// "",
|
||||
// tint = Color(cInt),
|
||||
// modifier = Modifier
|
||||
// .padding()
|
||||
// .padding(end = 16.dp)
|
||||
// .alpha(HALF_ALPHA)
|
||||
// )
|
||||
//
|
||||
// Icon(
|
||||
// painterResource(R.drawable.ic_baseline_videocam_24),
|
||||
// "",
|
||||
// tint = Color(cInt),
|
||||
// modifier = Modifier
|
||||
// .padding()
|
||||
// .alpha(HALF_ALPHA)
|
||||
// )
|
||||
//
|
||||
// ComposeChatMenu(colorScheme.background, false)
|
||||
}
|
||||
|
||||
val messages = contextChatRetrieveUiStateSuccess.messages.map(ChatMessageJson::asModel)
|
||||
val messageId = contextChatRetrieveUiStateSuccess.messageId
|
||||
val threadId = contextChatRetrieveUiStateSuccess.threadId
|
||||
val adapter = ComposeChatAdapter(
|
||||
messagesJson = contextChatRetrieveUiStateSuccess.messages,
|
||||
messageId = messageId,
|
||||
threadId = threadId
|
||||
)
|
||||
SideEffect {
|
||||
adapter.addMessages(messages.toMutableList(), true)
|
||||
}
|
||||
adapter.GetView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This code was written back then but not needed yet, but it's not deleted yet
|
||||
// because it may be used soon when further migrating to Compose...
|
||||
@Composable
|
||||
private fun ComposeChatMenu(backgroundColor: Color, enabled: Boolean = true) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.Companion.wrapContentSize(Alignment.Companion.TopStart)
|
||||
) {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More options"
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.Companion.background(backgroundColor)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.nc_search)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
},
|
||||
enabled = enabled
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.nc_conversation_menu_conversation_info)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
},
|
||||
enabled = enabled
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.nc_shared_items)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
},
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.contextchat
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import autodagger.AutoInjector
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
|
||||
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
|
||||
import com.nextcloud.talk.models.json.chat.ChatMessageJson
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource: ChatNetworkDataSource) :
|
||||
ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var chatViewModel: ChatViewModel
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
var threadId: String? = null
|
||||
|
||||
private val _getContextChatMessagesState =
|
||||
MutableStateFlow<ContextChatRetrieveUiState>(ContextChatRetrieveUiState.None)
|
||||
val getContextChatMessagesState: StateFlow<ContextChatRetrieveUiState> = _getContextChatMessagesState
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun getContextForChatMessages(
|
||||
credentials: String,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
threadId: String?,
|
||||
messageId: String,
|
||||
title: String
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val user = userManager.currentUser.blockingGet()
|
||||
|
||||
if (!user.hasSpreedFeatureCapability("chat-get-context") ||
|
||||
!user.hasSpreedFeatureCapability("federation-v1")
|
||||
) {
|
||||
_getContextChatMessagesState.value = ContextChatRetrieveUiState.Error
|
||||
}
|
||||
|
||||
var messages = chatNetworkDataSource.getContextForChatMessage(
|
||||
credentials = credentials,
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
messageId = messageId,
|
||||
limit = LIMIT,
|
||||
threadId = threadId?.toInt()
|
||||
)
|
||||
|
||||
if (threadId.isNullOrEmpty()) {
|
||||
messages = messages.filter { !isThreadChildMessage(it) }
|
||||
}
|
||||
|
||||
val subTitle = if (threadId?.isNotEmpty() == true) {
|
||||
messages.firstOrNull()?.threadTitle
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
_getContextChatMessagesState.value = ContextChatRetrieveUiState.Success(
|
||||
messageId = messageId,
|
||||
threadId = threadId,
|
||||
messages = messages,
|
||||
title = title,
|
||||
subTitle = subTitle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isThreadChildMessage(currentMessage: ChatMessageJson): Boolean =
|
||||
currentMessage.hasThread &&
|
||||
currentMessage.threadId != currentMessage.id
|
||||
|
||||
fun clearContextChatState() {
|
||||
_getContextChatMessagesState.value = ContextChatRetrieveUiState.None
|
||||
}
|
||||
|
||||
sealed class ContextChatRetrieveUiState {
|
||||
data object None : ContextChatRetrieveUiState()
|
||||
data class Success(
|
||||
val messageId: String,
|
||||
val threadId: String?,
|
||||
val messages: List<ChatMessageJson>,
|
||||
val title: String?,
|
||||
val subTitle: String?
|
||||
) : ContextChatRetrieveUiState()
|
||||
data object Error : ContextChatRetrieveUiState()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LIMIT = 50
|
||||
}
|
||||
}
|
||||
|
|
@ -29,12 +29,14 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
|
@ -173,6 +175,9 @@ fun ConversationCreationScreen(
|
|||
|
||||
ColoredStatusBar()
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.displayCutoutPadding(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.nc_new_conversation)) },
|
||||
|
|
@ -191,8 +196,8 @@ fun ConversationCreationScreen(
|
|||
content = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp)
|
||||
.background(colorResource(id = R.color.bg_default))
|
||||
.padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, paddingValues.calculateBottomPadding())
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ import com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS
|
|||
import com.nextcloud.talk.models.json.participants.ParticipantsOverall
|
||||
import com.nextcloud.talk.repositories.conversations.ConversationsRepository
|
||||
import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
|
||||
import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity
|
||||
import com.nextcloud.talk.ui.dialog.DialogBanListFragment
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.CapabilitiesUtil
|
||||
|
|
@ -601,6 +602,22 @@ class ConversationInfoActivity :
|
|||
startActivity(intent)
|
||||
}
|
||||
|
||||
fun openThreadsOverview() {
|
||||
val threadsUrl = ApiUtils.getUrlForRecentThreads(
|
||||
version = 1,
|
||||
baseUrl = conversationUser.baseUrl,
|
||||
token = conversationToken
|
||||
)
|
||||
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_ROOM_TOKEN, conversationToken)
|
||||
bundle.putString(ThreadsOverviewActivity.KEY_APPBAR_TITLE, getString(R.string.recent_threads))
|
||||
bundle.putString(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl)
|
||||
val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java)
|
||||
threadsOverviewIntent.putExtras(bundle)
|
||||
startActivity(threadsOverviewIntent)
|
||||
}
|
||||
|
||||
private fun setupWebinaryView() {
|
||||
if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.WEBINARY_LOBBY) &&
|
||||
webinaryRoomType(conversation!!) &&
|
||||
|
|
@ -1057,9 +1074,17 @@ class ConversationInfoActivity :
|
|||
) {
|
||||
binding.sharedItemsButton.setOnClickListener { showSharedItems() }
|
||||
} else {
|
||||
binding.sharedItemsButton.visibility = GONE
|
||||
binding.sharedItems.visibility = GONE
|
||||
}
|
||||
|
||||
if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS)) {
|
||||
binding.sharedItems.visibility = VISIBLE
|
||||
binding.showThreadsButton.setOnClickListener { openThreadsOverview() }
|
||||
} else {
|
||||
binding.showThreadsButton.visibility = GONE
|
||||
}
|
||||
|
||||
if (conversation!!.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL &&
|
||||
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CONVERSATION_CREATION_ALL)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -38,14 +38,12 @@ import androidx.activity.OnBackPressedCallback
|
|||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.MenuItemCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.DialogFragment
|
||||
|
|
@ -111,10 +109,12 @@ import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter
|
|||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository
|
||||
import com.nextcloud.talk.settings.SettingsActivity
|
||||
import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity
|
||||
import com.nextcloud.talk.ui.BackgroundVoiceMessageCard
|
||||
import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment
|
||||
import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment
|
||||
import com.nextcloud.talk.ui.dialog.ContextChatCompose
|
||||
import com.nextcloud.talk.contextchat.ContextChatView
|
||||
import com.nextcloud.talk.contextchat.ContextChatViewModel
|
||||
import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog
|
||||
import com.nextcloud.talk.ui.dialog.FilterConversationFragment
|
||||
import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE
|
||||
|
|
@ -203,6 +203,7 @@ class ConversationsListActivity :
|
|||
lateinit var contactsViewModel: ContactsViewModel
|
||||
|
||||
lateinit var conversationsListViewModel: ConversationsListViewModel
|
||||
lateinit var contextChatViewModel: ContextChatViewModel
|
||||
|
||||
override val appBarLayoutType: AppBarLayoutType
|
||||
get() = AppBarLayoutType.SEARCH_BAR
|
||||
|
|
@ -262,14 +263,14 @@ class ConversationsListActivity :
|
|||
currentUser = currentUserProvider.currentUser.blockingGet()
|
||||
|
||||
conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java]
|
||||
contextChatViewModel = ViewModelProvider(this, viewModelFactory)[ContextChatViewModel::class.java]
|
||||
|
||||
binding = ActivityConversationsBinding.inflate(layoutInflater)
|
||||
setupActionBar()
|
||||
setContentView(binding.root)
|
||||
initSystemBars()
|
||||
|
||||
viewThemeUtils.material.themeSearchCardView(binding.searchToolbar)
|
||||
viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE)
|
||||
viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT)
|
||||
viewThemeUtils.platform.colorTextView(binding.searchText, ColorRole.ON_SURFACE_VARIANT)
|
||||
|
||||
forwardMessage = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false)
|
||||
|
|
@ -343,6 +344,7 @@ class ConversationsListActivity :
|
|||
}
|
||||
|
||||
showSearchOrToolbar()
|
||||
conversationsListViewModel.checkIfThreadsExist()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
|
@ -359,7 +361,7 @@ class ConversationsListActivity :
|
|||
@Suppress("MagicNumber")
|
||||
private fun addEmptyItemForEdgeToEdgeIfNecessary() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
adapter?.addScrollableFooter(SpacerItem(200))
|
||||
adapter?.addScrollableFooter(SpacerItem(100))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +430,23 @@ class ConversationsListActivity :
|
|||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
conversationsListViewModel.threadsExistState.collect { state ->
|
||||
when (state) {
|
||||
is ConversationsListViewModel.ThreadsExistUiState.Success -> {
|
||||
binding.threadsButton.visibility = if (state.threadsExistence == true) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
binding.threadsButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
conversationsListViewModel.getRoomsFlow
|
||||
.onEach { list ->
|
||||
|
|
@ -707,6 +726,7 @@ class ConversationsListActivity :
|
|||
binding.newMentionPopupBubble.visibility = View.GONE
|
||||
}
|
||||
|
||||
layoutManager?.scrollToPositionWithOffset(0, 0)
|
||||
updateFilterConversationButtonColor()
|
||||
}
|
||||
|
||||
|
|
@ -994,7 +1014,7 @@ class ConversationsListActivity :
|
|||
private fun showSearchBar() {
|
||||
val layoutParams = binding.searchToolbar.layoutParams as AppBarLayout.LayoutParams
|
||||
binding.searchToolbar.visibility = View.VISIBLE
|
||||
binding.searchText.hint = getString(R.string.appbar_search_in, getString(R.string.nc_app_product_name))
|
||||
binding.searchText.text = getString(R.string.appbar_search_in, getString(R.string.nc_app_product_name))
|
||||
binding.conversationListToolbar.visibility = View.GONE
|
||||
// layoutParams.setScrollFlags(AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL | AppBarLayout
|
||||
// .LayoutParams.SCROLL_FLAG_SNAP | AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS);
|
||||
|
|
@ -1304,6 +1324,13 @@ class ConversationsListActivity :
|
|||
newFragment.show(supportFragmentManager, FilterConversationFragment.TAG)
|
||||
}
|
||||
|
||||
binding.threadsButton.setOnClickListener {
|
||||
openFollowedThreadsOverview()
|
||||
}
|
||||
binding.threadsButton.let {
|
||||
viewThemeUtils.platform.colorImageView(it, ColorRole.ON_SURFACE_VARIANT)
|
||||
}
|
||||
|
||||
binding.newMentionPopupBubble.visibility = View.GONE
|
||||
binding.newMentionPopupBubble.setOnClickListener {
|
||||
val layoutManager = binding.recyclerView.layoutManager as SmoothScrollLinearLayoutManager?
|
||||
|
|
@ -1507,15 +1534,16 @@ class ConversationsListActivity :
|
|||
).model.displayName
|
||||
|
||||
binding.genericComposeView.apply {
|
||||
val shouldDismiss = mutableStateOf(false)
|
||||
setContent {
|
||||
val bundle = bundleOf()
|
||||
bundle.putString(BundleKeys.KEY_CREDENTIALS, credentials!!)
|
||||
bundle.putString(BundleKeys.KEY_BASE_URL, currentUser!!.baseUrl)
|
||||
bundle.putString(KEY_ROOM_TOKEN, token)
|
||||
bundle.putString(BundleKeys.KEY_MESSAGE_ID, item.messageEntry.messageId)
|
||||
bundle.putString(BundleKeys.KEY_CONVERSATION_NAME, conversationName)
|
||||
ContextChatCompose(bundle).GetDialogView(shouldDismiss, context)
|
||||
contextChatViewModel.getContextForChatMessages(
|
||||
credentials = credentials!!,
|
||||
baseUrl = currentUser!!.baseUrl!!,
|
||||
token = token,
|
||||
threadId = item.messageEntry.threadId,
|
||||
messageId = item.messageEntry.messageId!!,
|
||||
title = item.messageEntry.title
|
||||
)
|
||||
ContextChatView(context, contextChatViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2205,17 +2233,32 @@ class ConversationsListActivity :
|
|||
binding.filterConversationsButton.let {
|
||||
viewThemeUtils.platform.colorImageView(
|
||||
it,
|
||||
ColorRole.ON_SURFACE
|
||||
ColorRole.ON_SURFACE_VARIANT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openFollowedThreadsOverview() {
|
||||
val threadsUrl = ApiUtils.getUrlForSubscribedThreads(
|
||||
version = 1,
|
||||
baseUrl = currentUser!!.baseUrl
|
||||
)
|
||||
|
||||
val bundle = Bundle()
|
||||
bundle.putString(ThreadsOverviewActivity.KEY_APPBAR_TITLE, getString(R.string.threads))
|
||||
bundle.putString(ThreadsOverviewActivity.KEY_THREADS_SOURCE_URL, threadsUrl)
|
||||
val threadsOverviewIntent = Intent(context, ThreadsOverviewActivity::class.java)
|
||||
threadsOverviewIntent.putExtras(bundle)
|
||||
startActivity(threadsOverviewIntent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = ConversationsListActivity::class.java.simpleName
|
||||
const val UNREAD_BUBBLE_DELAY = 2500
|
||||
const val BOTTOM_SHEET_DELAY: Long = 2500
|
||||
private const val KEY_SEARCH_QUERY = "ConversationsListActivity.searchQuery"
|
||||
private const val CHAT_ACTIVITY_LOCAL_NAME = "com.nextcloud.talk.chat.ChatActivity"
|
||||
const val SEARCH_DEBOUNCE_INTERVAL_MS = 300
|
||||
const val SEARCH_MIN_CHARS = 1
|
||||
const val HTTP_UNAUTHORIZED = 401
|
||||
|
|
|
|||
|
|
@ -36,4 +36,8 @@ interface OfflineConversationsRepository {
|
|||
* to be handled asynchronously.
|
||||
*/
|
||||
fun getRoom(roomToken: String): Job
|
||||
|
||||
suspend fun updateConversation(conversationModel: ConversationModel)
|
||||
|
||||
suspend fun getLocallyStoredConversation(roomToken: String): ConversationModel?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,12 +98,22 @@ class OfflineFirstConversationsRepository @Inject constructor(
|
|||
runBlocking {
|
||||
_conversationFlow.emit(model)
|
||||
val entityList = listOf(model.asEntity())
|
||||
dao.upsertConversations(entityList)
|
||||
dao.upsertConversations(user.id!!, entityList)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override suspend fun updateConversation(conversationModel: ConversationModel) {
|
||||
val entity = conversationModel.asEntity()
|
||||
dao.updateConversation(entity)
|
||||
}
|
||||
|
||||
override suspend fun getLocallyStoredConversation(roomToken: String): ConversationModel? {
|
||||
val id = user.id!!
|
||||
return getConversation(id, roomToken)
|
||||
}
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
private suspend fun getRoomsFromServer(): List<ConversationEntity>? {
|
||||
var conversationsFromSync: List<ConversationEntity>? = null
|
||||
|
|
@ -126,7 +136,7 @@ class OfflineFirstConversationsRepository @Inject constructor(
|
|||
}
|
||||
|
||||
deleteLeftConversations(conversationsFromSync)
|
||||
dao.upsertConversations(conversationsFromSync)
|
||||
dao.upsertConversations(user.id!!, conversationsFromSync)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Something went wrong when fetching conversations", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,21 +10,35 @@ import android.util.Log
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
|
||||
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.invitation.data.InvitationsModel
|
||||
import com.nextcloud.talk.invitation.data.InvitationsRepository
|
||||
import com.nextcloud.talk.threadsoverview.data.ThreadsRepository
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
|
||||
import com.nextcloud.talk.utils.SpreedFeatures
|
||||
import com.nextcloud.talk.utils.UserIdUtils
|
||||
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
|
||||
import io.reactivex.Observer
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class ConversationsListViewModel @Inject constructor(
|
||||
private val repository: OfflineConversationsRepository,
|
||||
private val threadsRepository: ThreadsRepository,
|
||||
private val currentUserProvider: CurrentUserProviderNew,
|
||||
var userManager: UserManager
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -32,10 +46,23 @@ class ConversationsListViewModel @Inject constructor(
|
|||
lateinit var invitationsRepository: InvitationsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var currentUserProvider: CurrentUserProviderNew
|
||||
lateinit var arbitraryStorageManager: ArbitraryStorageManager
|
||||
|
||||
private val _currentUser = currentUserProvider.currentUser.blockingGet()
|
||||
val currentUser: User = _currentUser
|
||||
val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: ""
|
||||
|
||||
sealed interface ViewState
|
||||
|
||||
sealed class ThreadsExistUiState {
|
||||
data object None : ThreadsExistUiState()
|
||||
data class Success(val threadsExistence: Boolean?) : ThreadsExistUiState()
|
||||
data class Error(val exception: Exception) : ThreadsExistUiState()
|
||||
}
|
||||
|
||||
private val _threadsExistState = MutableStateFlow<ThreadsExistUiState>(ThreadsExistUiState.None)
|
||||
val threadsExistState: StateFlow<ThreadsExistUiState> = _threadsExistState
|
||||
|
||||
object GetRoomsStartState : ViewState
|
||||
object GetRoomsErrorState : ViewState
|
||||
open class GetRoomsSuccessState(val listIsNotEmpty: Boolean) : ViewState
|
||||
|
|
@ -87,6 +114,79 @@ class ConversationsListViewModel @Inject constructor(
|
|||
repository.getRooms()
|
||||
}
|
||||
|
||||
fun checkIfThreadsExist() {
|
||||
val limitForFollowedThreadsExistenceCheck = 1
|
||||
val accountId = UserIdUtils.getIdForUser(currentUserProvider.currentUser.blockingGet())
|
||||
|
||||
fun isLastCheckTooOld(lastCheckDate: Long): Boolean {
|
||||
val currentTimeMillis = System.currentTimeMillis()
|
||||
val differenceMillis = currentTimeMillis - lastCheckDate
|
||||
val checkIntervalInMillies = TimeUnit.HOURS.toMillis(2)
|
||||
return differenceMillis > checkIntervalInMillies
|
||||
}
|
||||
|
||||
fun checkIfFollowedThreadsExist() {
|
||||
val threadsUrl = ApiUtils.getUrlForSubscribedThreads(
|
||||
version = 1,
|
||||
baseUrl = currentUser.baseUrl
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val threads =
|
||||
threadsRepository.getThreads(credentials, threadsUrl, limitForFollowedThreadsExistenceCheck)
|
||||
val followedThreadsExistNew = threads.ocs?.data?.isNotEmpty()
|
||||
_threadsExistState.value = ThreadsExistUiState.Success(followedThreadsExistNew)
|
||||
val followedThreadsExistLastCheckNew = System.currentTimeMillis()
|
||||
arbitraryStorageManager.storeStorageSetting(
|
||||
accountId,
|
||||
FOLLOWED_THREADS_EXIST_LAST_CHECK,
|
||||
followedThreadsExistLastCheckNew.toString(),
|
||||
""
|
||||
)
|
||||
arbitraryStorageManager.storeStorageSetting(
|
||||
accountId,
|
||||
FOLLOWED_THREADS_EXIST,
|
||||
followedThreadsExistNew.toString(),
|
||||
""
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
_threadsExistState.value = ThreadsExistUiState.Error(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSpreedFeatureCapability(currentUser.capabilities!!.spreedCapability!!, SpreedFeatures.THREADS)) {
|
||||
_threadsExistState.value = ThreadsExistUiState.Success(false)
|
||||
return
|
||||
}
|
||||
|
||||
val followedThreadsExistOld = arbitraryStorageManager.getStorageSetting(
|
||||
accountId,
|
||||
FOLLOWED_THREADS_EXIST,
|
||||
""
|
||||
).blockingGet()?.value?.toBoolean() ?: false
|
||||
|
||||
val followedThreadsExistLastCheckOld = arbitraryStorageManager.getStorageSetting(
|
||||
accountId,
|
||||
FOLLOWED_THREADS_EXIST_LAST_CHECK,
|
||||
""
|
||||
).blockingGet()?.value?.toLong()
|
||||
|
||||
if (followedThreadsExistOld) {
|
||||
Log.d(TAG, "followed threads exist for this user. No need to check again.")
|
||||
_threadsExistState.value = ThreadsExistUiState.Success(true)
|
||||
} else {
|
||||
if (followedThreadsExistLastCheckOld == null || isLastCheckTooOld(followedThreadsExistLastCheckOld)) {
|
||||
Log.d(TAG, "check if followed threads exist never happened or is too old. Checking now...")
|
||||
checkIfFollowedThreadsExist()
|
||||
} else {
|
||||
_threadsExistState.value = ThreadsExistUiState.Success(false)
|
||||
Log.d(TAG, "already checked in the last 2 hours if followed threads exist. Skip check.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FederatedInvitationsObserver : Observer<InvitationsModel> {
|
||||
override fun onSubscribe(d: Disposable) {
|
||||
// unused atm
|
||||
|
|
@ -122,5 +222,7 @@ class ConversationsListViewModel @Inject constructor(
|
|||
|
||||
companion object {
|
||||
private val TAG = ConversationsListViewModel::class.simpleName
|
||||
const val FOLLOWED_THREADS_EXIST_LAST_CHECK = "FOLLOWED_THREADS_EXIST_LAST_CHECK"
|
||||
const val FOLLOWED_THREADS_EXIST = "FOLLOWED_THREADS_EXIST"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@
|
|||
*/
|
||||
package com.nextcloud.talk.dagger.modules
|
||||
|
||||
import android.content.Context
|
||||
import com.nextcloud.talk.account.data.LoginRepository
|
||||
import com.nextcloud.talk.account.data.io.LocalLoginDataSource
|
||||
import com.nextcloud.talk.account.data.network.NetworkLoginDataSource
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.api.NcApiCoroutines
|
||||
import com.nextcloud.talk.chat.data.ChatMessageRepository
|
||||
|
|
@ -54,10 +58,14 @@ import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository
|
|||
import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepositoryImpl
|
||||
import com.nextcloud.talk.shareditems.repositories.SharedItemsRepository
|
||||
import com.nextcloud.talk.shareditems.repositories.SharedItemsRepositoryImpl
|
||||
import com.nextcloud.talk.threadsoverview.data.ThreadsRepository
|
||||
import com.nextcloud.talk.threadsoverview.data.ThreadsRepositoryImpl
|
||||
import com.nextcloud.talk.translate.repositories.TranslateRepository
|
||||
import com.nextcloud.talk.translate.repositories.TranslateRepositoryImpl
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -184,4 +192,27 @@ class RepositoryModule {
|
|||
ncApiCoroutines: NcApiCoroutines,
|
||||
currentUserProviderNew: CurrentUserProviderNew
|
||||
): ConversationCreationRepository = ConversationCreationRepositoryImpl(ncApiCoroutines, currentUserProviderNew)
|
||||
|
||||
@Provides
|
||||
fun provideThreadsRepository(
|
||||
ncApiCoroutines: NcApiCoroutines,
|
||||
currentUserProviderNew: CurrentUserProviderNew
|
||||
): ThreadsRepository = ThreadsRepositoryImpl(ncApiCoroutines, currentUserProviderNew)
|
||||
|
||||
@Provides
|
||||
fun provideNetworkDataSource(okHttpClient: OkHttpClient): NetworkLoginDataSource =
|
||||
NetworkLoginDataSource(okHttpClient)
|
||||
|
||||
@Provides
|
||||
fun providesLocalDataSource(
|
||||
userManager: UserManager,
|
||||
appPreferences: AppPreferences,
|
||||
context: Context
|
||||
): LocalLoginDataSource = LocalLoginDataSource(userManager, appPreferences, context)
|
||||
|
||||
@Provides
|
||||
fun provideLoginRepository(
|
||||
networkLoginDataSource: NetworkLoginDataSource,
|
||||
localLoginDataSource: LocalLoginDataSource
|
||||
): LoginRepository = LoginRepository(networkLoginDataSource, localLoginDataSource)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,10 @@ import java.security.KeyStoreException;
|
|||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Singleton;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.X509KeyManager;
|
||||
|
|
@ -48,6 +49,7 @@ import dagger.Provides;
|
|||
import io.reactivex.schedulers.Schedulers;
|
||||
import okhttp3.Authenticator;
|
||||
import okhttp3.Cache;
|
||||
import okhttp3.ConnectionSpec;
|
||||
import okhttp3.Credentials;
|
||||
import okhttp3.Dispatcher;
|
||||
import okhttp3.Interceptor;
|
||||
|
|
@ -60,7 +62,6 @@ import okhttp3.internal.tls.OkHostnameVerifier;
|
|||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
|
||||
import retrofit2.converter.gson.GsonConverterFactory;
|
||||
|
||||
@Module(includes = DatabaseModule.class)
|
||||
public class RestModule {
|
||||
|
|
@ -257,8 +258,8 @@ public class RestModule {
|
|||
|
||||
public static class HttpAuthenticator implements Authenticator {
|
||||
|
||||
private String credentials;
|
||||
private String authenticatorType;
|
||||
private final String credentials;
|
||||
private final String authenticatorType;
|
||||
|
||||
public HttpAuthenticator(@NonNull String credentials, @NonNull String authenticatorType) {
|
||||
this.credentials = credentials;
|
||||
|
|
@ -291,7 +292,7 @@ public class RestModule {
|
|||
|
||||
private class GetProxyRunnable implements Runnable {
|
||||
private volatile Proxy proxy;
|
||||
private AppPreferences appPreferences;
|
||||
private final AppPreferences appPreferences;
|
||||
|
||||
GetProxyRunnable(AppPreferences appPreferences) {
|
||||
this.appPreferences = appPreferences;
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ package com.nextcloud.talk.dagger.modules
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel
|
||||
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
|
||||
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
|
||||
import com.nextcloud.talk.contacts.ContactsViewModel
|
||||
import com.nextcloud.talk.contextchat.ContextChatViewModel
|
||||
import com.nextcloud.talk.conversationcreation.ConversationCreationViewModel
|
||||
import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel
|
||||
import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel
|
||||
|
|
@ -27,6 +29,7 @@ import com.nextcloud.talk.polls.viewmodels.PollVoteViewModel
|
|||
import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel
|
||||
import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel
|
||||
import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel
|
||||
import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel
|
||||
import com.nextcloud.talk.translate.viewmodels.TranslateViewModel
|
||||
import com.nextcloud.talk.viewmodels.CallRecordingViewModel
|
||||
import dagger.Binds
|
||||
|
|
@ -154,4 +157,19 @@ abstract class ViewModelModule {
|
|||
@IntoMap
|
||||
@ViewModelKey(DiagnoseViewModel::class)
|
||||
abstract fun diagnoseViewModel(viewModel: DiagnoseViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ThreadsOverviewViewModel::class)
|
||||
abstract fun threadsOverviewViewModel(viewModel: ThreadsOverviewViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(BrowserLoginActivityViewModel::class)
|
||||
abstract fun browserLoginActivityViewModel(viewModel: BrowserLoginActivityViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ContextChatViewModel::class)
|
||||
abstract fun contextChatViewModel(viewModel: ContextChatViewModel): ViewModel
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,43 +25,24 @@ interface ChatBlocksDao {
|
|||
SELECT *
|
||||
FROM ChatBlocks
|
||||
WHERE internalConversationId in (:internalConversationId)
|
||||
ORDER BY newestMessageId ASC
|
||||
"""
|
||||
)
|
||||
fun getChatBlocks(internalConversationId: String): Flow<List<ChatBlockEntity>>
|
||||
|
||||
// @Query(
|
||||
// """
|
||||
// SELECT *
|
||||
// FROM ChatBlocks
|
||||
// WHERE internalConversationId in (:internalConversationId)
|
||||
// AND newestMessageId >= :messageId
|
||||
// ORDER BY newestMessageId ASC
|
||||
// """
|
||||
// )
|
||||
// fun getChatBlocksThatReachMessageId(
|
||||
// internalConversationId: String,
|
||||
// messageId: Long
|
||||
// ):
|
||||
// Flow<List<ChatBlockEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ChatBlocks
|
||||
WHERE internalConversationId in (:internalConversationId)
|
||||
AND (threadId = :threadId OR (threadId IS NULL AND :threadId IS NULL))
|
||||
AND oldestMessageId <= :messageId
|
||||
AND newestMessageId >= :messageId
|
||||
ORDER BY newestMessageId ASC
|
||||
"""
|
||||
)
|
||||
fun getChatBlocksContainingMessageId(internalConversationId: String, messageId: Long): Flow<List<ChatBlockEntity?>>
|
||||
fun getChatBlocksContainingMessageId(
|
||||
internalConversationId: String,
|
||||
threadId: Long?,
|
||||
messageId: Long
|
||||
): Flow<List<ChatBlockEntity?>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ChatBlocks
|
||||
WHERE internalConversationId = :internalConversationId
|
||||
AND (threadId = :threadId OR (threadId IS NULL AND :threadId IS NULL))
|
||||
AND(
|
||||
(oldestMessageId <= :oldestMessageId AND newestMessageId >= :oldestMessageId)
|
||||
OR
|
||||
|
|
@ -74,20 +55,23 @@ interface ChatBlocksDao {
|
|||
)
|
||||
fun getConnectedChatBlocks(
|
||||
internalConversationId: String,
|
||||
threadId: Long?,
|
||||
oldestMessageId: Long,
|
||||
newestMessageId: Long
|
||||
): Flow<List<ChatBlockEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertChatBlock(chatBlock: ChatBlockEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM ChatBlocks
|
||||
WHERE internalConversationId LIKE :pattern
|
||||
SELECT MAX(newestMessageId) as max_items
|
||||
FROM ChatBlocks
|
||||
WHERE internalConversationId = :internalConversationId
|
||||
AND (threadId = :threadId OR (threadId IS NULL AND :threadId IS NULL))
|
||||
"""
|
||||
)
|
||||
fun clearChatBlocksForUser(pattern: String)
|
||||
fun getNewestMessageIdFromChatBlocks(internalConversationId: String, threadId: Long?): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertChatBlock(chatBlock: ChatBlockEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -18,16 +18,6 @@ import kotlinx.coroutines.flow.Flow
|
|||
@Dao
|
||||
@Suppress("Detekt.TooManyFunctions")
|
||||
interface ChatMessagesDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT MAX(id) as max_items
|
||||
FROM ChatMessages
|
||||
WHERE internalConversationId = :internalConversationId
|
||||
AND isTemporary = 0
|
||||
"""
|
||||
)
|
||||
fun getNewestMessageId(internalConversationId: String): Long
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT *
|
||||
|
|
@ -57,10 +47,14 @@ interface ChatMessagesDao {
|
|||
WHERE internalConversationId = :internalConversationId
|
||||
AND isTemporary = 1
|
||||
AND sendStatus != 'SENT_PENDING_ACK'
|
||||
AND (:threadId IS NULL OR threadId = :threadId)
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
"""
|
||||
)
|
||||
fun getTempUnsentMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>>
|
||||
fun getTempUnsentMessagesForConversation(
|
||||
internalConversationId: String,
|
||||
threadId: Long?
|
||||
): Flow<List<ChatMessageEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
@ -69,10 +63,15 @@ interface ChatMessagesDao {
|
|||
WHERE internalConversationId = :internalConversationId
|
||||
AND referenceId = :referenceId
|
||||
AND isTemporary = 1
|
||||
AND (:threadId IS NULL OR threadId = :threadId)
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
"""
|
||||
)
|
||||
fun getTempMessageForConversation(internalConversationId: String, referenceId: String): Flow<ChatMessageEntity?>
|
||||
fun getTempMessageForConversation(
|
||||
internalConversationId: String,
|
||||
referenceId: String,
|
||||
threadId: Long?
|
||||
): Flow<ChatMessageEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertChatMessages(chatMessages: List<ChatMessageEntity>)
|
||||
|
|
@ -84,7 +83,8 @@ interface ChatMessagesDao {
|
|||
"""
|
||||
SELECT *
|
||||
FROM ChatMessages
|
||||
WHERE internalConversationId = :internalConversationId AND id = :messageId
|
||||
WHERE internalConversationId = :internalConversationId
|
||||
AND id = :messageId
|
||||
"""
|
||||
)
|
||||
fun getChatMessageForConversation(internalConversationId: String, messageId: Long): Flow<ChatMessageEntity>
|
||||
|
|
@ -126,10 +126,15 @@ interface ChatMessagesDao {
|
|||
FROM ChatMessages
|
||||
WHERE internalConversationId = :internalConversationId AND id >= :messageId
|
||||
AND isTemporary = 0
|
||||
AND (:threadId IS NULL OR threadId = :threadId)
|
||||
ORDER BY timestamp ASC, id ASC
|
||||
"""
|
||||
)
|
||||
fun getMessagesForConversationSince(internalConversationId: String, messageId: Long): Flow<List<ChatMessageEntity>>
|
||||
fun getMessagesForConversationSince(
|
||||
internalConversationId: String,
|
||||
messageId: Long,
|
||||
threadId: Long?
|
||||
): Flow<List<ChatMessageEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
@ -138,6 +143,7 @@ interface ChatMessagesDao {
|
|||
WHERE internalConversationId = :internalConversationId
|
||||
AND isTemporary = 0
|
||||
AND id < :messageId
|
||||
AND (:threadId IS NULL OR threadId = :threadId)
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
|
@ -145,7 +151,8 @@ interface ChatMessagesDao {
|
|||
fun getMessagesForConversationBefore(
|
||||
internalConversationId: String,
|
||||
messageId: Long,
|
||||
limit: Int
|
||||
limit: Int,
|
||||
threadId: Long?
|
||||
): Flow<List<ChatMessageEntity>>
|
||||
|
||||
@Query(
|
||||
|
|
@ -155,6 +162,7 @@ interface ChatMessagesDao {
|
|||
WHERE internalConversationId = :internalConversationId
|
||||
AND isTemporary = 0
|
||||
AND id <= :messageId
|
||||
AND (:threadId IS NULL OR threadId = :threadId)
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
|
@ -162,7 +170,8 @@ interface ChatMessagesDao {
|
|||
fun getMessagesForConversationBeforeAndEqual(
|
||||
internalConversationId: String,
|
||||
messageId: Long,
|
||||
limit: Int
|
||||
limit: Int,
|
||||
threadId: Long?
|
||||
): Flow<List<ChatMessageEntity>>
|
||||
|
||||
@Query(
|
||||
|
|
@ -171,10 +180,16 @@ interface ChatMessagesDao {
|
|||
FROM ChatMessages
|
||||
WHERE internalConversationId = :internalConversationId
|
||||
AND isTemporary = 0
|
||||
AND (:threadId IS NULL OR threadId = :threadId)
|
||||
AND id BETWEEN :newestMessageId AND :oldestMessageId
|
||||
"""
|
||||
)
|
||||
fun getCountBetweenMessageIds(internalConversationId: String, oldestMessageId: Long, newestMessageId: Long): Int
|
||||
fun getCountBetweenMessageIds(
|
||||
internalConversationId: String,
|
||||
oldestMessageId: Long,
|
||||
newestMessageId: Long,
|
||||
threadId: Long?
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
@ -192,4 +207,18 @@ interface ChatMessagesDao {
|
|||
"""
|
||||
)
|
||||
fun deleteMessagesOlderThan(internalConversationId: String, messageId: Long)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM ChatMessages AS child
|
||||
INNER JOIN ChatMessages AS parent
|
||||
ON child.parent = parent.id
|
||||
WHERE child.internalConversationId = :internalConversationId
|
||||
AND child.isTemporary = 0
|
||||
AND child.messageType = 'comment'
|
||||
AND parent.threadId = :threadId
|
||||
"""
|
||||
)
|
||||
fun getNumberOfThreadReplies(internalConversationId: String, threadId: Long): Int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@
|
|||
package com.nextcloud.talk.data.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy.Companion.REPLACE
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import androidx.room.Upsert
|
||||
import com.nextcloud.talk.data.database.model.ConversationEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@Dao
|
||||
interface ConversationsDao {
|
||||
|
|
@ -22,8 +25,19 @@ interface ConversationsDao {
|
|||
@Query("SELECT * FROM Conversations where accountId = :accountId AND token = :token")
|
||||
fun getConversationForUser(accountId: Long, token: String): Flow<ConversationEntity?>
|
||||
|
||||
@Upsert
|
||||
fun upsertConversations(conversationEntities: List<ConversationEntity>)
|
||||
@Transaction
|
||||
suspend fun upsertConversations(accountId: Long, serverItems: List<ConversationEntity>) {
|
||||
serverItems.forEach { serverItem ->
|
||||
val existingItem = getConversationForUser(accountId, serverItem.token).first()
|
||||
if (existingItem != null) {
|
||||
val mergedItem = serverItem.copy()
|
||||
mergedItem.messageDraft = existingItem.messageDraft
|
||||
updateConversation(mergedItem)
|
||||
} else {
|
||||
insertConversation(serverItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes rows in the db matching the specified [conversationIds]
|
||||
|
|
@ -36,9 +50,12 @@ interface ConversationsDao {
|
|||
)
|
||||
fun deleteConversations(conversationIds: List<String>)
|
||||
|
||||
@Update
|
||||
@Update(onConflict = REPLACE)
|
||||
fun updateConversation(conversationEntity: ConversationEntity)
|
||||
|
||||
@Insert(onConflict = REPLACE)
|
||||
fun insertConversation(conversation: ConversationEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM Conversations
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ fun ChatMessageJson.asEntity(accountId: Long) =
|
|||
accountId = accountId,
|
||||
id = id,
|
||||
internalConversationId = "$accountId@$token",
|
||||
threadId = threadId,
|
||||
isThread = hasThread,
|
||||
message = message!!,
|
||||
token = token!!,
|
||||
actorType = actorType!!,
|
||||
|
|
@ -40,7 +42,9 @@ fun ChatMessageJson.asEntity(accountId: Long) =
|
|||
lastEditTimestamp = lastEditTimestamp,
|
||||
deleted = deleted,
|
||||
referenceId = referenceId,
|
||||
silent = silent
|
||||
silent = silent,
|
||||
threadTitle = threadTitle,
|
||||
threadReplies = threadReplies
|
||||
)
|
||||
|
||||
fun ChatMessageEntity.asModel() =
|
||||
|
|
@ -48,6 +52,8 @@ fun ChatMessageEntity.asModel() =
|
|||
jsonMessageId = id.toInt(),
|
||||
message = message,
|
||||
token = token,
|
||||
threadId = threadId,
|
||||
isThread = isThread,
|
||||
actorType = actorType,
|
||||
actorId = actorId,
|
||||
actorDisplayName = actorDisplayName,
|
||||
|
|
@ -70,7 +76,9 @@ fun ChatMessageEntity.asModel() =
|
|||
isTemporary = isTemporary,
|
||||
sendStatus = sendStatus,
|
||||
readStatus = ReadStatus.NONE,
|
||||
silent = silent
|
||||
silent = silent,
|
||||
threadTitle = threadTitle,
|
||||
threadReplies = threadReplies
|
||||
)
|
||||
|
||||
fun ChatMessageJson.asModel() =
|
||||
|
|
@ -78,6 +86,8 @@ fun ChatMessageJson.asModel() =
|
|||
jsonMessageId = id.toInt(),
|
||||
message = message,
|
||||
token = token,
|
||||
threadId = threadId,
|
||||
isThread = hasThread,
|
||||
actorType = actorType,
|
||||
actorId = actorId,
|
||||
actorDisplayName = actorDisplayName,
|
||||
|
|
@ -97,5 +107,7 @@ fun ChatMessageJson.asModel() =
|
|||
lastEditTimestamp = lastEditTimestamp,
|
||||
isDeleted = deleted,
|
||||
referenceId = referenceId,
|
||||
silent = silent
|
||||
silent = silent,
|
||||
threadTitle = threadTitle,
|
||||
threadReplies = threadReplies
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
|
||||
* SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
|
|
@ -63,7 +63,8 @@ fun ConversationModel.asEntity() =
|
|||
remoteToken = remoteToken,
|
||||
hasArchived = hasArchived,
|
||||
hasSensitive = hasSensitive,
|
||||
hasImportant = hasImportant
|
||||
hasImportant = hasImportant,
|
||||
messageDraft = messageDraft
|
||||
)
|
||||
|
||||
fun ConversationEntity.asModel() =
|
||||
|
|
@ -117,7 +118,8 @@ fun ConversationEntity.asModel() =
|
|||
remoteToken = remoteToken,
|
||||
hasArchived = hasArchived,
|
||||
hasSensitive = hasSensitive,
|
||||
hasImportant = hasImportant
|
||||
hasImportant = hasImportant,
|
||||
messageDraft = messageDraft
|
||||
)
|
||||
|
||||
fun Conversation.asEntity(accountId: Long) =
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ data class ChatBlockEntity(
|
|||
@ColumnInfo(name = "internalConversationId") var internalConversationId: String,
|
||||
@ColumnInfo(name = "accountId") var accountId: Long? = null,
|
||||
@ColumnInfo(name = "token") var token: String?,
|
||||
@ColumnInfo(name = "threadId") var threadId: Long? = null,
|
||||
@ColumnInfo(name = "oldestMessageId") var oldestMessageId: Long,
|
||||
@ColumnInfo(name = "newestMessageId") var newestMessageId: Long,
|
||||
@ColumnInfo(name = "hasHistory") var hasHistory: Boolean
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ data class ChatMessageEntity(
|
|||
@ColumnInfo(name = "id") var id: Long = 0,
|
||||
// accountId@roomtoken
|
||||
@ColumnInfo(name = "internalConversationId") var internalConversationId: String,
|
||||
|
||||
@ColumnInfo(name = "threadId") var threadId: Long? = null,
|
||||
@ColumnInfo(name = "isThread") var isThread: Boolean = false,
|
||||
@ColumnInfo(name = "actorDisplayName") var actorDisplayName: String,
|
||||
@ColumnInfo(name = "message") var message: String,
|
||||
|
||||
|
|
@ -67,5 +68,7 @@ data class ChatMessageEntity(
|
|||
@ColumnInfo(name = "sendStatus") var sendStatus: SendStatus? = null,
|
||||
@ColumnInfo(name = "silent") var silent: Boolean = false,
|
||||
@ColumnInfo(name = "systemMessage") var systemMessageType: ChatMessage.SystemMessageType,
|
||||
@ColumnInfo(name = "threadTitle") var threadTitle: String? = null,
|
||||
@ColumnInfo(name = "threadReplies") var threadReplies: Int? = 0,
|
||||
@ColumnInfo(name = "timestamp") var timestamp: Long = 0
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.room.ForeignKey
|
|||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.nextcloud.talk.data.user.model.UserEntity
|
||||
import com.nextcloud.talk.models.MessageDraft
|
||||
import com.nextcloud.talk.models.json.conversations.ConversationEnums
|
||||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
|
||||
|
|
@ -96,7 +97,8 @@ data class ConversationEntity(
|
|||
@ColumnInfo(name = "unreadMessages") var unreadMessages: Int = 0,
|
||||
@ColumnInfo(name = "hasArchived") var hasArchived: Boolean = false,
|
||||
@ColumnInfo(name = "hasSensitive") var hasSensitive: Boolean = false,
|
||||
@ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false
|
||||
@ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false,
|
||||
@ColumnInfo(name = "messageDraft") var messageDraft: MessageDraft? = MessageDraft()
|
||||
// missing/not needed: attendeeId
|
||||
// missing/not needed: attendeePin
|
||||
// missing/not needed: attendeePermissions
|
||||
|
|
|
|||
|
|
@ -89,6 +89,17 @@ object Migrations {
|
|||
}
|
||||
}
|
||||
|
||||
val MIGRATION_17_19 = object : Migration(17, 19) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
Log.i(
|
||||
"Migrations",
|
||||
"Migrating 17 to 19 (migration 17 to 18 had bugs in app version v22.0.0 Alpha 11 and " +
|
||||
"v22.0.0 Alpha 12)"
|
||||
)
|
||||
migrateToMessageThreads(db)
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
fun migrateToRoom(db: SupportSQLiteDatabase) {
|
||||
|
|
@ -297,7 +308,7 @@ object Migrations {
|
|||
"ADD COLUMN hasArchived INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "hasArchived already exists")
|
||||
Log.i("Migrations", "hasArchived already exists", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -308,7 +319,7 @@ object Migrations {
|
|||
"ADD COLUMN objectId TEXT NOT NULL DEFAULT '';"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "Something went wrong when adding column objectId to table Conversations")
|
||||
Log.i("Migrations", "Something went wrong when adding column objectId to table Conversations", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +330,7 @@ object Migrations {
|
|||
"ADD COLUMN hasSensitive INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "Something went wrong when adding column hasSensitive to table Conversations")
|
||||
Log.i("Migrations", "Something went wrong when adding column hasSensitive to table Conversations", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +341,43 @@ object Migrations {
|
|||
"ADD COLUMN hasImportant INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "Something went wrong when adding column hasImportant to table Conversations")
|
||||
Log.i("Migrations", "Something went wrong when adding column hasImportant to table Conversations", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateToMessageThreads(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
db.execSQL(
|
||||
"ALTER TABLE ChatBlocks " +
|
||||
"ADD COLUMN threadId INTEGER;"
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"ALTER TABLE ChatMessages " +
|
||||
"ADD COLUMN threadId INTEGER;"
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"ALTER TABLE ChatMessages " +
|
||||
"ADD COLUMN isThread INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
|
||||
// Foreign key constraints are not active during migration.
|
||||
// At least db.execSQL("PRAGMA foreign_keys=ON;") etc did not help.
|
||||
// Because of this it is not enough to just clear the Conversations table (to have cascade deletion in
|
||||
// other tables), but all related tables have to be cleared with SQL statement as well.
|
||||
|
||||
db.execSQL(
|
||||
"DELETE FROM Conversations"
|
||||
)
|
||||
db.execSQL(
|
||||
"DELETE FROM ChatMessages"
|
||||
)
|
||||
db.execSQL(
|
||||
"DELETE FROM ChatBlocks"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "Something went wrong when migrating to messageThreads", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,7 +388,7 @@ object Migrations {
|
|||
"ADD COLUMN referenceId TEXT;"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "Something went wrong when adding column referenceId to table ChatMessages")
|
||||
Log.i("Migrations", "Something went wrong when adding column referenceId to table ChatMessages", e)
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -350,7 +397,7 @@ object Migrations {
|
|||
"ADD COLUMN isTemporary INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "Something went wrong when adding column isTemporary to table ChatMessages")
|
||||
Log.i("Migrations", "Something went wrong when adding column isTemporary to table ChatMessages", e)
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -359,7 +406,7 @@ object Migrations {
|
|||
"ADD COLUMN sendingFailed INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "Something went wrong when adding column sendingFailed to table ChatMessages")
|
||||
Log.i("Migrations", "Something went wrong when adding column sendingFailed to table ChatMessages", e)
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -368,7 +415,7 @@ object Migrations {
|
|||
"ADD COLUMN silent INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
} catch (e: SQLException) {
|
||||
Log.i("Migrations", "Something went wrong when adding column silent to table ChatMessages")
|
||||
Log.i("Migrations", "Something went wrong when adding column silent to table ChatMessages", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import com.nextcloud.talk.data.storage.ArbitraryStoragesDao
|
|||
import com.nextcloud.talk.data.storage.model.ArbitraryStorageEntity
|
||||
import com.nextcloud.talk.data.user.UsersDao
|
||||
import com.nextcloud.talk.data.user.model.UserEntity
|
||||
import com.nextcloud.talk.models.MessageDraftConverter
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -47,10 +48,12 @@ import java.util.Locale
|
|||
ChatMessageEntity::class,
|
||||
ChatBlockEntity::class
|
||||
],
|
||||
version = 17,
|
||||
version = 21,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class)
|
||||
AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class),
|
||||
AutoMigration(from = 19, to = 20),
|
||||
AutoMigration(from = 20, to = 21)
|
||||
],
|
||||
exportSchema = true
|
||||
)
|
||||
|
|
@ -63,8 +66,10 @@ import java.util.Locale
|
|||
HashMapHashMapConverter::class,
|
||||
LinkedHashMapConverter::class,
|
||||
ArrayListConverter::class,
|
||||
SendStatusConverter::class
|
||||
SendStatusConverter::class,
|
||||
MessageDraftConverter::class
|
||||
)
|
||||
@Suppress("MagicNumber")
|
||||
abstract class TalkDatabase : RoomDatabase() {
|
||||
abstract fun usersDao(): UsersDao
|
||||
abstract fun conversationsDao(): ConversationsDao
|
||||
|
|
@ -85,6 +90,21 @@ abstract class TalkDatabase : RoomDatabase() {
|
|||
instance ?: build(context).also { instance = it }
|
||||
}
|
||||
|
||||
// If editing the migrations, please add a test case in MigrationsTest under androidTest/data
|
||||
val MIGRATIONS = arrayOf(
|
||||
Migrations.MIGRATION_6_8,
|
||||
Migrations.MIGRATION_7_8,
|
||||
Migrations.MIGRATION_8_9,
|
||||
Migrations.MIGRATION_10_11,
|
||||
Migrations.MIGRATION_11_12,
|
||||
Migrations.MIGRATION_12_13,
|
||||
Migrations.MIGRATION_13_14,
|
||||
Migrations.MIGRATION_14_15,
|
||||
Migrations.MIGRATION_15_16,
|
||||
Migrations.MIGRATION_17_19
|
||||
)
|
||||
|
||||
@Suppress("SpreadOperator")
|
||||
private fun build(context: Context): TalkDatabase {
|
||||
val passCharArray = context.getString(R.string.nc_talk_database_encryption_key).toCharArray()
|
||||
val passphrase: ByteArray = getBytesFromChars(passCharArray)
|
||||
|
|
@ -104,17 +124,8 @@ abstract class TalkDatabase : RoomDatabase() {
|
|||
.databaseBuilder(context.applicationContext, TalkDatabase::class.java, dbName)
|
||||
// comment out openHelperFactory to view the database entries in Android Studio for debugging
|
||||
.openHelperFactory(factory)
|
||||
.addMigrations(
|
||||
Migrations.MIGRATION_6_8,
|
||||
Migrations.MIGRATION_7_8,
|
||||
Migrations.MIGRATION_8_9,
|
||||
Migrations.MIGRATION_10_11,
|
||||
Migrations.MIGRATION_11_12,
|
||||
Migrations.MIGRATION_12_13,
|
||||
Migrations.MIGRATION_13_14,
|
||||
Migrations.MIGRATION_14_15,
|
||||
Migrations.MIGRATION_15_16
|
||||
)
|
||||
.fallbackToDestructiveMigrationFrom(true, 18)
|
||||
.addMigrations(*MIGRATIONS) // * converts migrations to vararg
|
||||
.allowMainThreadQueries()
|
||||
.addCallback(
|
||||
object : Callback() {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import android.widget.Toast
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
|
|
@ -114,7 +115,8 @@ class DiagnoseActivity : BaseActivity() {
|
|||
ColoredStatusBar()
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding(),
|
||||
.statusBarsPadding()
|
||||
.displayCutoutPadding(),
|
||||
topBar = {
|
||||
StandardAppBar(
|
||||
title = stringResource(R.string.nc_settings_diagnose_title),
|
||||
|
|
@ -126,8 +128,13 @@ class DiagnoseActivity : BaseActivity() {
|
|||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp)
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
0.dp,
|
||||
paddingValues.calculateTopPadding(),
|
||||
0.dp,
|
||||
paddingValues.calculateBottomPadding()
|
||||
)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
DiagnoseContentComposable(
|
||||
|
|
|
|||
|
|
@ -11,9 +11,18 @@
|
|||
package com.nextcloud.talk.extensions
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.util.Log
|
||||
|
|
@ -21,6 +30,7 @@ import android.widget.ImageView
|
|||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.imageLoader
|
||||
|
|
@ -42,6 +52,7 @@ import com.nextcloud.talk.utils.ApiUtils
|
|||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.nextcloud.talk.utils.TextDrawable
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
|
||||
private const val ROUNDING_PIXEL = 16f
|
||||
private const val TAG = "ImageViewExtensions"
|
||||
|
|
@ -291,18 +302,12 @@ fun ImageView.loadSystemAvatar(): io.reactivex.disposables.Disposable {
|
|||
)
|
||||
}
|
||||
|
||||
fun ImageView.loadNoteToSelfAvatar(): io.reactivex.disposables.Disposable {
|
||||
fun ImageView.loadNoteToSelfAvatar() {
|
||||
val layers = arrayOfNulls<Drawable>(2)
|
||||
layers[0] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_background)
|
||||
layers[1] = ContextCompat.getDrawable(context, R.drawable.ic_note_to_self)
|
||||
val layerDrawable = LayerDrawable(layers)
|
||||
val data: Any = layerDrawable
|
||||
|
||||
return DisposableWrapper(
|
||||
load(data) {
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
)
|
||||
setImageDrawable(CircularDrawable(layerDrawable))
|
||||
}
|
||||
|
||||
fun ImageView.loadFirstLetterAvatar(name: String): io.reactivex.disposables.Disposable {
|
||||
|
|
@ -416,3 +421,77 @@ private class DisposableWrapper(private val disposable: coil.request.Disposable)
|
|||
|
||||
override fun isDisposed(): Boolean = disposable.isDisposed
|
||||
}
|
||||
|
||||
private class CircularDrawable(private val sourceDrawable: Drawable) : Drawable() {
|
||||
|
||||
private val bitmap: Bitmap = drawableToBitmap(sourceDrawable)
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
}
|
||||
|
||||
private val rect = RectF()
|
||||
private var radius = 0f
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
super.onBoundsChange(bounds)
|
||||
rect.set(bounds)
|
||||
|
||||
radius = min(rect.width() / 2.0f, rect.height() / 2.0f)
|
||||
|
||||
val matrix = Matrix()
|
||||
val scale: Float
|
||||
var dx = 0f
|
||||
var dy = 0f
|
||||
|
||||
if (bitmap.width * rect.height() > rect.width() * bitmap.height) {
|
||||
// Taller than wide, scale to height and center horizontally
|
||||
scale = rect.height() / bitmap.height.toFloat()
|
||||
dx = (rect.width() - bitmap.width * scale) / 2.0f
|
||||
} else {
|
||||
// Wider than tall, scale to width and center vertically
|
||||
scale = rect.width() / bitmap.width.toFloat()
|
||||
dy = (rect.height() - bitmap.height * scale) / 2.0f
|
||||
}
|
||||
|
||||
matrix.setScale(scale, scale)
|
||||
matrix.postTranslate(dx.toInt().toFloat() + rect.left, dy.toInt().toFloat() + rect.top)
|
||||
paint.shader.setLocalMatrix(matrix)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
canvas.drawCircle(rect.centerX(), rect.centerY(), radius, paint)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
paint.alpha = alpha
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
paint.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"This method is no longer used in graphics optimizations",
|
||||
ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat")
|
||||
)
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
|
||||
override fun getIntrinsicWidth(): Int = sourceDrawable.intrinsicWidth
|
||||
|
||||
override fun getIntrinsicHeight(): Int = sourceDrawable.intrinsicHeight
|
||||
|
||||
companion object {
|
||||
|
||||
private fun drawableToBitmap(drawable: Drawable): Bitmap {
|
||||
if (drawable is BitmapDrawable) {
|
||||
if (drawable.bitmap != null) {
|
||||
return drawable.bitmap
|
||||
}
|
||||
}
|
||||
|
||||
val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else 1
|
||||
val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else 1
|
||||
return drawable.toBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2021-2023 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2021-2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
@ -85,6 +85,8 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ONE_TO_ONE
|
|||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SHARE_RECORDING_TO_CHAT_URL
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_OPENED_VIA_NOTIFICATION
|
||||
import com.nextcloud.talk.utils.preferences.AppPreferences
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Observer
|
||||
|
|
@ -134,8 +136,6 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
private lateinit var signatureVerification: SignatureVerification
|
||||
private var context: Context? = null
|
||||
private var conversationType: String? = "one2one"
|
||||
private var muteCall = false
|
||||
private var importantConversation = false
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
|
||||
override fun doWork(): Result {
|
||||
|
|
@ -186,7 +186,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
|
||||
private fun handleTestPushMessage() {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
intent.flags = getIntentFlags()
|
||||
showNotification(intent, null)
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
|
||||
private fun handleRemoteTalkSharePushMessage() {
|
||||
val mainActivityIntent = Intent(context, MainActivity::class.java)
|
||||
mainActivityIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
mainActivityIntent.flags = getIntentFlags()
|
||||
val bundle = Bundle()
|
||||
bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!)
|
||||
bundle.putBoolean(KEY_REMOTE_TALK_SHARE, true)
|
||||
|
|
@ -255,7 +255,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
val bundle = createBundle(conversation)
|
||||
|
||||
fullScreenIntent.putExtras(bundle)
|
||||
fullScreenIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
fullScreenIntent.flags = getIntentFlags()
|
||||
|
||||
val requestCode = System.currentTimeMillis().toInt()
|
||||
|
||||
|
|
@ -399,6 +399,10 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
val ncNotification = notificationOverall.ocs!!.notification
|
||||
if (ncNotification != null) {
|
||||
enrichPushMessageByNcNotificationData(ncNotification)
|
||||
|
||||
val threadId = parseThreadId(ncNotification.objectId)
|
||||
threadId?.let { intent.putExtra(KEY_THREAD_ID, it) }
|
||||
|
||||
showNotification(intent, ncNotification)
|
||||
}
|
||||
}
|
||||
|
|
@ -425,7 +429,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
pushMessage.timestamp = ncNotification.datetime!!.millis
|
||||
|
||||
if (ncNotification.messageRichParameters != null &&
|
||||
ncNotification.messageRichParameters!!.size > 0
|
||||
ncNotification.messageRichParameters!!.isNotEmpty()
|
||||
) {
|
||||
pushMessage.text = getParsedMessage(
|
||||
ncNotification.messageRich,
|
||||
|
|
@ -436,11 +440,11 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
}
|
||||
|
||||
val subjectRichParameters = ncNotification.subjectRichParameters
|
||||
if (subjectRichParameters != null && subjectRichParameters.size > 0) {
|
||||
if (subjectRichParameters != null && subjectRichParameters.isNotEmpty()) {
|
||||
val callHashMap = subjectRichParameters["call"]
|
||||
val userHashMap = subjectRichParameters["user"]
|
||||
val guestHashMap = subjectRichParameters["guest"]
|
||||
if (callHashMap != null && callHashMap.size > 0 && callHashMap.containsKey("name")) {
|
||||
if (callHashMap != null && callHashMap.isNotEmpty() && callHashMap.containsKey("name")) {
|
||||
if (subjectRichParameters.containsKey("reaction")) {
|
||||
pushMessage.subject = ""
|
||||
} else if (ncNotification.objectType == "chat") {
|
||||
|
|
@ -490,15 +494,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
else -> Log.e(TAG, "unknown pushMessage.type")
|
||||
}
|
||||
|
||||
// Use unique request code to make sure that a new PendingIntent gets created for each notification
|
||||
// See https://github.com/nextcloud/talk-android/issues/2111
|
||||
val requestCode = System.currentTimeMillis().toInt()
|
||||
val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(context, requestCode, intent, intentFlag)
|
||||
val pendingIntent = createUniquePendingIntent(intent)
|
||||
val uri = signatureVerification.user!!.baseUrl!!.toUri()
|
||||
val baseUrl = uri.host
|
||||
|
||||
|
|
@ -530,7 +526,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
if ((TYPE_CHAT == pushMessage.type || TYPE_REMINDER == pushMessage.type) &&
|
||||
pushMessage.notificationUser != null
|
||||
) {
|
||||
prepareChatNotification(notificationBuilder, activeStatusBarNotification, systemNotificationId)
|
||||
prepareChatNotification(notificationBuilder, activeStatusBarNotification)
|
||||
addReplyAction(notificationBuilder, systemNotificationId)
|
||||
addMarkAsReadAction(notificationBuilder, systemNotificationId)
|
||||
}
|
||||
|
|
@ -636,8 +632,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
|
||||
private fun prepareChatNotification(
|
||||
notificationBuilder: NotificationCompat.Builder,
|
||||
activeStatusBarNotification: StatusBarNotification?,
|
||||
systemNotificationId: Int
|
||||
activeStatusBarNotification: StatusBarNotification?
|
||||
) {
|
||||
val notificationUser = pushMessage.notificationUser
|
||||
val userType = notificationUser!!.type
|
||||
|
|
@ -838,6 +833,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
}
|
||||
}
|
||||
|
||||
private fun parseThreadId(objectId: String?): Long? = objectId?.split("/")?.getOrNull(2)?.toLongOrNull()
|
||||
|
||||
private fun sendNotification(notificationId: Int, notification: Notification) {
|
||||
Log.d(TAG, "show notification with id $notificationId")
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
|
|
@ -947,14 +944,14 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
)
|
||||
|
||||
if (isOngoingCallNotificationVisible) {
|
||||
val notificationBuilder: NotificationCompat.Builder?
|
||||
|
||||
notificationBuilder = NotificationCompat.Builder(
|
||||
val notificationBuilder = NotificationCompat.Builder(
|
||||
context!!,
|
||||
NotificationUtils.NotificationChannels
|
||||
.NOTIFICATION_CHANNEL_MESSAGES_V4.name
|
||||
)
|
||||
|
||||
val intent = createMainActivityIntent()
|
||||
|
||||
val notification: Notification = notificationBuilder
|
||||
.setContentTitle(
|
||||
String.format(
|
||||
|
|
@ -966,7 +963,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
.setOngoing(false)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentIntent(getIntentToOpenConversation())
|
||||
.setContentIntent(createUniquePendingIntent(intent))
|
||||
.build()
|
||||
|
||||
val notificationId: Int = SystemClock.uptimeMillis().toInt()
|
||||
|
|
@ -989,31 +986,29 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor
|
|||
|
||||
private fun createMainActivityIntent(): Intent {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
intent.flags = getIntentFlags()
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
|
||||
bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!)
|
||||
bundle.putBoolean(KEY_OPENED_VIA_NOTIFICATION, true)
|
||||
intent.putExtras(bundle)
|
||||
return intent
|
||||
}
|
||||
|
||||
private fun getIntentToOpenConversation(): PendingIntent? {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_ROOM_TOKEN, pushMessage.id)
|
||||
bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!)
|
||||
intent.putExtras(bundle)
|
||||
|
||||
private fun createUniquePendingIntent(intent: Intent): PendingIntent? {
|
||||
// Use unique request code to make sure that a new PendingIntent gets created for each notification
|
||||
// See https://github.com/nextcloud/talk-android/issues/2111
|
||||
val requestCode = System.currentTimeMillis().toInt()
|
||||
val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
} else {
|
||||
0
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
return PendingIntent.getActivity(context, requestCode, intent, intentFlag)
|
||||
}
|
||||
|
||||
private fun getIntentFlags(): Int = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
|
||||
companion object {
|
||||
val TAG = NotificationWorker::class.simpleName
|
||||
private const val TYPE_CHAT = "chat"
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ class MessageSearchActivity : BaseActivity() {
|
|||
if (state is MessageSearchViewModel.FinishedState) {
|
||||
val resultIntent = Intent().apply {
|
||||
putExtra(RESULT_KEY_MESSAGE_ID, state.selectedMessageId)
|
||||
putExtra(RESULT_KEY_THREAD_ID, state.selectedThreadId)
|
||||
}
|
||||
setResult(Activity.RESULT_OK, resultIntent)
|
||||
finish()
|
||||
|
|
@ -244,5 +245,6 @@ class MessageSearchActivity : BaseActivity() {
|
|||
|
||||
companion object {
|
||||
const val RESULT_KEY_MESSAGE_ID = "MessageSearchActivity.result.message"
|
||||
const val RESULT_KEY_THREAD_ID = "MessageSearchActivity.result.thread"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class MessageSearchViewModel @Inject constructor(private val unifiedSearchReposi
|
|||
object EmptyState : ViewState()
|
||||
object ErrorState : ViewState()
|
||||
class LoadedState(val results: List<SearchMessageEntry>, val hasMore: Boolean) : ViewState()
|
||||
class FinishedState(val selectedMessageId: String) : ViewState()
|
||||
class FinishedState(val selectedMessageId: String, val selectedThreadId: String?) : ViewState()
|
||||
|
||||
private lateinit var messageSearchHelper: MessageSearchHelper
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ class MessageSearchViewModel @Inject constructor(private val unifiedSearchReposi
|
|||
}
|
||||
|
||||
fun selectMessage(messageEntry: SearchMessageEntry) {
|
||||
_state.value = FinishedState(messageEntry.messageId!!)
|
||||
_state.value = FinishedState(messageEntry.messageId!!, messageEntry.threadId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
57
app/src/main/java/com/nextcloud/talk/models/MessageDraft.kt
Normal file
57
app/src/main/java/com/nextcloud/talk/models/MessageDraft.kt
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.TypeConverter
|
||||
import com.bluelinelabs.logansquare.LoganSquare
|
||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@JsonObject
|
||||
@Serializable
|
||||
data class MessageDraft(
|
||||
@JsonField(name = ["messageText"])
|
||||
var messageText: String = "",
|
||||
@JsonField(name = ["messageCursor"])
|
||||
var messageCursor: Int = 0,
|
||||
@JsonField(name = ["quotedJsonId"])
|
||||
var quotedJsonId: Int? = null,
|
||||
@JsonField(name = ["quotedDisplayName"])
|
||||
var quotedDisplayName: String? = null,
|
||||
@JsonField(name = ["quotedMessageText"])
|
||||
var quotedMessageText: String? = null,
|
||||
@JsonField(name = ["quoteImageUrl"])
|
||||
var quotedImageUrl: String? = null,
|
||||
@JsonField(name = ["threadTitle"])
|
||||
var threadTitle: String? = null
|
||||
) : Parcelable {
|
||||
constructor() : this("", 0, null, null, null, null, null)
|
||||
}
|
||||
|
||||
class MessageDraftConverter {
|
||||
|
||||
@TypeConverter
|
||||
fun fromMessageDraftToString(messageDraft: MessageDraft?): String =
|
||||
if (messageDraft == null) {
|
||||
""
|
||||
} else {
|
||||
LoganSquare.serialize(messageDraft)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringToMessageDraft(value: String): MessageDraft? =
|
||||
if (value.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
LoganSquare.parse(value, MessageDraft::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,13 @@
|
|||
package com.nextcloud.talk.models.domain
|
||||
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.models.MessageDraft
|
||||
import com.nextcloud.talk.models.json.chat.ChatMessageJson
|
||||
import com.nextcloud.talk.models.json.conversations.Conversation
|
||||
import com.nextcloud.talk.models.json.conversations.ConversationEnums
|
||||
import com.nextcloud.talk.models.json.participants.Participant
|
||||
|
||||
class ConversationModel(
|
||||
data class ConversationModel(
|
||||
var internalId: String,
|
||||
var accountId: Long,
|
||||
var token: String,
|
||||
|
|
@ -65,7 +66,8 @@ class ConversationModel(
|
|||
var hasImportant: Boolean = false,
|
||||
|
||||
// attributes that don't come from API. This should be changed?!
|
||||
var password: String? = null
|
||||
var password: String? = null,
|
||||
var messageDraft: MessageDraft? = MessageDraft()
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ data class SearchMessageEntry(
|
|||
val title: String,
|
||||
val messageExcerpt: String,
|
||||
val conversationToken: String,
|
||||
val threadId: String?,
|
||||
val messageId: String?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ import kotlinx.parcelize.Parcelize
|
|||
data class ChatMessageJson(
|
||||
@JsonField(name = ["id"]) var id: Long = 0,
|
||||
@JsonField(name = ["token"]) var token: String? = null,
|
||||
@JsonField(name = ["threadId"]) var threadId: Long? = null,
|
||||
|
||||
// Be aware that variables with "is" at the beginning will lead to the error:
|
||||
// "@JsonField annotation can only be used on private fields if both getter and setter are present."
|
||||
// Instead, name it with "has" at the beginning: isThread -> hasThread
|
||||
@JsonField(name = ["isThread"]) var hasThread: Boolean = false,
|
||||
@JsonField(name = ["actorType"]) var actorType: String? = null,
|
||||
@JsonField(name = ["actorId"]) var actorId: String? = null,
|
||||
@JsonField(name = ["actorDisplayName"]) var actorDisplayName: String? = null,
|
||||
|
|
@ -44,5 +50,7 @@ data class ChatMessageJson(
|
|||
@JsonField(name = ["lastEditTimestamp"]) var lastEditTimestamp: Long? = 0,
|
||||
@JsonField(name = ["deleted"]) var deleted: Boolean = false,
|
||||
@JsonField(name = ["referenceId"]) var referenceId: String? = null,
|
||||
@JsonField(name = ["silent"]) var silent: Boolean = false
|
||||
@JsonField(name = ["silent"]) var silent: Boolean = false,
|
||||
@JsonField(name = ["threadTitle"]) var threadTitle: String? = null,
|
||||
@JsonField(name = ["threadReplies"]) var threadReplies: Int? = 0
|
||||
) : Parcelable
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
|
||||
* SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.RECORDIN
|
|||
import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.USER_ADDED
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.USER_REMOVED
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.PHONE_ADDED
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage.SystemMessageType.THREAD_CREATED
|
||||
|
||||
/*
|
||||
* see https://nextcloud-talk.readthedocs.io/en/latest/chat/#system-messages
|
||||
|
|
@ -143,6 +144,7 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter<ChatMessage.Syst
|
|||
"federated_user_added" -> FEDERATED_USER_ADDED
|
||||
"federated_user_removed" -> FEDERATED_USER_REMOVED
|
||||
"phone_added" -> PHONE_ADDED
|
||||
"thread_created" -> THREAD_CREATED
|
||||
else -> DUMMY
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +214,7 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter<ChatMessage.Syst
|
|||
FEDERATED_USER_ADDED -> "federated_user_added"
|
||||
FEDERATED_USER_REMOVED -> "federated_user_removed"
|
||||
PHONE_ADDED -> "phone_added"
|
||||
THREAD_CREATED -> "thread_created"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ enum class StatusType(val string: String) {
|
|||
OFFLINE("offline"),
|
||||
DND("dnd"),
|
||||
AWAY("away"),
|
||||
BUSY("busy"),
|
||||
INVISIBLE("invisible")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.models.json.threads
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@JsonObject
|
||||
data class Thread(
|
||||
@JsonField(name = ["id"])
|
||||
var id: Int = 0,
|
||||
|
||||
@JsonField(name = ["roomToken"])
|
||||
var roomToken: String = "",
|
||||
|
||||
@JsonField(name = ["title"])
|
||||
var title: String = "",
|
||||
|
||||
@JsonField(name = ["lastMessageId"])
|
||||
var lastMessageId: Int = 0,
|
||||
|
||||
@JsonField(name = ["lastActivity"])
|
||||
var lastActivity: Int = 0,
|
||||
|
||||
@JsonField(name = ["numReplies"])
|
||||
var numReplies: Int = 0
|
||||
) : Parcelable
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.models.json.threads
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@JsonObject
|
||||
data class ThreadAttendee(
|
||||
@JsonField(name = ["notificationLevel"])
|
||||
var notificationLevel: Int = 0
|
||||
) : Parcelable
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.models.json.threads
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||
import com.nextcloud.talk.models.json.chat.ChatMessageJson
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@JsonObject
|
||||
data class ThreadInfo(
|
||||
@JsonField(name = ["thread"])
|
||||
var thread: Thread? = null,
|
||||
|
||||
@JsonField(name = ["attendee"])
|
||||
var attendee: ThreadAttendee? = null,
|
||||
|
||||
@JsonField(name = ["first"])
|
||||
var first: ChatMessageJson? = null,
|
||||
|
||||
@JsonField(name = ["last"])
|
||||
var last: ChatMessageJson? = null
|
||||
) : Parcelable
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.models.json.threads
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||
import com.nextcloud.talk.models.json.generic.GenericMeta
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@JsonObject
|
||||
data class ThreadOCS(
|
||||
@JsonField(name = ["meta"])
|
||||
var meta: GenericMeta?,
|
||||
@JsonField(name = ["data"])
|
||||
var data: ThreadInfo? = null
|
||||
) : Parcelable {
|
||||
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
|
||||
constructor() : this(null, null)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.models.json.threads
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@JsonObject
|
||||
data class ThreadOverall(
|
||||
@JsonField(name = ["ocs"])
|
||||
var ocs: ThreadOCS? = null
|
||||
) : Parcelable {
|
||||
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
|
||||
constructor() : this(null)
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.models.json.threads
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||
import com.nextcloud.talk.models.json.generic.GenericMeta
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@JsonObject
|
||||
data class ThreadsOCS(
|
||||
@JsonField(name = ["meta"])
|
||||
var meta: GenericMeta?,
|
||||
@JsonField(name = ["data"])
|
||||
var data: List<ThreadInfo>? = null
|
||||
) : Parcelable {
|
||||
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
|
||||
constructor() : this(null, null)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.models.json.threads
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bluelinelabs.logansquare.annotation.JsonField
|
||||
import com.bluelinelabs.logansquare.annotation.JsonObject
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@JsonObject
|
||||
data class ThreadsOverall(
|
||||
@JsonField(name = ["ocs"])
|
||||
var ocs: ThreadsOCS? = null
|
||||
) : Parcelable {
|
||||
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
|
||||
constructor() : this(null)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ package com.nextcloud.talk.remotefilebrowser.activities
|
|||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
|
|
@ -18,6 +19,8 @@ import android.view.View
|
|||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
|
|
@ -83,6 +86,7 @@ class RemoteFileBrowserActivity :
|
|||
viewThemeUtils.material.colorMaterialTextButton(binding.pathNavigationBackButton)
|
||||
viewThemeUtils.platform.themeStatusBar(this)
|
||||
setContentView(binding.root)
|
||||
initSystemBars()
|
||||
|
||||
DisplayUtils.applyColorToNavigationBar(
|
||||
this.window,
|
||||
|
|
@ -242,6 +246,21 @@ class RemoteFileBrowserActivity :
|
|||
binding.recyclerView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
fun initSystemBars() {
|
||||
val decorView = window.decorView
|
||||
decorView.setOnApplyWindowInsetsListener { view, insets ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
val systemBars = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or
|
||||
WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
|
||||
}
|
||||
insets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(decorView)
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
refreshCurrentPath()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,10 @@ class ReactionsRepositoryImpl @Inject constructor(
|
|||
val internalConversationId = "$accountId@$roomToken"
|
||||
val emoji = model.emoji
|
||||
|
||||
val message = dao.getChatMessageForConversation(internalConversationId, id).first()
|
||||
val message = dao.getChatMessageForConversation(
|
||||
internalConversationId,
|
||||
id
|
||||
).first()
|
||||
|
||||
// 2. Check state of entity, create params as needed
|
||||
if (message.reactions == null) {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ class UnifiedSearchRepositoryImpl(private val api: NcApi, private val userProvid
|
|||
|
||||
private const val ATTRIBUTE_CONVERSATION = "conversation"
|
||||
private const val ATTRIBUTE_MESSAGE_ID = "messageId"
|
||||
private const val ATTRIBUTE_THREAD_ID = "threadId"
|
||||
|
||||
private fun mapToMessageResults(
|
||||
data: UnifiedSearchResponseData,
|
||||
|
|
@ -81,13 +82,15 @@ class UnifiedSearchRepositoryImpl(private val api: NcApi, private val userProvid
|
|||
private fun mapToMessage(unifiedSearchEntry: UnifiedSearchEntry, searchTerm: String): SearchMessageEntry {
|
||||
val conversation = unifiedSearchEntry.attributes?.get(ATTRIBUTE_CONVERSATION)!!
|
||||
val messageId = unifiedSearchEntry.attributes?.get(ATTRIBUTE_MESSAGE_ID)
|
||||
val threadId = unifiedSearchEntry.attributes?.get(ATTRIBUTE_THREAD_ID)
|
||||
return SearchMessageEntry(
|
||||
searchTerm,
|
||||
unifiedSearchEntry.thumbnailUrl,
|
||||
unifiedSearchEntry.title!!,
|
||||
unifiedSearchEntry.subline!!,
|
||||
conversation,
|
||||
messageId
|
||||
searchTerm = searchTerm,
|
||||
thumbnailURL = unifiedSearchEntry.thumbnailUrl,
|
||||
title = unifiedSearchEntry.title!!,
|
||||
messageExcerpt = unifiedSearchEntry.subline!!,
|
||||
conversationToken = conversation,
|
||||
threadId = threadId,
|
||||
messageId = messageId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1410,10 +1410,6 @@ class SettingsActivity :
|
|||
json.toRequestBody("application/json".toMediaTypeOrNull())
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
loadCapabilitiesAndUpdateSettings()
|
||||
Log.i(TAG, "typing status set")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
appPreferences.typingStatus = !newBoolean
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.threadsoverview
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import autodagger.AutoInjector
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.activities.BaseActivity
|
||||
import com.nextcloud.talk.api.NcApi
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.ChatActivity.Companion.TAG
|
||||
import com.nextcloud.talk.components.ColoredStatusBar
|
||||
import com.nextcloud.talk.components.StandardAppBar
|
||||
import com.nextcloud.talk.data.database.mappers.asModel
|
||||
import com.nextcloud.talk.models.json.threads.ThreadInfo
|
||||
import com.nextcloud.talk.threadsoverview.components.ThreadRow
|
||||
import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID
|
||||
import javax.inject.Inject
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class ThreadsOverviewActivity : BaseActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelProvider.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var ncApi: NcApi
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
lateinit var threadsOverviewViewModel: ThreadsOverviewViewModel
|
||||
|
||||
var threadsSourceUrl: String = ""
|
||||
var appbarTitle: String = ""
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
|
||||
threadsOverviewViewModel = ViewModelProvider(
|
||||
this,
|
||||
viewModelFactory
|
||||
)[ThreadsOverviewViewModel::class.java]
|
||||
|
||||
val colorScheme = viewThemeUtils.getColorScheme(this)
|
||||
|
||||
val extras: Bundle? = intent.extras
|
||||
threadsSourceUrl = extras?.getString(KEY_THREADS_SOURCE_URL).orEmpty()
|
||||
appbarTitle = extras?.getString(KEY_APPBAR_TITLE).orEmpty()
|
||||
|
||||
setContent {
|
||||
val backgroundColor = colorResource(id = R.color.bg_default)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme
|
||||
) {
|
||||
ColoredStatusBar()
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding(),
|
||||
topBar = {
|
||||
StandardAppBar(
|
||||
title = appbarTitle,
|
||||
null
|
||||
)
|
||||
},
|
||||
content = { paddingValues ->
|
||||
val uiState by threadsOverviewViewModel.threadsListState.collectAsState()
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp)
|
||||
.background(backgroundColor)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
ThreadsOverviewScreen(
|
||||
uiState,
|
||||
onThreadClick = { roomToken, threadId ->
|
||||
navigateToChatActivity(roomToken, threadId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToChatActivity(roomToken: String, threadId: Int) {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_ROOM_TOKEN, roomToken)
|
||||
bundle.putLong(KEY_THREAD_ID, threadId.toLong())
|
||||
val chatIntent = Intent(context, ChatActivity::class.java)
|
||||
chatIntent.putExtras(bundle)
|
||||
startActivity(chatIntent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
supportActionBar?.show()
|
||||
threadsOverviewViewModel.init(threadsSourceUrl)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = ThreadsOverviewActivity::class.java.simpleName
|
||||
val KEY_APPBAR_TITLE = "KEY_APPBAR_TITLE"
|
||||
val KEY_THREADS_SOURCE_URL = "KEY_THREADS_SOURCE_URL"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThreadsOverviewScreen(
|
||||
uiState: ThreadsOverviewViewModel.ThreadsListUiState,
|
||||
onThreadClick: (roomToken: String, threadId: Int) -> Unit
|
||||
) {
|
||||
when (val state = uiState) {
|
||||
is ThreadsOverviewViewModel.ThreadsListUiState.None -> {
|
||||
LoadingIndicator()
|
||||
}
|
||||
|
||||
is ThreadsOverviewViewModel.ThreadsListUiState.Success -> {
|
||||
ThreadsList(
|
||||
threads = state.threadsList!!,
|
||||
onThreadClick = onThreadClick
|
||||
)
|
||||
}
|
||||
|
||||
is ThreadsOverviewViewModel.ThreadsListUiState.Error -> {
|
||||
Log.e(TAG, "Error when retrieving threads", uiState.exception)
|
||||
ErrorView(message = stringResource(R.string.nc_common_error_sorry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThreadsList(threads: List<ThreadInfo>, onThreadClick: (roomToken: String, threadId: Int) -> Unit) {
|
||||
val space = ' '
|
||||
if (threads.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.threads_list_empty))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
items = threads,
|
||||
key = { threadInfo -> threadInfo.thread!!.id }
|
||||
) { threadInfo ->
|
||||
val messageJson = threadInfo.last ?: threadInfo.first
|
||||
val messageModel = messageJson?.asModel()
|
||||
|
||||
ThreadRow(
|
||||
roomToken = threadInfo.thread!!.roomToken,
|
||||
threadId = threadInfo.thread!!.id,
|
||||
title = threadInfo.thread?.title.orEmpty(),
|
||||
numReplies = pluralStringResource(
|
||||
R.plurals.thread_replies,
|
||||
threadInfo.thread?.numReplies ?: 0,
|
||||
threadInfo.thread?.numReplies ?: 0
|
||||
),
|
||||
secondLineTitle = messageModel?.actorDisplayName?.substringBefore(space)?.let { "$it:" }.orEmpty(),
|
||||
secondLine = messageModel?.text.orEmpty(),
|
||||
date = getLastActivityDate(threadInfo), // replace with value from api when available
|
||||
onClick = onThreadClick
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun getLastActivityDate(threadInfo: ThreadInfo): String {
|
||||
val oneSecond = 1000L
|
||||
|
||||
val lastActivityTimestamp = threadInfo.thread?.lastActivity ?: 0
|
||||
|
||||
val lastActivityDate = DateUtils.getRelativeTimeSpanString(
|
||||
lastActivityTimestamp.times(oneSecond),
|
||||
System.currentTimeMillis(),
|
||||
0,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
).toString()
|
||||
return lastActivityDate
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingIndicator() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorView(message: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = message, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue