diff --git a/.idea/inspectionProfiles/ktlint.xml b/.idea/inspectionProfiles/ktlint.xml index a34119a8b..176170d8b 100644 --- a/.idea/inspectionProfiles/ktlint.xml +++ b/.idea/inspectionProfiles/ktlint.xml @@ -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" /> diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index af291d4cf..cef8a27c0 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -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 } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt index d808ac9a8..664139d25 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt @@ -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? } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt index 6f857d254..e476d568b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt @@ -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 + } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 0a2b4a060..e72282f56 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -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 diff --git a/app/src/main/java/com/nextcloud/talk/contacts/ImageRequest.kt b/app/src/main/java/com/nextcloud/talk/contacts/ImageRequest.kt index c91061308..6d8eda446 100644 --- a/app/src/main/java/com/nextcloud/talk/contacts/ImageRequest.kt +++ b/app/src/main/java/com/nextcloud/talk/contacts/ImageRequest.kt @@ -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 +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index fdc6f8099..3e5f87ce5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -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 -> { diff --git a/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java index 8679f6504..77706c15a 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java @@ -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()); - Log.d(TAG, "deleted user: " + username); + if (username != null) { + Log.d(TAG, "deleted user: " + username); + } } catch (Throwable e) { Log.e(TAG, "error while trying to delete user", e); } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesWorker.java index 2a30d3875..08c7aca57 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesWorker.java @@ -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 diff --git a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt new file mode 100644 index 000000000..fc7e4d58b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt @@ -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) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ContextChatCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ContextChatCompose.kt new file mode 100644 index 000000000..990eefe3b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/ContextChatCompose.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index 66cb5b2cb..ff2956e5e 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -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" + } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt index ffedb19d9..eee95b293 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt @@ -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) } } diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index 146471ff3..6c69b1207 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -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>