feat(chat): message reactions + delete (unsend) via long-press menu
Long-press a message for a reaction bar (heart/laugh/thumb/wow/sad/fire), Copy (text),
and Delete (author). Reactions stored as a reactions:{uid:emoji} map; delete sets a
'deleted' tombstone ('This message was deleted') and updates the inbox preview if it was
last. Rules: any member may change only reactions; author may set only deleted. Live
verification pending the Phase B rules deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7f1b938aa5
commit
5b9596e042
|
|
@ -166,6 +166,32 @@ class FirestoreConversationDataSource @Inject constructor(
|
||||||
awaitClose { listener.remove() }
|
awaitClose { listener.remove() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggles the caller's emoji reaction on a message (pass null to remove). Any couple member may react. */
|
||||||
|
suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) {
|
||||||
|
val ref = messagesRef(coupleId, conversationId).document(messageId)
|
||||||
|
if (emoji == null) {
|
||||||
|
ref.update("reactions.$userId", FieldValue.delete()).voidAwait()
|
||||||
|
} else {
|
||||||
|
ref.update(mapOf("reactions.$userId" to emoji)).voidAwait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsend: tombstones a message (author-only) and reflects it in the inbox preview if it was last. */
|
||||||
|
suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) {
|
||||||
|
messagesRef(coupleId, conversationId).document(messageId).update("deleted", true).voidAwait()
|
||||||
|
val latest = runCatching {
|
||||||
|
messagesRef(coupleId, conversationId)
|
||||||
|
.orderBy("createdAt", Query.Direction.DESCENDING).limit(1).get().await()
|
||||||
|
}.getOrNull()
|
||||||
|
if (latest?.documents?.firstOrNull()?.id == messageId) {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId) ?: return
|
||||||
|
conversationsRef(coupleId).document(conversationId).set(
|
||||||
|
mapOf("lastMessagePreview" to fieldEncryptor.encrypt("Message deleted", aead, coupleId)),
|
||||||
|
SetOptions.merge()
|
||||||
|
).voidAwait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Emits the partner's most recent read timestamp for this conversation (0 if never read). */
|
/** Emits the partner's most recent read timestamp for this conversation (0 if never read). */
|
||||||
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
|
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
|
||||||
val listener = conversationsRef(coupleId).document(conversationId)
|
val listener = conversationsRef(coupleId).document(conversationId)
|
||||||
|
|
@ -216,12 +242,16 @@ class FirestoreConversationDataSource @Inject constructor(
|
||||||
): QuestionMessage? {
|
): QuestionMessage? {
|
||||||
val userId = getString("authorUserId") ?: return null
|
val userId = getString("authorUserId") ?: return null
|
||||||
val type = getString("type") ?: "text"
|
val type = getString("type") ?: "text"
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val reactions = (get("reactions") as? Map<String, String>).orEmpty()
|
||||||
return QuestionMessage(
|
return QuestionMessage(
|
||||||
id = id,
|
id = id,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
type = type,
|
type = type,
|
||||||
mediaUrl = getString("mediaUrl") ?: "",
|
mediaUrl = getString("mediaUrl") ?: "",
|
||||||
durationMs = getLong("durationMs") ?: 0L,
|
durationMs = getLong("durationMs") ?: 0L,
|
||||||
|
reactions = reactions,
|
||||||
|
deleted = getBoolean("deleted") ?: false,
|
||||||
text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "",
|
text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "",
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,12 @@ class ConversationRepositoryImpl @Inject constructor(
|
||||||
override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) =
|
override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) =
|
||||||
dataSource.sendVoiceMessage(coupleId, conversationId, userId, audioBytes, durationMs)
|
dataSource.sendVoiceMessage(coupleId, conversationId, userId, audioBytes, durationMs)
|
||||||
|
|
||||||
|
override suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) =
|
||||||
|
dataSource.setReaction(coupleId, conversationId, messageId, userId, emoji)
|
||||||
|
|
||||||
|
override suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) =
|
||||||
|
dataSource.deleteMessage(coupleId, conversationId, messageId)
|
||||||
|
|
||||||
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
|
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
|
||||||
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
|
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,12 @@ data class QuestionMessage(
|
||||||
val mediaUrl: String = "",
|
val mediaUrl: String = "",
|
||||||
/** Voice-note length in milliseconds (0 for non-voice). */
|
/** Voice-note length in milliseconds (0 for non-voice). */
|
||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
|
/** Emoji reactions keyed by reactor uid, e.g. {uid: "❤️"}. */
|
||||||
|
val reactions: Map<String, String> = emptyMap(),
|
||||||
|
/** True once the author has unsent (tombstoned) the message. */
|
||||||
|
val deleted: Boolean = false,
|
||||||
val createdAt: Long = 0L
|
val createdAt: Long = 0L
|
||||||
) {
|
) {
|
||||||
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
|
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() && !deleted
|
||||||
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank()
|
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() && !deleted
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,5 +14,7 @@ interface ConversationRepository {
|
||||||
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
|
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
|
||||||
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
|
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
|
||||||
suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long)
|
suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long)
|
||||||
|
suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?)
|
||||||
|
suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String)
|
||||||
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
|
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ fun ConversationScreen(
|
||||||
showAvatar = isLastInRun,
|
showAvatar = isLastInRun,
|
||||||
showTimestamp = isLastInRun,
|
showTimestamp = isLastInRun,
|
||||||
showSeen = seen,
|
showSeen = seen,
|
||||||
|
onReact = { emoji -> viewModel.react(message.id, emoji) },
|
||||||
|
onDelete = { viewModel.deleteMessage(message.id) },
|
||||||
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,24 @@ class ConversationViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle the caller's emoji reaction on a message (tap the same emoji again to remove). */
|
||||||
|
fun react(messageId: String, emoji: String) {
|
||||||
|
if (currentUserId.isEmpty()) return
|
||||||
|
val current = _uiState.value.messages.firstOrNull { it.id == messageId }?.reactions?.get(currentUserId)
|
||||||
|
val next = if (current == emoji) null else emoji
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.setReaction(coupleId, conversationId, messageId, currentUserId, next) }
|
||||||
|
.onFailure { _events.tryEmit("Couldn't add reaction.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessage(messageId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.deleteMessage(coupleId, conversationId, messageId) }
|
||||||
|
.onFailure { _events.tryEmit("Couldn't delete message.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun retryMedia(id: String) {
|
fun retryMedia(id: String) {
|
||||||
val data = retryStore[id] ?: return
|
val data = retryStore[id] ?: return
|
||||||
removePending(id)
|
removePending(id)
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -30,6 +32,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Image
|
import androidx.compose.material.icons.filled.Image
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
|
@ -37,6 +41,8 @@ import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.PhotoCamera
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
|
@ -59,7 +65,9 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
|
@ -83,6 +91,9 @@ import java.nio.ByteBuffer
|
||||||
* One chat row — Messenger style: only the partner's avatar shows (on the left); our own messages
|
* One chat row — Messenger style: only the partner's avatar shows (on the left); our own messages
|
||||||
* are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages.
|
* are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages.
|
||||||
*/
|
*/
|
||||||
|
val REACTION_EMOJIS = listOf("❤️", "😂", "👍", "😮", "😢", "🔥")
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessageRow(
|
fun ChatMessageRow(
|
||||||
message: QuestionMessage,
|
message: QuestionMessage,
|
||||||
|
|
@ -91,6 +102,10 @@ fun ChatMessageRow(
|
||||||
showAvatar: Boolean,
|
showAvatar: Boolean,
|
||||||
showTimestamp: Boolean,
|
showTimestamp: Boolean,
|
||||||
showSeen: Boolean = false,
|
showSeen: Boolean = false,
|
||||||
|
canReact: Boolean = true,
|
||||||
|
onReact: (String) -> Unit = {},
|
||||||
|
onReactBlocked: () -> Unit = {},
|
||||||
|
onDelete: () -> Unit = {},
|
||||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||||
) {
|
) {
|
||||||
val bubbleShape = if (isCurrentUser) {
|
val bubbleShape = if (isCurrentUser) {
|
||||||
|
|
@ -98,6 +113,8 @@ fun ChatMessageRow(
|
||||||
} else {
|
} else {
|
||||||
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
|
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
|
||||||
}
|
}
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -110,26 +127,57 @@ fun ChatMessageRow(
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
when {
|
Box(
|
||||||
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
|
modifier = if (message.deleted) Modifier
|
||||||
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
|
else Modifier.combinedClickable(onClick = {}, onLongClick = { menuOpen = true })
|
||||||
else -> Surface(
|
) {
|
||||||
shape = bubbleShape,
|
when {
|
||||||
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
|
message.deleted -> DeletedBubble(bubbleShape, isCurrentUser)
|
||||||
else MaterialTheme.colorScheme.surfaceVariant,
|
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
|
||||||
modifier = Modifier.widthIn(max = 264.dp)
|
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
|
||||||
) {
|
else -> Surface(
|
||||||
Text(
|
shape = bubbleShape,
|
||||||
text = message.text,
|
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
|
modifier = Modifier.widthIn(max = 264.dp)
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
) {
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
|
Text(
|
||||||
)
|
text = message.text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessageActionMenu(
|
||||||
|
expanded = menuOpen,
|
||||||
|
onDismiss = { menuOpen = false },
|
||||||
|
canCopy = message.type == "text",
|
||||||
|
canDelete = isCurrentUser,
|
||||||
|
onReact = { emoji ->
|
||||||
|
menuOpen = false
|
||||||
|
if (canReact) onReact(emoji) else onReactBlocked()
|
||||||
|
},
|
||||||
|
onCopy = {
|
||||||
|
menuOpen = false
|
||||||
|
clipboard.setText(AnnotatedString(message.text))
|
||||||
|
},
|
||||||
|
onDelete = { menuOpen = false; onDelete() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.reactions.isNotEmpty() && !message.deleted) {
|
||||||
|
ReactionChip(
|
||||||
|
emojis = message.reactions.values.toList(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(if (isCurrentUser) Alignment.End else Alignment.Start)
|
||||||
|
.padding(start = if (isCurrentUser) 0.dp else 40.dp, end = if (isCurrentUser) 6.dp else 0.dp, top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showTimestamp) {
|
if (showTimestamp) {
|
||||||
val time = formatClockTime(message.createdAt)
|
val time = formatClockTime(message.createdAt)
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -144,6 +192,81 @@ fun ChatMessageRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeletedBubble(shape: Shape, isCurrentUser: Boolean) {
|
||||||
|
Surface(
|
||||||
|
shape = shape,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.widthIn(max = 264.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "This message was deleted",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReactionChip(emojis: List<String>, modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = emojis.distinct().joinToString("") + if (emojis.size > 1) " ${emojis.size}" else "",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageActionMenu(
|
||||||
|
expanded: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
canCopy: Boolean,
|
||||||
|
canDelete: Boolean,
|
||||||
|
onReact: (String) -> Unit,
|
||||||
|
onCopy: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
REACTION_EMOJIS.forEach { emoji ->
|
||||||
|
Text(
|
||||||
|
text = emoji,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable { onReact(emoji) }
|
||||||
|
.padding(6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canCopy) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Copy") },
|
||||||
|
onClick = onCopy,
|
||||||
|
leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canDelete) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Delete") },
|
||||||
|
onClick = onDelete,
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A centered date pill shown between messages from different days. */
|
/** A centered date pill shown between messages from different days. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatDaySeparator(epochMillis: Long) {
|
fun ChatDaySeparator(epochMillis: Long) {
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,7 @@ service cloud.firestore {
|
||||||
allow create: if isCouplesMember(coupleId)
|
allow create: if isCouplesMember(coupleId)
|
||||||
&& coupleEncryptionEnabled(coupleId)
|
&& coupleEncryptionEnabled(coupleId)
|
||||||
&& request.resource.data.authorUserId == request.auth.uid
|
&& request.resource.data.authorUserId == request.auth.uid
|
||||||
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs'])
|
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs', 'reactions', 'deleted'])
|
||||||
&& (
|
&& (
|
||||||
(request.resource.data.get('type', 'text') in ['image', 'voice']
|
(request.resource.data.get('type', 'text') in ['image', 'voice']
|
||||||
&& request.resource.data.mediaUrl is string
|
&& request.resource.data.mediaUrl is string
|
||||||
|
|
@ -442,7 +442,15 @@ service cloud.firestore {
|
||||||
(request.resource.data.get('type', 'text') == 'text'
|
(request.resource.data.get('type', 'text') == 'text'
|
||||||
&& isCiphertext(request.resource.data.text))
|
&& isCiphertext(request.resource.data.text))
|
||||||
);
|
);
|
||||||
allow update, delete: if false;
|
// Reactions: any couple member may change ONLY the reactions map.
|
||||||
|
// Unsend: only the author may set the `deleted` tombstone.
|
||||||
|
allow update: if isCouplesMember(coupleId) && (
|
||||||
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions'])
|
||||||
|
||
|
||||||
|
(resource.data.authorUserId == request.auth.uid
|
||||||
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleted']))
|
||||||
|
);
|
||||||
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue