Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MetadataUpdate
import com.flipcash.services.models.chat.ReactionSummary
import com.flipcash.services.models.chat.ReactionUpdate
import com.flipcash.services.models.chat.Reactor
import com.flipcash.services.models.chat.RosterChange
import com.flipcash.services.models.chat.TypingNotification
import com.flipcash.services.models.chat.TypingState
Expand Down Expand Up @@ -358,6 +359,13 @@ class EventStreamDelegate @Inject constructor(
metaUpdate.newLastActivity.toEpochMilliseconds(),
)
}
is MetadataUpdate.ViewerStateChanged -> {
// The receiving user's own chat state (currently just mute) changed.
// Persisting it needs a chat_metadata column/migration that belongs
// with the muting feature's storage work, not this contract sync, so
// the update is acknowledged here without a write. Wire this up to
// metadataDataSource once that column exists.
}
}
}

Expand Down Expand Up @@ -448,23 +456,40 @@ class EventStreamDelegate @Inject constructor(
val existing = overlays[update.messageId]
val existingReactions = existing?.reactions?.toMutableList() ?: mutableListOf()

// The server does not report a "self" flag on the update itself, so the client
// maintains EmojiReaction.selfReactor by comparing the update's actor to the
// signed-in user: an ADDED update from self sets it, a REMOVED update from self
// clears it, and an update from anyone else leaves the existing self state alone.
val isSelfActor = update.actor == userManager.accountId

val idx = existingReactions.indexOfFirst { it.emoji == update.emoji }
if (idx >= 0) {
val current = existingReactions[idx]
if (update.sequence <= current.sequence) return
val selfReactor = when {
isSelfActor && update.action == ReactionUpdate.Action.ADDED ->
Reactor(userId = update.actor, reactedAt = update.reactedAt, version = current.selfReactor?.version ?: 0)
isSelfActor && update.action == ReactionUpdate.Action.REMOVED -> null
else -> current.selfReactor
}
existingReactions[idx] = EmojiReaction(
emoji = update.emoji,
count = update.count,
reactedBySelf = current.reactedBySelf,
selfReactor = selfReactor,
sampleReactors = current.sampleReactors,
sequence = update.sequence,
)
} else {
val selfReactor = if (isSelfActor && update.action == ReactionUpdate.Action.ADDED) {
Reactor(userId = update.actor, reactedAt = update.reactedAt)
} else {
null
}
existingReactions.add(
EmojiReaction(
emoji = update.emoji,
count = update.count,
reactedBySelf = false,
selfReactor = selfReactor,
sampleReactors = emptyList(),
sequence = update.sequence,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ data class ReactionSummarySerialized(
data class EmojiReactionSerialized(
val emoji: String,
val count: Long,
val reactedBySelf: Boolean,
val selfReactor: ReactorSerialized? = null,
val sampleReactors: List<ReactorSerialized>,
val sequence: Long,
)
Expand All @@ -266,6 +266,7 @@ data class EmojiReactionSerialized(
data class ReactorSerialized(
val userIdHex: String,
val reactedAtEpochSeconds: Long,
val version: Long = 0,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class ChatTypeConvertersTest {
EmojiReactionSerialized(
emoji = "\uD83D\uDE00",
count = 3,
reactedBySelf = true,
selfReactor = ReactorSerialized(userIdHex = "1234", reactedAtEpochSeconds = 1500L),
sampleReactors = listOf(
ReactorSerialized(userIdHex = "aabb", reactedAtEpochSeconds = 1000L),
ReactorSerialized(userIdHex = "ccdd", reactedAtEpochSeconds = 2000L),
Expand All @@ -29,7 +29,7 @@ class ChatTypeConvertersTest {
EmojiReactionSerialized(
emoji = "\uD83D\uDC4D",
count = 1,
reactedBySelf = false,
selfReactor = null,
sampleReactors = emptyList(),
sequence = 3,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,15 @@ private fun ReactionSummary.toSerialized(): ReactionSummarySerialized = Reaction
private fun EmojiReaction.toSerialized(): EmojiReactionSerialized = EmojiReactionSerialized(
emoji = emoji.value,
count = count,
reactedBySelf = reactedBySelf,
selfReactor = selfReactor?.toSerialized(),
sampleReactors = sampleReactors.map { it.toSerialized() },
sequence = sequence,
)

private fun Reactor.toSerialized(): ReactorSerialized = ReactorSerialized(
userIdHex = userId.hexEncodedString(),
reactedAtEpochSeconds = reactedAt.epochSeconds,
version = version,
)

private fun ReactionSummarySerialized.toDomain(): ReactionSummary = ReactionSummary(
Expand All @@ -407,14 +408,15 @@ private fun ReactionSummarySerialized.toDomain(): ReactionSummary = ReactionSumm
private fun EmojiReactionSerialized.toDomain(): EmojiReaction = EmojiReaction(
emoji = Emoji(emoji),
count = count,
reactedBySelf = reactedBySelf,
selfReactor = selfReactor?.toDomain(),
sampleReactors = sampleReactors.map { it.toDomain() },
sequence = sequence,
)

private fun ReactorSerialized.toDomain(): Reactor = Reactor(
userId = userIdHex.hexToIdExt(),
reactedAt = Instant.fromEpochSeconds(reactedAtEpochSeconds),
version = version,
)

// endregion
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.3"
# 0.3.0 is the first release of either package to ship R8 keep rules for its generated
# messages, which is what lets proguard-rules.pro drop its own.
ocp-client-protocol = "0.5.0"
flipcash2-client-protocol = "0.9.0"
flipcash2-client-protocol = "0.10.0"

# The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's
# `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMetadata
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.StartChatParameters
import com.flipcash.services.models.chat.ViewerState
import com.flipcash.services.models.chat.ViewMode
import com.flipcash.services.repository.ChatRepository
import com.flipcash.services.user.UserManager
import javax.inject.Inject
Expand All @@ -17,11 +20,14 @@ class ChatController @Inject constructor(
private val repository: ChatRepository,
private val userManager: UserManager,
) {
suspend fun getChat(chatId: ChatId): Result<ChatMetadata> {
suspend fun getChat(
chatId: ChatId,
viewMode: ViewMode = ViewMode.FULL,
): Result<ChatMetadata> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.getChat(owner, chatId)
return repository.getChat(owner, chatId, viewMode)
}

suspend fun getDmChatFeed(
Expand Down Expand Up @@ -75,4 +81,20 @@ class ChatController @Inject constructor(

return repository.leaveChat(owner, chatId)
}

/** Mutes [chatId] for the caller per [mute], returning the caller's new viewer state. */
suspend fun muteChat(chatId: ChatId, mute: MuteState): Result<ViewerState> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.muteChat(owner, chatId, mute)
}

/** Clears any mute on [chatId] for the caller, returning the caller's new viewer state. */
suspend fun unmuteChat(chatId: ChatId): Result<ViewerState> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.unmuteChat(owner, chatId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.ReactionSummary
import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.models.chat.ViewMode
import com.flipcash.services.repository.ChatMessagingRepository
import com.flipcash.services.repository.DeltaUpdate
import com.flipcash.services.repository.ReactorsPage
Expand All @@ -26,24 +27,40 @@ class ChatMessagingController @Inject constructor(
private fun requireOwner() = userManager.accountCluster?.authority?.keyPair
?: throw IllegalStateException("No account cluster in UserManager")

suspend fun getMessage(chatId: ChatId, messageId: Long): Result<ChatMessage> {
suspend fun getMessage(
chatId: ChatId,
messageId: Long,
viewMode: ViewMode = ViewMode.FULL,
): Result<ChatMessage> {
val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) }
return repository.getMessage(owner, chatId, messageId)
return repository.getMessage(owner, chatId, messageId, viewMode)
}

suspend fun getMessages(chatId: ChatId, queryOptions: QueryOptions = QueryOptions()): Result<List<ChatMessage>> {
suspend fun getMessages(
chatId: ChatId,
queryOptions: QueryOptions = QueryOptions(),
viewMode: ViewMode = ViewMode.FULL,
): Result<List<ChatMessage>> {
val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) }
return repository.getMessages(owner, chatId, queryOptions)
return repository.getMessages(owner, chatId, queryOptions, viewMode)
}

suspend fun getMessagesByIds(chatId: ChatId, messageIds: List<Long>): Result<List<ChatMessage>> {
suspend fun getMessagesByIds(
chatId: ChatId,
messageIds: List<Long>,
viewMode: ViewMode = ViewMode.FULL,
): Result<List<ChatMessage>> {
val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) }
return repository.getMessagesByIds(owner, chatId, messageIds)
return repository.getMessagesByIds(owner, chatId, messageIds, viewMode)
}

fun getDelta(chatId: ChatId, afterSequence: Long): Flow<Result<DeltaUpdate>> {
fun getDelta(
chatId: ChatId,
afterSequence: Long,
viewMode: ViewMode = ViewMode.FULL,
): Flow<Result<DeltaUpdate>> {
val owner = requireOwner()
return repository.getDelta(owner, chatId, afterSequence)
return repository.getDelta(owner, chatId, afterSequence, viewMode)
}

suspend fun sendMessage(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.flipcash.services.controllers

import com.flipcash.services.models.ReportTarget
import com.flipcash.services.repository.ReportingRepository
import com.flipcash.services.user.UserManager
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ReportingController @Inject constructor(
private val repository: ReportingRepository,
private val userManager: UserManager,
) {
/** Files a report against [target], with an optional free-form [description]. */
suspend fun report(target: ReportTarget, description: String = ""): Result<Unit> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.report(owner, target, description)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.flipcash.services.internal.network.services.ProfileService
import com.flipcash.services.internal.network.services.PurchaseService
import com.flipcash.services.internal.network.services.PushService
import com.flipcash.services.internal.network.services.ResolverService
import com.flipcash.services.internal.network.services.ReportingService
import com.flipcash.services.internal.network.services.SettingsService
import com.flipcash.services.internal.network.services.ThirdPartyService
import com.flipcash.services.internal.repositories.InternalAccountRepository
Expand All @@ -46,6 +47,7 @@ import com.flipcash.services.internal.repositories.InternalProfileRepository
import com.flipcash.services.internal.repositories.InternalPurchaseRepository
import com.flipcash.services.internal.repositories.InternalPushRepository
import com.flipcash.services.internal.repositories.InternalResolverRepository
import com.flipcash.services.internal.repositories.InternalReportingRepository
import com.flipcash.services.internal.repositories.InternalSettingsRepository
import com.flipcash.services.internal.repositories.InternalThirdPartyRepository
import com.flipcash.services.repository.AccountRepository
Expand All @@ -62,6 +64,7 @@ import com.flipcash.services.repository.PurchaseRepository
import com.flipcash.services.repository.PushRepository
import com.flipcash.services.repository.BlobStorageRepository
import com.flipcash.services.repository.ResolverRepository
import com.flipcash.services.repository.ReportingRepository
import com.flipcash.services.repository.SettingsRepository
import com.flipcash.services.repository.ThirdPartyRepository
import com.getcode.opencode.ProtocolConfig
Expand Down Expand Up @@ -254,4 +257,9 @@ internal object FlipcashModule {
textModerationResponseMapper,
imageModerationResponseMapper
)

@Provides
internal fun providesReportingRepository(
service: ReportingService,
): ReportingRepository = InternalReportingRepository(service)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.flipcash.services.internal.network.extensions.toId
import com.flipcash.services.internal.network.extensions.toMediaItem
import com.flipcash.services.internal.network.extensions.toPointer
import com.flipcash.services.internal.network.extensions.toRosterSummary
import com.flipcash.services.internal.network.extensions.toViewerState
import com.flipcash.services.models.chat.ChatMember
import com.flipcash.services.models.chat.ChatMetadata
import kotlin.time.Instant
Expand Down Expand Up @@ -42,6 +43,7 @@ class ChatMetadataMapper @Inject constructor(
picture = if (from.hasPicture()) from.picture.toMediaItem() else null,
rosterSummary = from.rosterSummary.toRosterSummary(),
rules = if (from.hasRules()) from.rules.toChatRules() else null,
viewerState = if (from.hasViewerState()) from.viewerState.toViewerState() else null,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel
import com.flipcash.services.internal.network.extensions.asChatId
import com.flipcash.services.internal.network.extensions.asProtoChatType
import com.flipcash.services.internal.network.extensions.asProtoIdempotencyKey
import com.flipcash.services.internal.network.extensions.asProtoMuteState
import com.flipcash.services.internal.network.extensions.asProtoRules
import com.flipcash.services.internal.network.extensions.asQueryOptions
import com.flipcash.services.internal.network.extensions.asViewMode
import com.flipcash.services.internal.network.extensions.authenticate
import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.StartChatParameters
import com.flipcash.services.models.chat.ViewMode
import com.getcode.utils.toByteString
import com.getcode.ed25519.Ed25519.KeyPair
import com.getcode.opencode.internal.network.core.GrpcApi
Expand All @@ -37,9 +41,11 @@ internal class ChatApi @Inject constructor(
suspend fun getChat(
owner: KeyPair,
chatId: ChatId,
viewMode: ViewMode = ViewMode.FULL,
): RpcChatService.GetChatResponse {
val request = RpcChatService.GetChatRequest.newBuilder()
.setChatId(chatId.asChatId())
.setViewMode(viewMode.asViewMode())
.apply { setAuth(authenticate(owner)) }
.build()

Expand Down Expand Up @@ -154,4 +160,38 @@ internal class ChatApi @Inject constructor(
api.leaveChat(request)
}
}

suspend fun muteChat(
owner: KeyPair,
chatId: ChatId,
mute: MuteState,
): RpcChatService.MuteChatResponse {
val request = RpcChatService.MuteChatRequest.newBuilder()
.setChatId(chatId.asChatId())
.setMute(mute.asProtoMuteState())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

return withContext(Dispatchers.IO) {
api.muteChat(request)
}
}

suspend fun unmuteChat(
owner: KeyPair,
chatId: ChatId,
): RpcChatService.UnmuteChatResponse {
val request = RpcChatService.UnmuteChatRequest.newBuilder()
.setChatId(chatId.asChatId())
.apply { setAuth(authenticate(owner)) }
.build()

request.validate().orThrow()

return withContext(Dispatchers.IO) {
api.unmuteChat(request)
}
}
}
Loading
Loading