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:
rapterjet2004 2025-01-23 15:26:32 -06:00 committed by Marcel Hibbe
parent 5aab7ac9bb
commit ce589b3cae
No known key found for this signature in database
GPG Key ID: C793F8B59F43CE7B
14 changed files with 1310 additions and 22 deletions

View File

@ -68,6 +68,7 @@
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />

View File

@ -8,6 +8,7 @@
package com.nextcloud.talk.api package com.nextcloud.talk.api
import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall 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.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.generic.GenericOverall
@ -227,4 +228,11 @@ interface NcApiCoroutines {
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Url url: String @Url url: String
): UserAbsenceOverall ): UserAbsenceOverall
@GET
suspend fun getContextOfChatMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Query("limit") limit: Int
): ChatOverall
} }

View File

@ -9,10 +9,12 @@ package com.nextcloud.talk.chat.data.network
import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.capabilities.SpreedCapability 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.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.conversations.RoomsOverall import com.nextcloud.talk.models.json.conversations.RoomsOverall
import com.nextcloud.talk.models.json.generic.GenericOverall 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.reminder.Reminder
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall
import io.reactivex.Observable import io.reactivex.Observable
@ -66,4 +68,12 @@ interface ChatNetworkDataSource {
fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int): Observable<GenericOverall> fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int): Observable<GenericOverall>
suspend fun editChatMessage(credentials: String, url: String, text: String): ChatOverallSingleMessage suspend fun editChatMessage(credentials: String, url: String, text: String): ChatOverallSingleMessage
suspend fun getOutOfOfficeStatusForUser(credentials: String, baseUrl: String, userId: String): UserAbsenceOverall 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?
} }

View File

@ -11,10 +11,12 @@ import com.nextcloud.talk.api.NcApiCoroutines
import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.capabilities.SpreedCapability 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.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.conversations.RoomsOverall import com.nextcloud.talk.models.json.conversations.RoomsOverall
import com.nextcloud.talk.models.json.generic.GenericOverall 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.reminder.Reminder
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall
import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils
@ -195,4 +197,28 @@ class RetrofitChatNetwork(
ApiUtils.getUrlForOutOfOffice(baseUrl, userId) 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
}
} }

View File

@ -31,10 +31,12 @@ import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.domain.ReactionAddedModel import com.nextcloud.talk.models.domain.ReactionAddedModel
import com.nextcloud.talk.models.domain.ReactionDeletedModel import com.nextcloud.talk.models.domain.ReactionDeletedModel
import com.nextcloud.talk.models.json.capabilities.SpreedCapability 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.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.conversations.RoomsOverall import com.nextcloud.talk.models.json.conversations.RoomsOverall
import com.nextcloud.talk.models.json.generic.GenericOverall 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.reminder.Reminder
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceData import com.nextcloud.talk.models.json.userAbsence.UserAbsenceData
import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.repositories.reactions.ReactionsRepository
@ -146,6 +148,18 @@ class ChatViewModel @Inject constructor(
val outOfOfficeViewState: LiveData<OutOfOfficeUIState> val outOfOfficeViewState: LiveData<OutOfOfficeUIState>
get() = _outOfOfficeViewState 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 val getMessageFlow = chatRepository.messageFlow
.onEach { .onEach {
_chatMessageViewState.value = if (_chatMessageViewState.value == ChatMessageInitialState) { _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 { companion object {
private val TAG = ChatViewModel::class.simpleName private val TAG = ChatViewModel::class.simpleName
const val JOIN_ROOM_RETRY_COUNT: Long = 3 const val JOIN_ROOM_RETRY_COUNT: Long = 3

View File

@ -10,7 +10,9 @@ package com.nextcloud.talk.contacts
import android.content.Context import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Size
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
@Composable @Composable
fun loadImage(imageUri: String?, context: Context, errorPlaceholderImage: Int): ImageRequest { fun loadImage(imageUri: String?, context: Context, errorPlaceholderImage: Int): ImageRequest {
@ -22,3 +24,15 @@ fun loadImage(imageUri: String?, context: Context, errorPlaceholderImage: Int):
.build() .build()
return imageRequest 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
}

View File

@ -39,7 +39,9 @@ import androidx.activity.OnBackPressedCallback
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.os.bundleOf
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.MenuItemCompat 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.BackgroundVoiceMessageCard
import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment
import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment 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.ConversationsListBottomDialog
import com.nextcloud.talk.ui.dialog.FilterConversationFragment import com.nextcloud.talk.ui.dialog.FilterConversationFragment
import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.users.UserManager
@ -1374,9 +1377,25 @@ class ConversationsListActivity :
when (item.itemViewType) { when (item.itemViewType) {
MessageResultItem.VIEW_TYPE -> { MessageResultItem.VIEW_TYPE -> {
val messageItem: MessageResultItem = item as MessageResultItem val messageItem: MessageResultItem = item as MessageResultItem
val conversationToken = messageItem.messageEntry.conversationToken val token = messageItem.messageEntry.conversationToken
selectedMessageId = messageItem.messageEntry.messageId val conversationName = (
showConversationByToken(conversationToken) 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 -> { LoadMoreResultsItem.VIEW_TYPE -> {

View File

@ -10,6 +10,7 @@ package com.nextcloud.talk.jobs;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.util.Log; import android.util.Log;
import com.nextcloud.talk.R; import com.nextcloud.talk.R;
import com.nextcloud.talk.api.NcApi; import com.nextcloud.talk.api.NcApi;
import com.nextcloud.talk.application.NextcloudTalkApplication; import com.nextcloud.talk.application.NextcloudTalkApplication;
@ -73,7 +74,7 @@ public class AccountRemovalWorker extends Worker {
@NonNull @NonNull
@Override @Override
public Result doWork() { public Result doWork() {
NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); Objects.requireNonNull(NextcloudTalkApplication.Companion.getSharedApplication()).getComponentApplication().inject(this);
List<User> users = userManager.getUsersScheduledForDeletion().blockingGet(); List<User> users = userManager.getUsersScheduledForDeletion().blockingGet();
for (User user : users) { for (User user : users) {
@ -91,7 +92,7 @@ public class AccountRemovalWorker extends Worker {
ncApi.unregisterDeviceForNotificationsWithNextcloud( ncApi.unregisterDeviceForNotificationsWithNextcloud(
ApiUtils.getCredentials(user.getUsername(), user.getToken()), ApiUtils.getCredentials(user.getUsername(), user.getToken()),
ApiUtils.getUrlNextcloudPush(user.getBaseUrl())) ApiUtils.getUrlNextcloudPush(Objects.requireNonNull(user.getBaseUrl())))
.blockingSubscribe(new Observer<GenericOverall>() { .blockingSubscribe(new Observer<GenericOverall>() {
@Override @Override
public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) { public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) {
@ -177,10 +178,11 @@ public class AccountRemovalWorker extends Worker {
private void initiateUserDeletion(User user) { private void initiateUserDeletion(User user) {
if (user.getId() != null) { if (user.getId() != null) {
WebSocketConnectionHelper.deleteExternalSignalingInstanceForUserEntity(user.getId()); long id = user.getId();
WebSocketConnectionHelper.deleteExternalSignalingInstanceForUserEntity(id);
try { try {
arbitraryStorageManager.deleteAllEntriesForAccountIdentifier(user.getId()); arbitraryStorageManager.deleteAllEntriesForAccountIdentifier(id);
deleteUser(user); deleteUser(user);
} catch (Throwable e) { } catch (Throwable e) {
Log.e(TAG, "error while trying to delete All Entries For Account Identifier", 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(); String username = user.getUsername();
try { try {
userManager.deleteUser(user.getId()); userManager.deleteUser(user.getId());
Log.d(TAG, "deleted user: " + username); if (username != null) {
Log.d(TAG, "deleted user: " + username);
}
} catch (Throwable e) { } catch (Throwable e) {
Log.e(TAG, "error while trying to delete user", e); Log.e(TAG, "error while trying to delete user", e);
} }

View File

@ -119,8 +119,13 @@ public class CapabilitiesWorker extends Worker {
.build() .build()
.create(NcApi.class); .create(NcApi.class);
ncApi.getCapabilities(ApiUtils.getCredentials(user.getUsername(), user.getToken()), String url = "";
ApiUtils.getUrlForCapabilities(user.getBaseUrl())) String baseurl = user.getBaseUrl();
if (baseurl != null) {
url = ApiUtils.getUrlForCapabilities(baseurl);
}
ncApi.getCapabilities(ApiUtils.getCredentials(user.getUsername(), user.getToken()), url)
.retry(3) .retry(3)
.blockingSubscribe(new Observer<CapabilitiesOverall>() { .blockingSubscribe(new Observer<CapabilitiesOverall>() {
@Override @Override

View 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)
}
}
}
}
}
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -619,4 +619,8 @@ object ApiUtils {
fun getUrlForOutOfOffice(baseUrl: String, userId: String): String { fun getUrlForOutOfOffice(baseUrl: String, userId: String): String {
return "$baseUrl$OCS_API_VERSION/apps/dav/api/v1/outOfOffice/$userId/now" 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"
}
} }

View File

@ -9,13 +9,13 @@ package com.nextcloud.talk.utils.message
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri
import android.text.SpannableString import android.text.SpannableString
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import androidx.core.net.toUri
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils
@ -79,7 +79,7 @@ class MessageUtils(val context: Context) {
viewThemeUtils: ViewThemeUtils, viewThemeUtils: ViewThemeUtils,
spannedText: Spanned, spannedText: Spanned,
message: ChatMessage, message: ChatMessage,
itemView: View itemView: View?
): Spanned { ): Spanned {
var processedMessageText = spannedText var processedMessageText = spannedText
val messageParameters = message.messageParameters val messageParameters = message.messageParameters
@ -103,15 +103,15 @@ class MessageUtils(val context: Context) {
messageParameters: HashMap<String?, HashMap<String?, String?>>, messageParameters: HashMap<String?, HashMap<String?, String?>>,
message: ChatMessage, message: ChatMessage,
messageString: Spanned, messageString: Spanned,
itemView: View itemView: View?
): Spanned { ): Spanned {
var messageStringInternal = messageString var messageStringInternal = messageString
for (key in messageParameters.keys) { for (key in messageParameters.keys) {
val individualHashMap = message.messageParameters!![key] val individualHashMap = message.messageParameters?.get(key)
if (individualHashMap != null) { if (individualHashMap != null) {
when (individualHashMap["type"]) { when (individualHashMap["type"]) {
"user", "guest", "call", "user-group", "email", "circle" -> { "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 R.xml.chip_you
} else { } else {
R.xml.chip_others R.xml.chip_others
@ -122,15 +122,21 @@ class MessageUtils(val context: Context) {
individualHashMap["id"] 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( messageStringInternal = DisplayUtils.searchAndReplaceWithMentionSpan(
key!!, key,
themingContext, themingContext,
messageStringInternal, messageStringInternal,
id!!, id,
message.token, message.token,
individualHashMap["name"]!!, name,
individualHashMap["type"]!!, type,
message.activeUser!!, user,
chip, chip,
viewThemeUtils, viewThemeUtils,
individualHashMap["server"] != null individualHashMap["server"] != null
@ -138,8 +144,8 @@ class MessageUtils(val context: Context) {
} }
"file" -> { "file" -> {
itemView.setOnClickListener { v -> itemView?.setOnClickListener { v ->
val browserIntent = Intent(Intent.ACTION_VIEW, individualHashMap["link"]!!.toUri()) val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"]))
context.startActivity(browserIntent) context.startActivity(browserIntent)
} }
} }

View File

@ -316,4 +316,9 @@
app:iconPadding="@dimen/standard_padding" app:iconPadding="@dimen/standard_padding"
style="@style/Widget.AppTheme.Button.ElevatedButton"/> 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> </androidx.coordinatorlayout.widget.CoordinatorLayout>