mirror of
https://github.com/nextcloud/talk-android
synced 2025-06-21 12:39:58 +01:00
Improving search capability
- Updated api with getContextForChatMessages - Added ContextChatCompose for viewing the context of messages - Added ComposeChatAdapter, a reimplementation of chat adapter - Helper functions - Added new date header - Added a better Shimmer Signed-off-by: rapterjet2004 <juliuslinus1@gmail.com>
This commit is contained in:
parent
5aab7ac9bb
commit
ce589b3cae
@ -68,6 +68,7 @@
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
|
@ -8,6 +8,7 @@
|
||||
package com.nextcloud.talk.api
|
||||
|
||||
import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall
|
||||
import com.nextcloud.talk.models.json.chat.ChatOverall
|
||||
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
|
||||
import com.nextcloud.talk.models.json.conversations.RoomOverall
|
||||
import com.nextcloud.talk.models.json.generic.GenericOverall
|
||||
@ -227,4 +228,11 @@ interface NcApiCoroutines {
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String
|
||||
): UserAbsenceOverall
|
||||
|
||||
@GET
|
||||
suspend fun getContextOfChatMessage(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Url url: String,
|
||||
@Query("limit") limit: Int
|
||||
): ChatOverall
|
||||
}
|
||||
|
@ -9,10 +9,12 @@ package com.nextcloud.talk.chat.data.network
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.models.domain.ConversationModel
|
||||
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.conversations.RoomsOverall
|
||||
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.userAbsence.UserAbsenceOverall
|
||||
import io.reactivex.Observable
|
||||
@ -66,4 +68,12 @@ interface ChatNetworkDataSource {
|
||||
fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int): Observable<GenericOverall>
|
||||
suspend fun editChatMessage(credentials: String, url: String, text: String): ChatOverallSingleMessage
|
||||
suspend fun getOutOfOfficeStatusForUser(credentials: String, baseUrl: String, userId: String): UserAbsenceOverall
|
||||
suspend fun getContextForChatMessage(
|
||||
credentials: String,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
messageId: String,
|
||||
limit: Int
|
||||
): List<ChatMessageJson>
|
||||
suspend fun getOpenGraph(credentials: String, baseUrl: String, extractedLinkToPreview: String): Reference?
|
||||
}
|
||||
|
@ -11,10 +11,12 @@ import com.nextcloud.talk.api.NcApiCoroutines
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.models.domain.ConversationModel
|
||||
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.conversations.RoomsOverall
|
||||
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.userAbsence.UserAbsenceOverall
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
@ -195,4 +197,28 @@ class RetrofitChatNetwork(
|
||||
ApiUtils.getUrlForOutOfOffice(baseUrl, userId)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getContextForChatMessage(
|
||||
credentials: String,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
messageId: String,
|
||||
limit: Int
|
||||
): List<ChatMessageJson> {
|
||||
val url = ApiUtils.getUrlForChatMessageContext(baseUrl, token, messageId)
|
||||
return ncApiCoroutines.getContextOfChatMessage(credentials, url, limit).ocs?.data ?: listOf()
|
||||
}
|
||||
|
||||
override suspend fun getOpenGraph(
|
||||
credentials: String,
|
||||
baseUrl: String,
|
||||
extractedLinkToPreview: String
|
||||
): Reference? {
|
||||
val openGraphLink = ApiUtils.getUrlForOpenGraph(baseUrl)
|
||||
return ncApi.getOpenGraph(
|
||||
credentials,
|
||||
openGraphLink,
|
||||
extractedLinkToPreview
|
||||
).blockingFirst().ocs?.data?.references?.entries?.iterator()?.next()?.value
|
||||
}
|
||||
}
|
||||
|
@ -31,10 +31,12 @@ 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.conversations.RoomsOverall
|
||||
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.userAbsence.UserAbsenceData
|
||||
import com.nextcloud.talk.repositories.reactions.ReactionsRepository
|
||||
@ -146,6 +148,18 @@ class ChatViewModel @Inject constructor(
|
||||
val outOfOfficeViewState: LiveData<OutOfOfficeUIState>
|
||||
get() = _outOfOfficeViewState
|
||||
|
||||
private val _voiceMessagePlaybackSpeedPreferences: MutableLiveData<Map<String, PlaybackSpeed>> = MutableLiveData()
|
||||
val voiceMessagePlaybackSpeedPreferences: LiveData<Map<String, PlaybackSpeed>>
|
||||
get() = _voiceMessagePlaybackSpeedPreferences
|
||||
|
||||
private val _getContextChatMessages: MutableLiveData<List<ChatMessageJson>> = MutableLiveData()
|
||||
val getContextChatMessages: LiveData<List<ChatMessageJson>>
|
||||
get() = _getContextChatMessages
|
||||
|
||||
val getOpenGraph: LiveData<Reference>
|
||||
get() = _getOpenGraph
|
||||
private val _getOpenGraph: MutableLiveData<Reference> = MutableLiveData()
|
||||
|
||||
val getMessageFlow = chatRepository.messageFlow
|
||||
.onEach {
|
||||
_chatMessageViewState.value = if (_chatMessageViewState.value == ChatMessageInitialState) {
|
||||
@ -838,6 +852,26 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = ChatViewModel::class.simpleName
|
||||
const val JOIN_ROOM_RETRY_COUNT: Long = 3
|
||||
|
@ -10,7 +10,9 @@ package com.nextcloud.talk.contacts
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import coil.transform.CircleCropTransformation
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
|
||||
@Composable
|
||||
fun loadImage(imageUri: String?, context: Context, errorPlaceholderImage: Int): ImageRequest {
|
||||
@ -22,3 +24,15 @@ fun loadImage(imageUri: String?, context: Context, errorPlaceholderImage: Int):
|
||||
.build()
|
||||
return imageRequest
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun load(imageUri: String?, context: Context, errorPlaceholderImage: Int): ImageRequest {
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(imageUri)
|
||||
.size(Size.ORIGINAL)
|
||||
.transformations(RoundedCornersTransformation())
|
||||
.error(errorPlaceholderImage)
|
||||
.placeholder(errorPlaceholderImage)
|
||||
.build()
|
||||
return imageRequest
|
||||
}
|
||||
|
@ -39,7 +39,9 @@ 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.os.bundleOf
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.MenuItemCompat
|
||||
@ -109,6 +111,7 @@ import com.nextcloud.talk.settings.SettingsActivity
|
||||
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.ui.dialog.ConversationsListBottomDialog
|
||||
import com.nextcloud.talk.ui.dialog.FilterConversationFragment
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
@ -1374,9 +1377,25 @@ class ConversationsListActivity :
|
||||
when (item.itemViewType) {
|
||||
MessageResultItem.VIEW_TYPE -> {
|
||||
val messageItem: MessageResultItem = item as MessageResultItem
|
||||
val conversationToken = messageItem.messageEntry.conversationToken
|
||||
selectedMessageId = messageItem.messageEntry.messageId
|
||||
showConversationByToken(conversationToken)
|
||||
val token = messageItem.messageEntry.conversationToken
|
||||
val conversationName = (
|
||||
conversationItems.first {
|
||||
(it is ConversationItem) && it.model.token == token
|
||||
} as ConversationItem
|
||||
).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, messageItem.messageEntry.messageId)
|
||||
bundle.putString(BundleKeys.KEY_CONVERSATION_NAME, conversationName)
|
||||
ContextChatCompose(bundle).GetDialogView(shouldDismiss, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadMoreResultsItem.VIEW_TYPE -> {
|
||||
|
@ -10,6 +10,7 @@ package com.nextcloud.talk.jobs;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.nextcloud.talk.R;
|
||||
import com.nextcloud.talk.api.NcApi;
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication;
|
||||
@ -73,7 +74,7 @@ public class AccountRemovalWorker extends Worker {
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
|
||||
Objects.requireNonNull(NextcloudTalkApplication.Companion.getSharedApplication()).getComponentApplication().inject(this);
|
||||
|
||||
List<User> users = userManager.getUsersScheduledForDeletion().blockingGet();
|
||||
for (User user : users) {
|
||||
@ -91,7 +92,7 @@ public class AccountRemovalWorker extends Worker {
|
||||
|
||||
ncApi.unregisterDeviceForNotificationsWithNextcloud(
|
||||
ApiUtils.getCredentials(user.getUsername(), user.getToken()),
|
||||
ApiUtils.getUrlNextcloudPush(user.getBaseUrl()))
|
||||
ApiUtils.getUrlNextcloudPush(Objects.requireNonNull(user.getBaseUrl())))
|
||||
.blockingSubscribe(new Observer<GenericOverall>() {
|
||||
@Override
|
||||
public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) {
|
||||
@ -177,10 +178,11 @@ public class AccountRemovalWorker extends Worker {
|
||||
|
||||
private void initiateUserDeletion(User user) {
|
||||
if (user.getId() != null) {
|
||||
WebSocketConnectionHelper.deleteExternalSignalingInstanceForUserEntity(user.getId());
|
||||
long id = user.getId();
|
||||
WebSocketConnectionHelper.deleteExternalSignalingInstanceForUserEntity(id);
|
||||
|
||||
try {
|
||||
arbitraryStorageManager.deleteAllEntriesForAccountIdentifier(user.getId());
|
||||
arbitraryStorageManager.deleteAllEntriesForAccountIdentifier(id);
|
||||
deleteUser(user);
|
||||
} catch (Throwable e) {
|
||||
Log.e(TAG, "error while trying to delete All Entries For Account Identifier", e);
|
||||
@ -193,7 +195,9 @@ public class AccountRemovalWorker extends Worker {
|
||||
String username = user.getUsername();
|
||||
try {
|
||||
userManager.deleteUser(user.getId());
|
||||
if (username != null) {
|
||||
Log.d(TAG, "deleted user: " + username);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Log.e(TAG, "error while trying to delete user", e);
|
||||
}
|
||||
|
@ -119,8 +119,13 @@ public class CapabilitiesWorker extends Worker {
|
||||
.build()
|
||||
.create(NcApi.class);
|
||||
|
||||
ncApi.getCapabilities(ApiUtils.getCredentials(user.getUsername(), user.getToken()),
|
||||
ApiUtils.getUrlForCapabilities(user.getBaseUrl()))
|
||||
String url = "";
|
||||
String baseurl = user.getBaseUrl();
|
||||
if (baseurl != null) {
|
||||
url = ApiUtils.getUrlForCapabilities(baseurl);
|
||||
}
|
||||
|
||||
ncApi.getCapabilities(ApiUtils.getCredentials(user.getUsername(), user.getToken()), url)
|
||||
.retry(3)
|
||||
.blockingSubscribe(new Observer<CapabilitiesOverall>() {
|
||||
@Override
|
||||
|
901
app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt
Normal file
901
app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt
Normal file
@ -0,0 +1,901 @@
|
||||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.View.TEXT_ALIGNMENT_VIEW_START
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.animation.animateColor
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import autodagger.AutoInjector
|
||||
import coil.compose.AsyncImage
|
||||
import com.elyeproj.loaderviewlibrary.LoaderImageView
|
||||
import com.elyeproj.loaderviewlibrary.LoaderTextView
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
|
||||
import com.nextcloud.talk.contacts.ContactsViewModel
|
||||
import com.nextcloud.talk.contacts.load
|
||||
import com.nextcloud.talk.contacts.loadImage
|
||||
import com.nextcloud.talk.data.database.mappers.asModel
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.models.json.chat.ChatMessageJson
|
||||
import com.nextcloud.talk.models.json.chat.ReadStatus
|
||||
import com.nextcloud.talk.models.json.opengraph.Reference
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.DateUtils
|
||||
import com.nextcloud.talk.utils.message.MessageUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("FunctionNaming", "TooManyFunctions", "LongMethod", "StaticFieldLeak")
|
||||
class ComposeChatAdapter(
|
||||
private var messagesJson: List<ChatMessageJson>? = null,
|
||||
private var messageId: String? = null
|
||||
) {
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
inner class ComposeChatAdapterViewModel : ViewModel() {
|
||||
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var messageUtils: MessageUtils
|
||||
|
||||
@Inject
|
||||
lateinit var contactsViewModel: ContactsViewModel
|
||||
|
||||
@Inject
|
||||
lateinit var chatViewModel: ChatViewModel
|
||||
|
||||
@Inject
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
val items = mutableStateListOf<ChatMessage>()
|
||||
|
||||
init {
|
||||
sharedApplication!!.componentApplication.inject(this)
|
||||
}
|
||||
|
||||
val currentUser: User = userManager.currentUser.blockingGet()
|
||||
val colorScheme = viewThemeUtils.getColorScheme(context)
|
||||
val highEmphasisColorInt = context.resources.getColor(R.color.high_emphasis_text, null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG: String = ComposeChatAdapter::class.java.simpleName
|
||||
private val REGULAR_TEXT_SIZE = 16.sp
|
||||
private val TIME_TEXT_SIZE = 12.sp
|
||||
private val AUTHOR_TEXT_SIZE = 12.sp
|
||||
private const val LONG_1000 = 1000
|
||||
private const val SCROLL_DELAY = 20L
|
||||
private const val QUOTE_SHAPE_OFFSET = 6
|
||||
private const val LINE_SPACING = 1.2f
|
||||
private const val CAPTION_WEIGHT = 0.8f
|
||||
private const val DEFAULT_WAVE_SIZE = 50
|
||||
private const val MAP_ZOOM = 15.0
|
||||
private const val INT_8 = 8
|
||||
private const val INT_128 = 128
|
||||
private const val ANIMATION_DURATION = 2500L
|
||||
private const val ANIMATED_BLINK = 500
|
||||
private const val FLOAT_06 = 0.6f
|
||||
private const val HALF_OPACITY = 127
|
||||
}
|
||||
|
||||
private var incomingShape: RoundedCornerShape = RoundedCornerShape(2.dp, 20.dp, 20.dp, 20.dp)
|
||||
private var outgoingShape: RoundedCornerShape = RoundedCornerShape(20.dp, 2.dp, 20.dp, 20.dp)
|
||||
private val viewModel = ComposeChatAdapterViewModel()
|
||||
|
||||
fun addMessages(messages: MutableList<ChatMessage>, append: Boolean) {
|
||||
if (messages.isEmpty()) return
|
||||
|
||||
val processedMessages = messages.toMutableList()
|
||||
if (viewModel.items.isNotEmpty()) {
|
||||
if (append) {
|
||||
processedMessages.add(viewModel.items.first())
|
||||
} else {
|
||||
processedMessages.add(viewModel.items.last())
|
||||
}
|
||||
}
|
||||
|
||||
if (append) viewModel.items.addAll(processedMessages) else viewModel.items.addAll(0, processedMessages)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GetView() {
|
||||
val listState = rememberLazyListState()
|
||||
val isBlinkingState = remember { mutableStateOf(true) }
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
state = listState,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
stickyHeader {
|
||||
if (viewModel.items.size == 0) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
ShimmerGroup()
|
||||
}
|
||||
} else {
|
||||
val timestamp = viewModel.items[listState.firstVisibleItemIndex].timestamp
|
||||
val dateString = formatTime(timestamp * LONG_1000)
|
||||
val color = Color(viewModel.highEmphasisColorInt)
|
||||
val backgroundColor =
|
||||
viewModel.context.resources.getColor(R.color.bg_message_list_incoming_bubble, null)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Absolute.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
dateString,
|
||||
fontSize = AUTHOR_TEXT_SIZE,
|
||||
color = color,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.shadow(
|
||||
16.dp,
|
||||
spotColor = viewModel.colorScheme.primary,
|
||||
ambientColor = viewModel.colorScheme.primary
|
||||
)
|
||||
.background(color = Color(backgroundColor), shape = RoundedCornerShape(8.dp))
|
||||
.padding(8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(viewModel.items) { message ->
|
||||
message.activeUser = viewModel.currentUser
|
||||
when (val type = message.getCalculateMessageType()) {
|
||||
ChatMessage.MessageType.SYSTEM_MESSAGE -> {
|
||||
if (!message.shouldFilter()) {
|
||||
SystemMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
ChatMessage.MessageType.VOICE_MESSAGE -> {
|
||||
VoiceMessage(message, isBlinkingState)
|
||||
}
|
||||
|
||||
ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> {
|
||||
ImageMessage(message, isBlinkingState)
|
||||
}
|
||||
|
||||
ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> {
|
||||
GeolocationMessage(message, isBlinkingState)
|
||||
}
|
||||
|
||||
ChatMessage.MessageType.POLL_MESSAGE -> {
|
||||
PollMessage(message, isBlinkingState)
|
||||
}
|
||||
|
||||
ChatMessage.MessageType.DECK_CARD -> {
|
||||
DeckMessage(message, isBlinkingState)
|
||||
}
|
||||
|
||||
ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> {
|
||||
if (message.isLinkPreview()) {
|
||||
LinkMessage(message, isBlinkingState)
|
||||
} else {
|
||||
TextMessage(message, isBlinkingState)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.d(TAG, "Unknown message type: $type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messageId != null && viewModel.items.size > 0) {
|
||||
LaunchedEffect(Dispatchers.Main) {
|
||||
delay(SCROLL_DELAY)
|
||||
val pos = searchMessages(messageId!!)
|
||||
if (pos > 0) {
|
||||
listState.scrollToItem(pos)
|
||||
}
|
||||
delay(ANIMATION_DURATION)
|
||||
isBlinkingState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatMessage.shouldFilter(): Boolean =
|
||||
this.isReaction() ||
|
||||
this.isPollVotedMessage() ||
|
||||
this.isEditMessage() ||
|
||||
this.isInfoMessageAboutDeletion()
|
||||
|
||||
private fun ChatMessage.isInfoMessageAboutDeletion(): Boolean =
|
||||
this.parentMessageId != null &&
|
||||
this.systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED
|
||||
|
||||
private fun ChatMessage.isPollVotedMessage(): Boolean =
|
||||
this.systemMessageType == ChatMessage.SystemMessageType.POLL_VOTED
|
||||
|
||||
private fun ChatMessage.isEditMessage(): Boolean =
|
||||
this.systemMessageType == ChatMessage.SystemMessageType.MESSAGE_EDITED
|
||||
|
||||
private fun ChatMessage.isReaction(): Boolean =
|
||||
systemMessageType == ChatMessage.SystemMessageType.REACTION ||
|
||||
systemMessageType == ChatMessage.SystemMessageType.REACTION_DELETED ||
|
||||
systemMessageType == ChatMessage.SystemMessageType.REACTION_REVOKED
|
||||
|
||||
private fun formatTime(timestampMillis: Long): String {
|
||||
val instant = Instant.ofEpochMilli(timestampMillis)
|
||||
val dateTime = instant.atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
|
||||
return dateTime.format(formatter)
|
||||
}
|
||||
|
||||
private fun searchMessages(searchId: String): Int {
|
||||
viewModel.items.forEachIndexed { index, message ->
|
||||
if (message.id == searchId) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommonMessageQuote(context: Context, message: ChatMessage) {
|
||||
val color = colorResource(R.color.high_emphasis_text)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.drawWithCache {
|
||||
onDrawWithContent {
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(0f, this.size.height / QUOTE_SHAPE_OFFSET),
|
||||
end = Offset(0f, this.size.height - (this.size.height / QUOTE_SHAPE_OFFSET)),
|
||||
strokeWidth = 4f,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(message.actorDisplayName!!, fontSize = AUTHOR_TEXT_SIZE)
|
||||
val imageUri = message.imageUrl
|
||||
if (imageUri != null) {
|
||||
val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image
|
||||
val loadedImage = loadImage(imageUri, context, errorPlaceholderImage)
|
||||
AsyncImage(
|
||||
model = loadedImage,
|
||||
contentDescription = stringResource(R.string.nc_sent_an_image),
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxHeight()
|
||||
)
|
||||
}
|
||||
EnrichedText(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommonMessageBody(
|
||||
message: ChatMessage,
|
||||
includePadding: Boolean = true,
|
||||
playAnimation: Boolean = false,
|
||||
content:
|
||||
@Composable
|
||||
(RowScope.() -> Unit)
|
||||
) {
|
||||
val incoming = message.actorId != viewModel.currentUser.userId
|
||||
val color = if (incoming) {
|
||||
if (message.isDeleted) {
|
||||
viewModel.context.resources.getColor(R.color.bg_message_list_incoming_bubble_deleted, null)
|
||||
} else {
|
||||
viewModel.context.resources.getColor(R.color.bg_message_list_incoming_bubble, null)
|
||||
}
|
||||
} else {
|
||||
if (message.isDeleted) {
|
||||
ColorUtils.setAlphaComponent(viewModel.colorScheme.surfaceVariant.toArgb(), HALF_OPACITY)
|
||||
} else {
|
||||
viewModel.colorScheme.surfaceVariant.toArgb()
|
||||
}
|
||||
}
|
||||
val shape = if (incoming) incomingShape else outgoingShape
|
||||
|
||||
Row(
|
||||
modifier = (
|
||||
if (message.id == messageId && playAnimation) Modifier.withCustomAnimation(incoming) else Modifier
|
||||
)
|
||||
.fillMaxWidth(1f)
|
||||
) {
|
||||
if (incoming) {
|
||||
val imageUri = message.actorId?.let { viewModel.contactsViewModel.getImageUri(it, true) }
|
||||
val errorPlaceholderImage: Int = R.drawable.account_circle_96dp
|
||||
val loadedImage = loadImage(imageUri, viewModel.context, errorPlaceholderImage)
|
||||
AsyncImage(
|
||||
model = loadedImage,
|
||||
contentDescription = stringResource(R.string.user_avatar),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding()
|
||||
.padding(end = 8.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(60.dp, 40.dp)
|
||||
.widthIn(60.dp, 280.dp)
|
||||
.heightIn(40.dp, 450.dp),
|
||||
color = Color(color),
|
||||
shape = shape
|
||||
) {
|
||||
val timeString = DateUtils(viewModel.context).getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
val modifier = if (includePadding) Modifier.padding(8.dp, 4.dp, 8.dp, 4.dp) else Modifier
|
||||
Column(modifier = modifier) {
|
||||
if (message.parentMessageId != null && !message.isDeleted && messagesJson != null) {
|
||||
messagesJson!!
|
||||
.find { it.parentMessage?.id == message.parentMessageId }
|
||||
?.parentMessage!!.asModel().let { CommonMessageQuote(viewModel.context, it) }
|
||||
}
|
||||
|
||||
if (incoming) {
|
||||
Text(message.actorDisplayName.toString(), fontSize = AUTHOR_TEXT_SIZE)
|
||||
}
|
||||
|
||||
Row {
|
||||
content()
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
timeString,
|
||||
fontSize = TIME_TEXT_SIZE,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
if (message.readStatus == ReadStatus.NONE) {
|
||||
val read = painterResource(R.drawable.ic_check_all)
|
||||
Icon(
|
||||
read,
|
||||
"",
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp)
|
||||
.size(12.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.withCustomAnimation(incoming: Boolean): Modifier {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val borderColor by infiniteTransition.animateColor(
|
||||
initialValue = viewModel.colorScheme.primary,
|
||||
targetValue = viewModel.colorScheme.background,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(ANIMATED_BLINK, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
)
|
||||
)
|
||||
|
||||
return this.border(
|
||||
width = 4.dp,
|
||||
color = borderColor,
|
||||
shape = if (incoming) incomingShape else outgoingShape
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShimmerGroup() {
|
||||
Shimmer()
|
||||
Shimmer(true)
|
||||
Shimmer()
|
||||
Shimmer(true)
|
||||
Shimmer(true)
|
||||
Shimmer()
|
||||
Shimmer(true)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Shimmer(outgoing: Boolean = false) {
|
||||
Row(modifier = Modifier.padding(top = 16.dp)) {
|
||||
if (!outgoing) {
|
||||
ShimmerImage(this)
|
||||
}
|
||||
|
||||
val v1 by remember { mutableIntStateOf((INT_8..INT_128).random()) }
|
||||
val v2 by remember { mutableIntStateOf((INT_8..INT_128).random()) }
|
||||
val v3 by remember { mutableIntStateOf((INT_8..INT_128).random()) }
|
||||
|
||||
Column {
|
||||
ShimmerText(this, v1, outgoing)
|
||||
ShimmerText(this, v2, outgoing)
|
||||
ShimmerText(this, v3, outgoing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShimmerImage(rowScope: RowScope) {
|
||||
rowScope.apply {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
LoaderImageView(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
|
||||
val color = resources.getColor(R.color.nc_shimmer_default_color, null)
|
||||
setBackgroundColor(color)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(40.dp)
|
||||
.align(Alignment.Top)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShimmerText(columnScope: ColumnScope, margin: Int, outgoing: Boolean = false) {
|
||||
columnScope.apply {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
LoaderTextView(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
val color = if (outgoing) {
|
||||
viewModel.colorScheme.primary.toArgb()
|
||||
} else {
|
||||
resources.getColor(R.color.nc_shimmer_default_color, null)
|
||||
}
|
||||
|
||||
setBackgroundColor(color)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(
|
||||
top = 6.dp,
|
||||
end = if (!outgoing) margin.dp else 8.dp,
|
||||
start = if (outgoing) margin.dp else 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnrichedText(message: ChatMessage) {
|
||||
AndroidView(factory = { ctx ->
|
||||
val incoming = message.actorId != viewModel.currentUser.userId
|
||||
var processedMessageText = viewModel.messageUtils.enrichChatMessageText(
|
||||
ctx,
|
||||
message,
|
||||
incoming,
|
||||
viewModel.viewThemeUtils
|
||||
)
|
||||
|
||||
processedMessageText = viewModel.messageUtils.processMessageParameters(
|
||||
ctx, viewModel.viewThemeUtils, processedMessageText!!, message, null
|
||||
)
|
||||
|
||||
androidx.emoji2.widget.EmojiTextView(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
|
||||
setLineSpacing(0F, LINE_SPACING)
|
||||
textAlignment = TEXT_ALIGNMENT_VIEW_START
|
||||
text = processedMessageText
|
||||
setPadding(0, INT_8, 0, 0)
|
||||
}
|
||||
}, modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextMessage(message: ChatMessage, state: MutableState<Boolean>) {
|
||||
CommonMessageBody(message, playAnimation = state.value) {
|
||||
EnrichedText(message)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemMessage(message: ChatMessage) {
|
||||
val similarMessages = sharedApplication!!.resources.getQuantityString(
|
||||
R.plurals.see_similar_system_messages,
|
||||
message.expandableChildrenAmount,
|
||||
message.expandableChildrenAmount
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val timeString = DateUtils(viewModel.context).getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
Row(horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
message.text,
|
||||
fontSize = AUTHOR_TEXT_SIZE,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth(FLOAT_06)
|
||||
)
|
||||
Text(
|
||||
timeString,
|
||||
fontSize = TIME_TEXT_SIZE,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
if (message.expandableChildrenAmount > 0) {
|
||||
TextButtonNoStyling(similarMessages) {
|
||||
// NOTE: Read only for now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextButtonNoStyling(text: String, onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick) {
|
||||
Text(
|
||||
text,
|
||||
fontSize = AUTHOR_TEXT_SIZE,
|
||||
color = Color(viewModel.highEmphasisColorInt)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageMessage(message: ChatMessage, state: MutableState<Boolean>) {
|
||||
val hasCaption = (message.message != "{file}")
|
||||
val incoming = message.actorId != viewModel.currentUser.userId
|
||||
val timeString = DateUtils(viewModel.context).getLocalTimeStringFromTimestamp(message.timestamp)
|
||||
CommonMessageBody(message, includePadding = false, playAnimation = state.value) {
|
||||
Column {
|
||||
message.activeUser = viewModel.currentUser
|
||||
val imageUri = message.imageUrl
|
||||
val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image
|
||||
val loadedImage = load(imageUri, viewModel.context, errorPlaceholderImage)
|
||||
|
||||
AsyncImage(
|
||||
model = loadedImage,
|
||||
contentDescription = stringResource(R.string.nc_sent_an_image),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
|
||||
if (hasCaption) {
|
||||
Text(
|
||||
message.text,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.widthIn(20.dp, 140.dp)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCaption) {
|
||||
Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!incoming) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
} else {
|
||||
Spacer(Modifier.size(width = 56.dp, 0.dp)) // To account for avatar size
|
||||
}
|
||||
Text(message.text, fontSize = 12.sp)
|
||||
Text(
|
||||
timeString,
|
||||
fontSize = TIME_TEXT_SIZE,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding()
|
||||
.padding(start = 4.dp)
|
||||
)
|
||||
if (message.readStatus == ReadStatus.NONE) {
|
||||
val read = painterResource(R.drawable.ic_check_all)
|
||||
Icon(
|
||||
read,
|
||||
"",
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp)
|
||||
.size(12.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceMessage(message: ChatMessage, state: MutableState<Boolean>) {
|
||||
CommonMessageBody(message, playAnimation = state.value) {
|
||||
Icon(
|
||||
Icons.Filled.PlayArrow,
|
||||
"play",
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
WaveformSeekBar(ctx).apply {
|
||||
setWaveData(FloatArray(DEFAULT_WAVE_SIZE) { Random.nextFloat() }) // READ ONLY for now
|
||||
setColors(
|
||||
viewModel.colorScheme.inversePrimary.toArgb(),
|
||||
viewModel.colorScheme.onPrimaryContainer.toArgb()
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.width(180.dp)
|
||||
.height(80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GeolocationMessage(message: ChatMessage, state: MutableState<Boolean>) {
|
||||
CommonMessageBody(message, playAnimation = state.value) {
|
||||
Column {
|
||||
if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "geo-location") {
|
||||
val lat = individualHashMap["latitude"]
|
||||
val lng = individualHashMap["longitude"]
|
||||
|
||||
if (lat != null && lng != null) {
|
||||
val latitude = lat.toDouble()
|
||||
val longitude = lng.toDouble()
|
||||
OpenStreetMap(latitude, longitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OpenStreetMap(latitude: Double, longitude: Double) {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(),
|
||||
factory = { context ->
|
||||
Configuration.getInstance().userAgentValue = context.packageName
|
||||
MapView(context).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
|
||||
val geoPoint = GeoPoint(latitude, longitude)
|
||||
controller.setCenter(geoPoint)
|
||||
controller.setZoom(MAP_ZOOM)
|
||||
|
||||
val marker = Marker(this)
|
||||
marker.position = geoPoint
|
||||
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
marker.title = "Location"
|
||||
overlays.add(marker)
|
||||
|
||||
invalidate()
|
||||
}
|
||||
},
|
||||
update = { mapView ->
|
||||
val geoPoint = GeoPoint(latitude, longitude)
|
||||
mapView.controller.setCenter(geoPoint)
|
||||
|
||||
val marker = mapView.overlays.find { it is Marker } as? Marker
|
||||
marker?.position = geoPoint
|
||||
mapView.invalidate()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkMessage(message: ChatMessage, state: MutableState<Boolean>) {
|
||||
val color = colorResource(R.color.high_emphasis_text)
|
||||
viewModel.chatViewModel.getOpenGraph(
|
||||
viewModel.currentUser.getCredentials(),
|
||||
viewModel.currentUser.baseUrl!!,
|
||||
message.extractedUrlToPreview!!
|
||||
)
|
||||
CommonMessageBody(message, playAnimation = state.value) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.drawWithCache {
|
||||
onDrawWithContent {
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset.Zero,
|
||||
end = Offset(0f, this.size.height),
|
||||
strokeWidth = 4f,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
.padding(8.dp)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
Column {
|
||||
val graphObject = viewModel.chatViewModel.getOpenGraph.asFlow().collectAsState(
|
||||
Reference(
|
||||
// Dummy class
|
||||
)
|
||||
).value.openGraphObject
|
||||
graphObject?.let {
|
||||
Text(it.name, fontSize = REGULAR_TEXT_SIZE, fontWeight = FontWeight.Bold)
|
||||
it.description?.let { Text(it, fontSize = AUTHOR_TEXT_SIZE) }
|
||||
it.link?.let { Text(it, fontSize = TIME_TEXT_SIZE) }
|
||||
it.thumb?.let {
|
||||
val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image
|
||||
val loadedImage = loadImage(it, viewModel.context, errorPlaceholderImage)
|
||||
AsyncImage(
|
||||
model = loadedImage,
|
||||
contentDescription = stringResource(R.string.nc_sent_an_image),
|
||||
modifier = Modifier
|
||||
.height(120.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollMessage(message: ChatMessage, state: MutableState<Boolean>) {
|
||||
CommonMessageBody(message, playAnimation = state.value) {
|
||||
Column {
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "talk-poll") {
|
||||
// val pollId = individualHashMap["id"]
|
||||
val pollName = individualHashMap["name"].toString()
|
||||
Row(modifier = Modifier.padding(start = 8.dp)) {
|
||||
Icon(painterResource(R.drawable.ic_baseline_bar_chart_24), "")
|
||||
Text(pollName, fontSize = AUTHOR_TEXT_SIZE, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) {
|
||||
// NOTE: read only for now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeckMessage(message: ChatMessage, state: MutableState<Boolean>) {
|
||||
CommonMessageBody(message, playAnimation = state.value) {
|
||||
Column {
|
||||
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
|
||||
for (key in message.messageParameters!!.keys) {
|
||||
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
|
||||
if (individualHashMap["type"] == "deck-card") {
|
||||
val cardName = individualHashMap["name"]
|
||||
val stackName = individualHashMap["stackname"]
|
||||
val boardName = individualHashMap["boardname"]
|
||||
// val cardLink = individualHashMap["link"]
|
||||
|
||||
if (cardName?.isNotEmpty() == true) {
|
||||
val cardDescription = String.format(
|
||||
viewModel.context.resources.getString(R.string.deck_card_description),
|
||||
stackName,
|
||||
boardName
|
||||
)
|
||||
Row(modifier = Modifier.padding(start = 8.dp)) {
|
||||
Icon(painterResource(R.drawable.deck), "")
|
||||
Text(cardName, fontSize = AUTHOR_TEXT_SIZE, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(cardDescription, fontSize = AUTHOR_TEXT_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.MutableState
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import autodagger.AutoInjector
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
|
||||
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.ui.theme.ViewThemeUtils
|
||||
import com.nextcloud.talk.users.UserManager
|
||||
import com.nextcloud.talk.utils.bundle.BundleKeys
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("FunctionNaming", "LongMethod", "StaticFieldLeak")
|
||||
class ContextChatCompose(val bundle: Bundle) {
|
||||
|
||||
companion object {
|
||||
const val LIMIT = 50
|
||||
const val HALF_ALPHA = 0.5f
|
||||
}
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
inner class ContextChatComposeViewModel : ViewModel() {
|
||||
@Inject
|
||||
lateinit var viewThemeUtils: ViewThemeUtils
|
||||
|
||||
@Inject
|
||||
lateinit var chatViewModel: ChatViewModel
|
||||
|
||||
@Inject
|
||||
lateinit var userManager: UserManager
|
||||
|
||||
init {
|
||||
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
|
||||
val credentials = bundle.getString(BundleKeys.KEY_CREDENTIALS)!!
|
||||
val baseUrl = bundle.getString(BundleKeys.KEY_BASE_URL)!!
|
||||
val token = bundle.getString(BundleKeys.KEY_ROOM_TOKEN)!!
|
||||
val messageId = bundle.getString(BundleKeys.KEY_MESSAGE_ID)!!
|
||||
|
||||
chatViewModel.getContextForChatMessages(credentials, baseUrl, token, messageId, LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GetDialogView(
|
||||
shouldDismiss: MutableState<Boolean>,
|
||||
context: Context,
|
||||
contextViewModel: ContextChatComposeViewModel = ContextChatComposeViewModel()
|
||||
) {
|
||||
if (shouldDismiss.value) {
|
||||
return
|
||||
}
|
||||
|
||||
val colorScheme = contextViewModel.viewThemeUtils.getColorScheme(context)
|
||||
MaterialTheme(colorScheme) {
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
shouldDismiss.value = true
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
val user = contextViewModel.userManager.currentUser.blockingGet()
|
||||
val shouldShow = !user.hasSpreedFeatureCapability("chat-get-context") ||
|
||||
!user.hasSpreedFeatureCapability("federation-v1")
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
shouldDismiss.value = true
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
stringResource(R.string.close),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
val name = bundle.getString(BundleKeys.KEY_CONVERSATION_NAME)!!
|
||||
Text(name, fontSize = 24.sp)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if (shouldShow) {
|
||||
Icon(
|
||||
Icons.Filled.Info,
|
||||
"Info Icon",
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.nc_capabilities_failed),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
} else {
|
||||
val contextState = contextViewModel
|
||||
.chatViewModel
|
||||
.getContextChatMessages
|
||||
.asFlow()
|
||||
.collectAsState(listOf())
|
||||
val messagesJson = contextState.value
|
||||
val messages = messagesJson.map(ChatMessageJson::asModel)
|
||||
val messageId = bundle.getString(BundleKeys.KEY_MESSAGE_ID)!!
|
||||
val adapter = ComposeChatAdapter(messagesJson, messageId)
|
||||
SideEffect {
|
||||
adapter.addMessages(messages.toMutableList(), true)
|
||||
}
|
||||
adapter.GetView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComposeChatMenu(backgroundColor: Color, enabled: Boolean = true) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.wrapContentSize(Alignment.TopStart)
|
||||
) {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More options"
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -619,4 +619,8 @@ object ApiUtils {
|
||||
fun getUrlForOutOfOffice(baseUrl: String, userId: String): String {
|
||||
return "$baseUrl$OCS_API_VERSION/apps/dav/api/v1/outOfOffice/$userId/now"
|
||||
}
|
||||
|
||||
fun getUrlForChatMessageContext(baseUrl: String, token: String, messageId: String): String {
|
||||
return "$baseUrl$OCS_API_VERSION$SPREED_API_VERSION/chat/$token/$messageId/context"
|
||||
}
|
||||
}
|
||||
|
@ -9,13 +9,13 @@ package com.nextcloud.talk.utils.message
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.core.net.toUri
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
@ -79,7 +79,7 @@ class MessageUtils(val context: Context) {
|
||||
viewThemeUtils: ViewThemeUtils,
|
||||
spannedText: Spanned,
|
||||
message: ChatMessage,
|
||||
itemView: View
|
||||
itemView: View?
|
||||
): Spanned {
|
||||
var processedMessageText = spannedText
|
||||
val messageParameters = message.messageParameters
|
||||
@ -103,15 +103,15 @@ class MessageUtils(val context: Context) {
|
||||
messageParameters: HashMap<String?, HashMap<String?, String?>>,
|
||||
message: ChatMessage,
|
||||
messageString: Spanned,
|
||||
itemView: View
|
||||
itemView: View?
|
||||
): Spanned {
|
||||
var messageStringInternal = messageString
|
||||
for (key in messageParameters.keys) {
|
||||
val individualHashMap = message.messageParameters!![key]
|
||||
val individualHashMap = message.messageParameters?.get(key)
|
||||
if (individualHashMap != null) {
|
||||
when (individualHashMap["type"]) {
|
||||
"user", "guest", "call", "user-group", "email", "circle" -> {
|
||||
val chip = if (individualHashMap["id"] == message.activeUser!!.userId) {
|
||||
val chip = if (individualHashMap["id"]?.equals(message.activeUser?.userId) == true) {
|
||||
R.xml.chip_you
|
||||
} else {
|
||||
R.xml.chip_others
|
||||
@ -122,15 +122,21 @@ class MessageUtils(val context: Context) {
|
||||
individualHashMap["id"]
|
||||
}
|
||||
|
||||
val name = individualHashMap["name"]
|
||||
val type = individualHashMap["type"]
|
||||
val user = message.activeUser
|
||||
if (user == null || key == null) break
|
||||
if (id == null || name == null || type == null) break
|
||||
|
||||
messageStringInternal = DisplayUtils.searchAndReplaceWithMentionSpan(
|
||||
key!!,
|
||||
key,
|
||||
themingContext,
|
||||
messageStringInternal,
|
||||
id!!,
|
||||
id,
|
||||
message.token,
|
||||
individualHashMap["name"]!!,
|
||||
individualHashMap["type"]!!,
|
||||
message.activeUser!!,
|
||||
name,
|
||||
type,
|
||||
user,
|
||||
chip,
|
||||
viewThemeUtils,
|
||||
individualHashMap["server"] != null
|
||||
@ -138,8 +144,8 @@ class MessageUtils(val context: Context) {
|
||||
}
|
||||
|
||||
"file" -> {
|
||||
itemView.setOnClickListener { v ->
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, individualHashMap["link"]!!.toUri())
|
||||
itemView?.setOnClickListener { v ->
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"]))
|
||||
context.startActivity(browserIntent)
|
||||
}
|
||||
}
|
||||
|
@ -316,4 +316,9 @@
|
||||
app:iconPadding="@dimen/standard_padding"
|
||||
style="@style/Widget.AppTheme.Button.ElevatedButton"/>
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/generic_compose_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
Loading…
Reference in New Issue
Block a user