Add support for starring messages.

This commit is contained in:
Greyson Parrelli
2026-03-20 21:24:10 -04:00
committed by Cody Henthorne
parent 6496f236ea
commit 48374e6950
48 changed files with 1149 additions and 49 deletions

View File

@@ -0,0 +1,411 @@
package org.thoughtcrime.securesms.starred
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.doOnNextLayout
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.viewModel
import java.util.Locale
import org.signal.core.ui.R as CoreUiR
class StarredMessagesActivity : PassphraseRequiredActivity() {
companion object {
private const val EXTRA_THREAD_ID = "thread_id"
const val NO_THREAD_ID = -1L
@JvmStatic
fun createIntent(context: Context): Intent {
return Intent(context, StarredMessagesActivity::class.java)
}
@JvmStatic
fun createIntent(context: Context, threadId: Long): Intent {
return Intent(context, StarredMessagesActivity::class.java).apply {
putExtra(EXTRA_THREAD_ID, threadId)
}
}
}
private val viewModel by viewModel {
val threadId = intent.getLongExtra(EXTRA_THREAD_ID, NO_THREAD_ID)
val effectiveThreadId = if (threadId == NO_THREAD_ID) null else threadId
StarredMessagesViewModel(effectiveThreadId)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContent {
SignalTheme {
StarredMessagesScreen(
viewModel = viewModel,
onNavigateBack = { supportFinishAfterTransition() },
onNavigateToMessage = ::navigateToMessage
)
}
}
}
private fun navigateToMessage(messageRecord: MessageRecord) {
lifecycleScope.launch {
val (threadRecipient, startingPosition) = withContext(Dispatchers.IO) {
val position = SignalDatabase.messages.getMessagePositionInConversation(messageRecord.threadId, messageRecord.dateReceived)
val recipient = SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)
Pair(recipient, maxOf(0, position))
}
if (threadRecipient != null) {
val intent = ConversationIntents.createBuilderSync(this@StarredMessagesActivity, threadRecipient.id, messageRecord.threadId)
.withStartingPosition(startingPosition)
.build()
startActivity(intent)
finish()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StarredMessagesScreen(
viewModel: StarredMessagesViewModel,
onNavigateBack: () -> Unit,
onNavigateToMessage: (MessageRecord) -> Unit
) {
val messages by viewModel.getMessages().collectAsStateWithLifecycle(initialValue = emptyList())
val scope = rememberCoroutineScope()
val context = LocalContext.current
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
topBar = {
Scaffolds.DefaultTopAppBar(
title = stringResource(R.string.StarredMessagesActivity__starred_messages),
titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) },
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
onNavigationClick = onNavigateBack,
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { padding ->
Box(
modifier = Modifier
.padding(padding)
.fillMaxSize()
) {
StarredMessageList(
messages = messages,
onItemClick = onNavigateToMessage,
onQuoteClick = onNavigateToMessage,
onUnstarMessage = { messageId ->
scope.launch {
try {
viewModel.unstarMessage(messageId)
} catch (e: Exception) {
Toast.makeText(context, "Failed to unstar message", Toast.LENGTH_SHORT).show()
}
}
},
modifier = Modifier.fillMaxSize()
)
if (messages.isEmpty()) {
EmptyState(modifier = Modifier.fillMaxSize())
}
}
}
}
@SuppressLint("WrongThread")
@Composable
private fun StarredMessageList(
messages: List<ConversationMessage>,
onItemClick: (MessageRecord) -> Unit,
onQuoteClick: (MessageRecord) -> Unit,
onUnstarMessage: (Long) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val onItemClickState = rememberUpdatedState(onItemClick)
val onQuoteClickState = rememberUpdatedState(onQuoteClick)
val onUnstarMessageState = rememberUpdatedState(onUnstarMessage)
val adapter = remember {
@Suppress("DEPRECATION")
val colorizer = ColorizerV1()
ConversationAdapter(
context,
lifecycleOwner,
Glide.with(context),
Locale.getDefault(),
StarredMessageClickListener(
onItemClick = { onItemClickState.value(it) },
onQuoteClick = { onQuoteClickState.value(it) },
onUnstarMessage = { onUnstarMessageState.value(it) },
context = context
),
false,
colorizer
).apply {
setCondensedMode(ConversationItemDisplayMode.Starred)
}
}
AndroidView(
factory = { ctx ->
FrameLayout(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
val videoContainer = FrameLayout(ctx).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
addView(videoContainer)
val recyclerView = RecyclerView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
clipToPadding = false
setPadding(0, 0, 0, (24 * resources.displayMetrics.density).toInt())
layoutManager = SmoothScrollingLinearLayoutManager(ctx, true)
this.adapter = adapter
itemAnimator = null
doOnNextLayout {
addItemDecoration(StickyHeaderDecoration(adapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
}
}
addView(recyclerView)
initializeGiphyMp4(lifecycleOwner.lifecycle, videoContainer, recyclerView)
}
},
update = {
adapter.submitList(messages)
},
modifier = modifier
)
}
private fun initializeGiphyMp4(lifecycle: Lifecycle, videoContainer: ViewGroup, list: RecyclerView) {
val context = list.context
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(context, lifecycle, videoContainer, maxPlayback)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
}
@Composable
private fun EmptyState(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(R.drawable.symbol_star_24),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.alpha(0.5f),
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.StarredMessagesFragment__no_starred_messages),
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.StarredMessagesFragment__tap_and_hold_on_a_message_to_star_it),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.alpha(0.7f)
)
}
}
private class StarredMessageClickListener(
private val onItemClick: (MessageRecord) -> Unit,
private val onQuoteClick: (MessageRecord) -> Unit,
private val onUnstarMessage: (Long) -> Unit,
private val context: Context
) : ConversationAdapter.ItemClickListener {
override fun onItemClick(item: MultiselectPart) {
onItemClick(item.getMessageRecord())
}
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
val messageRecord = item.getMessageRecord()
val items = mutableListOf<ActionItem>()
items.add(
ActionItem(R.drawable.symbol_star_outline_24, context.getString(R.string.conversation_selection__menu_unstar)) {
onUnstarMessage(messageRecord.id)
}
)
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.show(items)
}
override fun onQuoteClicked(messageRecord: MmsMessageRecord) {
onQuoteClick(messageRecord)
}
override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onMoreTextClicked(conversationRecipientId: RecipientId, messageId: Long, isMms: Boolean) = Unit
override fun onStickerClicked(stickerLocator: StickerLocator) = Unit
override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) = Unit
override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) = Unit
override fun onAddToContactsClicked(contact: Contact) = Unit
override fun onMessageSharedContactClicked(choices: MutableList<Recipient>) = Unit
override fun onInviteSharedContactClicked(choices: MutableList<Recipient>) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
override fun onMessageWithErrorClicked(messageRecord: MessageRecord) = Unit
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
override fun onChatSessionRefreshLearnMoreClicked() = Unit
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
override fun onJoinGroupCallClicked() = Unit
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
override fun onEnableCallNotificationsClicked() = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(conversationMessage: ConversationMessage) = Unit
override fun onShowSafetyTips(forGroup: Boolean) = Unit
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
override fun onItemDoubleClick(item: MultiselectPart) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
override fun onViewResultsClicked(pollId: Long) = Unit
override fun onIncomingIdentityMismatchClicked(recipientId: RecipientId) = Unit
override fun onRegisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState>) = Unit
override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState>) = Unit
override fun onVoiceNotePause(uri: Uri) = Unit
override fun onVoiceNotePlay(uri: Uri, messageId: Long, position: Double) = Unit
override fun onVoiceNoteSeekTo(uri: Uri, position: Double) = Unit
override fun onVoiceNotePlaybackSpeedChanged(uri: Uri, speed: Float) = Unit
override fun onPlayInlineContent(conversationMessage: ConversationMessage?) = Unit
override fun onInMemoryMessageClicked(messageRecord: InMemoryMessageRecord) = Unit
override fun onViewGroupDescriptionChange(groupId: GroupId?, description: String, isMessageRequestAccepted: Boolean) = Unit
override fun onChangeNumberUpdateContact(recipient: Recipient) = Unit
override fun onChangeProfileNameUpdateContact(recipient: Recipient) = Unit
override fun onBlockJoinRequest(recipient: Recipient) = Unit
override fun onInviteToSignalClicked() = Unit
override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) = Unit
override fun onUrlClicked(url: String): Boolean = false
override fun onGiftBadgeRevealed(messageRecord: MessageRecord) = Unit
override fun goToMediaPreview(parent: ConversationItem, sharedElement: View, args: MediaIntentFactory.MediaPreviewArgs) = Unit
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit
override fun onPaymentTombstoneClicked() = Unit
override fun onDisplayMediaNoLongerAvailableSheet() = Unit
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) = Unit
override fun onUpdateSignalClicked() = Unit
override fun onViewPollClicked(messageId: Long) = Unit
override fun onViewPinnedMessage(messageId: Long) = Unit
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.starred
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.RxDatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
class StarredMessagesViewModel(
private val threadId: Long?
) : ViewModel() {
fun getMessages(): Flow<List<ConversationMessage>> {
val trigger = if (threadId != null) {
RxDatabaseObserver.conversation(threadId)
} else {
RxDatabaseObserver.starredMessages
}
return trigger.toObservable().asFlow()
.map {
val messages = SignalDatabase.messages.getStarredMessages(threadId)
messages.map { record ->
val incomingRecord = if (record is MmsMessageRecord && record.isOutgoing) {
record.withIncomingType()
} else {
record
}
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId) ?: Recipient.UNKNOWN
ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(
AppDependencies.application,
incomingRecord,
threadRecipient
)
}
}
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
suspend fun unstarMessage(messageId: Long) {
withContext(Dispatchers.IO) {
SignalDatabase.messages.setStarred(messageId, false)
}
}
class Factory(
private val threadId: Long?
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return StarredMessagesViewModel(threadId) as T
}
}
}