From 4f4b5403f856f8962709ba828dc16fd2fa3284ae Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 24 Jun 2026 13:01:44 -0400 Subject: [PATCH 1/8] chore(protos): update flipcash protobuf definitions New Messaging RPCs: GetDelta, EditMessage, DeleteMessage, AddReaction, RemoveReaction, GetReactors, GetReactionSummary, GetReactionSummaries. New content types: ReplyContent, MediaContent, SystemContent, DeletedContent. New models: Event, Mutation, EventBatch, ReactionSummary, EmojiReaction, ReactionUpdate, Emoji, Reactor, MediaItem, MediaMetadata, MediaId. ChatUpdate.new_messages deprecated in favor of EventBatch events. Message gains last_edited_ts, event_sequence, reactions fields. Metadata gains latest_event_sequence field. Signed-off-by: Brandon McAnsh --- .../protos/src/main/proto/chat/v1/model.proto | 11 + .../src/main/proto/event/v1/model.proto | 30 +- .../messaging/v1/messaging_service.proto | 346 +++++++++++++++++- .../src/main/proto/messaging/v1/model.proto | 315 +++++++++++++++- 4 files changed, 694 insertions(+), 8 deletions(-) diff --git a/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto b/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto index 7256ca6d2..dacd3564c 100644 --- a/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto @@ -32,6 +32,17 @@ message Metadata { // The timestamp of the last activity in this chat google.protobuf.Timestamp last_activity = 5 [(validate.rules).timestamp.required = true]; + + // The chat's head event sequence — the value of the most recent event in its + // event log. A client compares this against its locally stored cursor for + // the chat to decide whether catch-up is needed: if its cursor is behind, it + // calls Messaging.GetDelta; if equal, it is current and can skip it. + // + // This is NOT derivable from last_message: an edit or deletion of an older + // message advances the head without changing last_message, so this value can + // exceed last_message.event_sequence. It is the same head reported by + // GetDeltaResponse.latest_sequence. + uint64 latest_event_sequence = 6; } message Member { diff --git a/definitions/flipcash/protos/src/main/proto/event/v1/model.proto b/definitions/flipcash/protos/src/main/proto/event/v1/model.proto index 6262a4995..ddb145345 100644 --- a/definitions/flipcash/protos/src/main/proto/event/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/event/v1/model.proto @@ -79,17 +79,37 @@ message ChatUpdate { // The chat that this update is for common.v1.ChatId chat = 1 [(validate.rules).message.required = true]; - // If present, new real-time messages sent on the chat - messaging.v1.MessageBatch new_messages = 2; - - // If present, message pointer updates for members in the chat + // If present, new real-time messages sent on the chat. + // + // Deprecated: superseded by `events` (Event.message_sent), which is + // sequenced and gap-detectable. New messages now arrive as events. + messaging.v1.MessageBatch new_messages = 2 [deprecated = true]; + + // If present, message pointer updates for members in the chat. Pointers are + // convergent (monotonic, last-writer-wins), so they ride the stream as a + // best-effort overlay and are reconciled from current state on reconnect — + // they are intentionally NOT part of the gap-detected event log. messaging.v1.PointerBatch pointer_updates = 3; - // If present, message typing notification state changes for members in the chat + // If present, message typing notification state changes for members in the + // chat. Transient and best-effort — not part of the event log. messaging.v1.IsTypingNotificationBatch is_typing_notifications = 4; // If present, updates to the chat metadata repeated chat.v1.MetadataUpdate metadata_updates = 5 [(validate.rules).repeated = { max_items: 1024 // Arbitrary }]; + + // If present, durable event-log events for the chat (messages sent, edited, + // and deleted). These are contiguous and ordered: clients apply them by + // ascending Event.sequence and gap-detect via Event.sequence/count, catching + // up with Messaging.GetDelta on a gap. This supersedes new_messages. + messaging.v1.EventBatch events = 6; + + // If present, best-effort real-time reaction changes for messages in the + // chat. Like pointer_updates, reactions are a convergent overlay — NOT part + // of the gap-detected event log; clients apply them last-writer-wins by + // ReactionUpdate.sequence and reconcile any misses by refreshing a message's + // ReactionSummary on view. + messaging.v1.ReactionUpdateBatch reaction_updates = 7; } diff --git a/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto b/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto index 975451c6a..c392a8d13 100644 --- a/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto +++ b/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto @@ -14,12 +14,76 @@ service Messaging { // GetMessage gets a single message in a chat rpc GetMessage(GetMessageRequest) returns (GetMessageResponse); - // GetMessages gets the set of messages for a chat using a paged and batched APIs + // GetMessages gets the set of messages for a chat using paged and batched APIs rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse); + // GetDelta returns, for cold-boot and reconnect catch-up, the current state + // of every message changed since the client's cursor, up to the chat's + // current head. It is a state delta, not a contiguous replay: each changed + // message appears once in its latest state and the client applies it + // last-writer-wins. Transient signals (typing) and convergent state + // (pointers, reactions) are fetched separately, not returned here. + // + // GetDelta always catches up to the head; there is no caller-specified + // upper bound. An online client that detects a gap while already receiving + // live updates does NOT bound the fetch: it calls GetDelta to the head and + // lets last-writer-wins (Message.event_sequence) absorb the overlap with + // live events buffered during the call — a message delivered by both paths + // is applied once, newest wins. A client may also wait briefly for an + // out-of-order live update to close a small gap before calling at all. + // + // On stream completion the client advances its cursor to the highest + // checkpoint_sequence it received, which equals latest_sequence — the client + // is now at the head. When the client is already current the server sends a + // single response with messages omitted (and checkpoint_sequence unset), + // leaving the cursor unchanged; latest_sequence still reports the head. + // + // This is a BOUNDED server stream: the server emits one or more batches and + // then completes once the delta up to the head (as of stream open) is + // exhausted. Unlike StreamEvents it does NOT stay open for live updates. + // Streaming the delta in batches avoids a per-page round trip; the server may + // currently send the whole delta as a single response, so clients must handle + // any number of batches and treat stream completion as "caught up." + // + // The Result field is meaningful on the first response and is OK for + // subsequent data batches; a terminal DENIED or RESET_REQUIRED is delivered + // as a single response that ends the stream. + rpc GetDelta(GetDeltaRequest) returns (stream GetDeltaResponse); + // SendMessage sends a message to a chat. rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); + // EditMessage edits the content of a message the caller previously sent. + rpc EditMessage(EditMessageRequest) returns (EditMessageResponse); + + // DeleteMessage deletes a message the caller previously sent. The message is + // tombstoned (content replaced with DeletedContent), not removed, so the + // per-chat MessageId sequence stays gapless. + rpc DeleteMessage(DeleteMessageRequest) returns (DeleteMessageResponse); + + // AddReaction adds the caller's reaction with a given emoji to a message. + // Idempotent: re-adding the same emoji the caller already reacted with is a + // no-op success. + rpc AddReaction(AddReactionRequest) returns (AddReactionResponse); + + // RemoveReaction removes the caller's reaction with a given emoji from a + // message. Idempotent: removing a reaction the caller does not have is a + // no-op success. + rpc RemoveReaction(RemoveReactionRequest) returns (RemoveReactionResponse); + + // GetReactors returns the paged list of users who reacted to a message with + // a given emoji — the on-demand drill-down behind EmojiReaction.count, which + // never inlines the full reactor list. + rpc GetReactors(GetReactorsRequest) returns (GetReactorsResponse); + + // GetReactionSummary fetches the current aggregate reaction state for a + // single message. + rpc GetReactionSummary(GetReactionSummaryRequest) returns (GetReactionSummaryResponse); + + // GetReactionSummaries fetches the current aggregate reaction state using + // paged and batched APIs + rpc GetReactionSummaries(GetReactionSummariesRequest) returns (GetReactionSummariesResponse); + // AdvancePointer advances a pointer in message history for a chat member. rpc AdvancePointer(AdvancePointerRequest) returns (AdvancePointerResponse); @@ -72,11 +136,67 @@ message GetMessagesResponse { MessageBatch messages = 2; } +message GetDeltaRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + // The latest event sequence the client has already applied. The server + // returns the current state of messages whose event_sequence is greater than + // this value, up to the current head. Use 0 to fetch from the beginning of + // the retained log. + uint64 after_sequence = 2; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetDeltaResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + // after_sequence is older than the oldest state the server can still + // resolve a delta for. The client must discard its cursor and re-sync + // chat history from GetMessages before resuming the event stream. + RESET_REQUIRED = 2; + } + + // A batch of changed messages in STRICTLY ASCENDING event_sequence order, + // continuing in order across batches (every sequence in a batch is higher + // than every sequence in the prior batch). Across the whole stream this is + // the current state of every message changed since after_sequence, up to the + // head; a message normally appears once in its latest state, but one + // re-edited mid-stream may reappear at its new, higher sequence (apply + // last-writer-wins). Omitted (not an empty batch) when there are no changes + // to report — e.g. when the client is already current; the server still + // sets latest_sequence and the stream then completes. Note MessageBatch + // itself requires at least one message, so "no changes" is signaled by + // leaving this field unset, never by an empty batch. + MessageBatch messages = 2; + + // The chat's latest event sequence (head) as of stream open — the target this + // catch-up converges to. Informational while streaming: it tells the client + // how far the chat has advanced before the final batch arrives. Once the + // stream completes, the client's cursor equals this; it does not need + // contiguous coverage of the intervening points, only the resulting state. + uint64 latest_sequence = 3; + + // Resume checkpoint: the event_sequence through which the delta is complete + // as of this batch — the batch's high-water mark, monotonically increasing + // across the stream toward latest_sequence. Persist it AFTER fully applying + // the batch. If the stream drops mid-catch-up, resume by calling GetDelta + // again with after_sequence set to the last checkpoint_sequence received. + // Because event_sequence only ever increases, this resumes exactly where you + // left off with no skipped messages (and at worst a harmless last-writer-wins + // re-apply of the boundary). + uint64 checkpoint_sequence = 4; +} + message SendMessageRequest { common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; // Allowed content types that can be sent by client: // - TextContent + // - ReplyContent + // - MediaContent repeated Content content = 2 [(validate.rules).repeated = { min_items: 1 max_items: 1 @@ -102,6 +222,230 @@ message SendMessageResponse { Message message = 2; } +message EditMessageRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // The new content for the message. Allowed content types match SendMessage: + // - TextContent + // - ReplyContent + // - MediaContent + repeated Content content = 3 [(validate.rules).repeated = { + min_items: 1 + max_items: 1 + }]; + + // Required optimistic-concurrency guard: the message's event_sequence as the + // client last observed it. The server applies the edit only if the message's + // current event_sequence still equals this value, and returns CONFLICT + // otherwise — so an edit based on a stale version (e.g. a concurrent + // edit/delete from the sender's other device) is rejected rather than + // clobbering the newer state. There is no last-writer-wins path. + uint64 expected_event_sequence = 4 [(validate.rules).uint64.gte = 1]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message EditMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + CANNOT_EDIT = 3; + // The message changed since expected_event_sequence (a concurrent + // edit/delete won). The edit was not applied; `message` carries the + // current state for the client to reconcile against and retry. + CONFLICT = 4; + } + + // On OK, the updated materialized message (advanced event_sequence, + // last_edited_ts set). On CONFLICT, the message's current state. + Message message = 2; +} + +message DeleteMessageRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // Required optimistic-concurrency guard: the message's event_sequence as the + // client last observed it. The server applies the delete only if the + // message's current event_sequence still equals this value, and returns + // CONFLICT otherwise — so a delete based on a stale version is rejected + // rather than racing a concurrent edit/delete. There is no + // last-writer-wins path. + uint64 expected_event_sequence = 3 [(validate.rules).uint64.gte = 1]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message DeleteMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + CANNOT_DELETE = 3; + // The message changed since expected_event_sequence (a concurrent + // edit/delete won). The delete was not applied; `message` carries the + // current state for the client to reconcile against and retry. + CONFLICT = 4; + } + + // On OK, the tombstoned materialized message (content replaced with + // DeletedContent, event_sequence advanced). On CONFLICT, the current state. + Message message = 2; +} + +message AddReactionRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // The emoji to react with. + Emoji emoji = 3 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message AddReactionResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + CANNOT_REACT = 3; + // Adding this emoji would exceed the per-message distinct reaction-type + // cap. Reactions to emojis already present on the message are unaffected. + TOO_MANY_REACTION_TYPES = 4; + } + + // The affected emoji's aggregate after the add (count, reacted_by_self true). + EmojiReaction reaction = 2; +} + +message RemoveReactionRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // The emoji whose reaction to remove for the caller. + Emoji emoji = 3 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message RemoveReactionResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } + + // The affected emoji's aggregate after the removal + EmojiReaction reaction = 2; +} + +message GetReactorsRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // The emoji whose reactors to list. + Emoji emoji = 3 [(validate.rules).message.required = true]; + + // Paging over the reactor list (server-ordered, typically most-recent + // first). Leave options.paging_token unset on the first request; on every + // subsequent request, set it to the paging_token from the most recent + // response to advance through the list. The token is opaque and + // server-generated; do not construct it. + common.v1.QueryOptions options = 4; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetReactorsResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } + + // A page of users who reacted with the requested emoji, with their reaction + // timestamps. Empty when the message exists but has no reactors for the emoji. + repeated Reactor reactors = 2 [(validate.rules).repeated = { + max_items: 100 + }]; + + // The server-generated cursor advanced past this page. The client MUST send + // the most recent value back in options.paging_token on the next + // GetReactorsRequest to fetch the following page. Set when result is OK and + // has_more is true. + common.v1.PagingToken paging_token = 3; + + // HasMore indicates whether further pages of reactors remain. When false, + // the reactor list has been fully read. When true, the client should issue + // another GetReactorsRequest with the returned paging_token. + bool has_more = 4; +} + +message GetReactionSummaryRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetReactionSummaryResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } + + // The aggregate reaction state for the message. reacted_by_self is computed + // for the caller; clients still apply per (message, emoji) by + // EmojiReaction.sequence, so a summary that is slightly behind a live update + // is harmlessly ignored rather than regressing state. + ReactionSummary summary = 2; +} + +message GetReactionSummariesRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + oneof query { + option (validate.required) = true; + + common.v1.QueryOptions options = 2; + MessageIdBatch message_ids = 3; + } + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetReactionSummariesResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } + + // One summary per requested message, keyed by ReactionSummary.message_id. + // reacted_by_self in each summary is computed for the caller; clients still + // apply per (message, emoji) by EmojiReaction.sequence, so a summary that + // is slightly behind a live update is harmlessly ignored rather than regressing + // state. + repeated ReactionSummary summaries = 2 [(validate.rules).repeated = { + max_items: 100 + }]; +} + message AdvancePointerRequest { common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; diff --git a/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto b/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto index f4b53492d..9ba8b88b5 100644 --- a/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto @@ -62,6 +62,34 @@ message Message { // client as the difference between the latest message's unread_seq and the // unread_seq of the message at their READ pointer. uint64 unread_seq = 5; + + // If set, the timestamp this message was last edited at. Absent on messages + // that have never been edited. The content above always reflects the + // current (materialized) state, so clients render it directly; this field + // only drives an "edited" affordance. Deletions are represented in content + // via DeletedContent, not here. + google.protobuf.Timestamp last_edited_ts = 6; + + // The event-log sequence at which this message reached its current state: + // the point of the most recent mutation (send, edit, or delete) affecting + // it. A per-message VERSION stamp — distinct from message_id (fixed + // identity/order) and unread_seq (unread accounting) — that advances on + // every edit/delete while message_id stays fixed. + // + // It makes a Message self-locating regardless of how it was obtained (event + // stream, GetMessages, SendMessage echo, last_message, push). Clients apply + // last-writer-wins by this value: ignore a copy whose event_sequence is <= + // the version already held, otherwise insert/replace. Cross-message gap + // detection is separate, via the live event log's Event.sequence/count and + // GetDelta catch-up. + uint64 event_sequence = 7 [(validate.rules).uint64.gte = 1]; + + // Aggregate reaction state for this message, current as of the time it was + // read. This is a convergent overlay, NOT part of the content versioned by + // event_sequence: reactions change without advancing event_sequence, so + // clients refresh it on view and via live reaction updates rather than + // through the event log. + ReactionSummary reactions = 8; } // Content for a chat message @@ -69,8 +97,12 @@ message Content { oneof type { option (validate.required) = true; - TextContent text = 1; - CashContent cash = 2; + TextContent text = 1; + CashContent cash = 2; + ReplyContent reply = 3; + MediaContent media = 4; + SystemContent system = 5; + DeletedContent deleted = 6; } } @@ -82,6 +114,7 @@ message TextContent { }]; } +// Cash content message CashContent { // Intent ID identifying the cash transaction at the OCP layer common.v1.IntentId intent_id = 1 [(validate.rules).message.required = true]; @@ -93,6 +126,218 @@ message CashContent { reserved 3; } +// Reply content +message ReplyContent { + // ID of the message being replied to + MessageId replied_message_id = 1 [(validate.rules).message.required = true]; + + // Reply message content. Allowed content types are: + // - TextContent + // - MediaContent + repeated Content content = 2 [(validate.rules).repeated = { + min_items: 1 + max_items: 1 + }]; +} + +// Media content (images, video, etc.) +message MediaContent { + // The media items attached to this message + repeated MediaItem items = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 1 + }]; + + // Optional caption rendered alongside the media + TextContent caption = 2; +} + +message MediaItem { + // Client-provided reference to media already uploaded out-of-band + MediaId media_id = 1 [(validate.rules).message.required = true]; + + // Server-authoritative metadata, resolved from the upload record. It is + // omitted on SendMessage and populated on stored/returned messages + MediaMetadata metadata = 2; +} + +message MediaId { + bytes value = 1 [(validate.rules).bytes = { + min_len: 16 + max_len: 16 + }]; +} + +// Server-authoritative metadata describing an uploaded media item. Never set by +// clients; the server derives every field from the uploaded bytes. +message MediaMetadata { + // MIME type (e.g. "image/jpeg", "video/mp4") + string mime_type = 1 [(validate.rules).string = { + min_len: 1 + max_len: 255 + }]; + + // Total size of the media in bytes. + uint64 size_bytes = 2 [(validate.rules).uint64.gte = 1]; + + // Pixel dimensions, for reserving layout before the bytes arrive. + uint32 width = 3 [(validate.rules).uint32.gte = 1]; + uint32 height = 4 [(validate.rules).uint32.gte = 1]; + + // Compact preview shown while the full media downloads (BlurHash string). + string blurhash = 5 [(validate.rules).string.max_len = 64]; + + // Duration in milliseconds for audio/video; 0 for stills. + uint64 duration_ms = 6; +} + +// System message content +message SystemContent { + // Best-effort, server-rendered text in the user's locale setting. Today this + // is the only way to display a system message; once the structured `event` + // oneof exists it becomes a fallback, rendered ONLY when the client does not + // recognize the variant (old client, new server). It is not localized per + // viewer — clients that know a variant render their own localized string. + string fallback_text = 1 [(validate.rules).string = { + min_len: 1 + max_len: 256 + }]; + + // todo: Define events once we have them +} + +// Deleted message content +message DeletedContent { + // Timestamp the message was deleted. Set whenever a message is tombstoned; + // clients can surface it as a "deleted" affordance. This is the deletion + // analog of Message.last_edited_ts, kept here so all deletion state lives in + // the content rather than as a separate flag on Message. + google.protobuf.Timestamp deleted_ts = 1 [(validate.rules).timestamp.required = true]; + + // When present, the user that deleted the message. If not present, a it is + // a system-level deletion (eg. moderation check). + common.v1.UserId deleted_by = 2; +} + +// Emoji identifies an emoji used in a reaction. The value is a unicode emoji +// sequence — a single grapheme cluster, which may include modifiers such as a +// skin-tone selector or ZWJ joins — or a custom emoji identifier where +// supported. +message Emoji { + // Structural bounds only — these bound size as defense-in-depth (min_len/ + // max_len count code points, max_bytes counts bytes; a complex ZWJ or + // tag-flag sequence is ~8 code points but ~32 bytes, so both earn their + // keep). True emoji validity (a real grapheme, normalization, any supported + // set) is enforced in server code, not here. + string value = 1 [(validate.rules).string = { + min_len: 1 + max_len: 32 + max_bytes: 128 + }]; +} + +// Reactor identifies a user who reacted to a message and when they did so. +message Reactor { + common.v1.UserId user_id = 1 [(validate.rules).message.required = true]; + + // Timestamp the user added this reaction. + google.protobuf.Timestamp reacted_ts = 2 [(validate.rules).timestamp.required = true]; +} + +// ReactionSummary is the aggregate reaction state attached to a message. It is +// bounded: the number of distinct reaction types per message is capped, so the +// summary stays small no matter how many users reacted. The full reactor list +// for any emoji is fetched on demand (paged), never inlined here. +message ReactionSummary { + // The message these reactions belong to. + MessageId message_id = 1 [(validate.rules).message.required = true]; + + // One entry per distinct emoji reacted to this message + repeated EmojiReaction reactions = 2; +} + +// EmojiReaction aggregates all reactions of a single emoji on a message. +message EmojiReaction { + // The emoji reacted with. + Emoji emoji = 1 [(validate.rules).message.required = true]; + + // Total number of users who reacted with this emoji. Authoritative and may + // be arbitrarily large; the individual reactor identities are not all + // returned here. + uint64 count = 2; + + // Whether the requesting user reacted with this emoji. Per-viewer: count and + // sample_reactors are shareable across users, but this bit is computed for + // the caller. + bool reacted_by_self = 3; + + // A small sample of reactors, with their reaction timestamps (e.g. for + // rendering a few avatars), capped well below count. The complete, paged + // reactor list is fetched on demand via GetReactors. + repeated Reactor sample_reactors = 4 [(validate.rules).repeated = { + max_items: 8 // Sample only; not the full list + }]; + + // Monotonic version of this emoji's aggregate on the message, assigned by + // the server and advanced on every change to it. Ordering only: clients + // apply reaction updates last-writer-wins by this value per (message, emoji) + // — and per actor for reacted_by_self — and treat a loaded summary as stale + // when a higher sequence arrives. It is NOT the chat event sequence + // (reactions never advance that), and it is NOT gapless: it carries no + // gap-detection meaning. + uint64 sequence = 5 [(validate.rules).uint64.gte = 1]; +} + +// ReactionUpdate is a best-effort, real-time reaction change for a single +// (message, emoji) cell. Reactions are a convergent overlay, so these ride the +// event stream OUTSIDE the gap-detected event log — a missed update is not +// caught up via GetDelta but reconciled by refreshing the message's +// ReactionSummary on view. +message ReactionUpdate { + // The message whose reactions changed. + MessageId message_id = 1 [(validate.rules).message.required = true]; + + // The emoji that was added or removed. + Emoji emoji = 2 [(validate.rules).message.required = true]; + + // The user who added or removed the reaction. A client renders + // reacted_by_self by comparing this to itself, so a reaction made on the + // user's other device is reflected. + common.v1.UserId actor = 3 [(validate.rules).message.required = true]; + + Action action = 4 [(validate.rules).enum = { + not_in: [0] + }]; + enum Action { + UNKNOWN = 0; + ADDED = 1; + REMOVED = 2; + } + + // The emoji's total reactor count after this change. 0 means no reactors + // remain and the client should drop the entry from the summary. + uint64 count = 5; + + // The emoji aggregate's new version after this change. Clients apply + // last-writer-wins by this value: ignore the count if sequence <= the + // count watermark held, and ignore the actor's reacted_by_self toggle if + // sequence <= the per-actor watermark held. Matches EmojiReaction.sequence. + uint64 sequence = 6 [(validate.rules).uint64.gte = 1]; + + // When the actor reacted. On ADDED, clients record this as the actor's + // Reactor.reacted_ts (e.g. when slotting them into sample_reactors); ignored + // for REMOVED. This is a display timestamp, distinct from `sequence`, which + // is the ordering key. + google.protobuf.Timestamp reacted_ts = 7 [(validate.rules).timestamp.required = true]; +} + +message ReactionUpdateBatch { + repeated ReactionUpdate reaction_updates = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + // Pointer in a chat indicating a user's message history state in a chat. message Pointer { // The type of pointer indicates which user's message history state can be @@ -141,6 +386,72 @@ message PointerBatch { }]; } +// Event is a contiguous run of one or more durable mutations to a chat, delivered +// atomically — the unit of the chat's event log. Newly sent messages, edits, +// and deletions are all mutations within an event. +// +// Only content-bearing, non-idempotent mutations live in the log, because that is +// what gap detection protects: missing one means missing data. Convergent state +// such as pointer advances (last-writer-wins, monotonic) and transient signals +// such as typing notifications are delivered out-of-band and fetched as current +// state, NOT replayed through this log. +// +// Clients apply events in ascending sequence order and use the sequence/count +// pair to detect gaps; on a gap they catch up via GetDelta. +message Event { + // Per-chat event sequence valued AFTER this event applies: the END of the + // half-open range (sequence - count, sequence] this event occupies. This is + // a SEPARATE sequence from MessageId — edits and deletions advance it + // without minting a new MessageId. + uint64 sequence = 1 [(validate.rules).uint64.gte = 1]; + + // The number of points this event consumes, equal to the number of + // mutations it carries — each mutation is one point. The mutation at index + // i sits at point (sequence - count + 1 + i). Clients gap-detect with + // local + count == sequence, so a server that begins emitting count > 1 + // (e.g. a bulk delete) needs no client change. + uint32 count = 2 [(validate.rules).uint32.gte = 1]; + + // Timestamp this event occurred at. + google.protobuf.Timestamp ts = 3 [(validate.rules).timestamp.required = true]; + + // The mutations in this event, ascending by point. Length must equal count. + repeated Mutation mutations = 4 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + +// Mutation is a single point in the event log: one message sent, edited, or +// deleted. Each carries the full materialized state of the affected message, so +// clients apply it by inserting or replacing their cached copy without a +// refetch. +message Mutation { + oneof type { + option (validate.required) = true; + + // A newly sent message. Inserts a new message_id at the tail of the + // chat. This is the only mutation that advances the MessageId sequence. + Message message_sent = 1; + + // An edit to an existing message (same message_id, updated content, + // last_edited_ts set). + Message message_edited = 2; + + // A deletion of an existing message (same message_id, content replaced + // with DeletedContent). The message_id is retained as a tombstone, so + // the MessageId sequence stays gapless. + Message message_deleted = 3; + } +} + +message EventBatch { + repeated Event events = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + message IsTypingNotification { common.v1.UserId user_id = 1 [(validate.rules).message.required = true]; From 38bcaf61d50804aaa49b84297ab1b24b62d5b285 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 24 Jun 2026 13:02:06 -0400 Subject: [PATCH 2/8] feat(messaging): scaffold service stubs for new RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Api → Service → Repository → Controller scaffolding for: GetDelta, EditMessage, DeleteMessage, AddReaction, RemoveReaction, GetReactors, GetReactionSummary, GetReactionSummaries. Domain model updates: - ChatMetadata: +latestEventSequence - ChatMessage: +lastEditedTs, eventSequence, reactions - MessageContent: +Reply, Media, System, Deleted subtypes - ChatUpdate: +events, reactionUpdates (newMessages deprecated) - New models: Emoji, Reactor, EmojiReaction, ReactionSummary, ReactionUpdate, MediaId, MediaItem, MediaMetadata, ChatEvent, ChatMutation Mapper updates for all new proto→domain and domain→proto conversions. 8 new error sealed classes matching proto response result enums. Signed-off-by: Brandon McAnsh --- .../controllers/ChatMessagingController.kt | 74 ++++++ .../internal/domain/ChatMetadataMapper.kt | 1 + .../internal/network/api/ChatMessagingApi.kt | 161 ++++++++++++ .../network/extensions/LocalToProtobuf.kt | 39 +++ .../network/extensions/ProtobufToLocal.kt | 108 ++++++++- .../network/services/ChatMessagingService.kt | 229 ++++++++++++++++++ .../InternalChatMessagingRepository.kt | 83 +++++++ .../com/flipcash/services/models/Errors.kt | 85 +++++++ .../services/models/chat/ChatEvent.kt | 10 + .../services/models/chat/ChatMessage.kt | 3 + .../services/models/chat/ChatMetadata.kt | 1 + .../services/models/chat/ChatMutation.kt | 9 + .../services/models/chat/ChatUpdate.kt | 11 +- .../flipcash/services/models/chat/Emoji.kt | 4 + .../services/models/chat/EmojiReaction.kt | 9 + .../flipcash/services/models/chat/MediaId.kt | 4 + .../services/models/chat/MediaItem.kt | 6 + .../services/models/chat/MediaMetadata.kt | 10 + .../services/models/chat/MessageContent.kt | 14 ++ .../services/models/chat/ReactionSummary.kt | 6 + .../services/models/chat/ReactionUpdate.kt | 20 ++ .../flipcash/services/models/chat/Reactor.kt | 9 + .../repository/ChatMessagingRepository.kt | 71 ++++++ 23 files changed, 961 insertions(+), 6 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatEvent.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMutation.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionSummary.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt index 87f78aefb..96481143a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt @@ -4,11 +4,17 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction 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.repository.ChatMessagingRepository +import com.flipcash.services.repository.DeltaUpdate +import com.flipcash.services.repository.ReactorsPage import com.flipcash.services.user.UserManager +import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @@ -35,6 +41,11 @@ class ChatMessagingController @Inject constructor( return repository.getMessagesByIds(owner, chatId, messageIds) } + fun getDelta(chatId: ChatId, afterSequence: Long): Flow> { + val owner = requireOwner() + return repository.getDelta(owner, chatId, afterSequence) + } + suspend fun sendMessage( chatId: ChatId, content: List, @@ -44,6 +55,69 @@ class ChatMessagingController @Inject constructor( return repository.sendMessage(owner, chatId, content, clientMessageId) } + suspend fun editMessage( + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.editMessage(owner, chatId, messageId, content, expectedEventSequence) + } + + suspend fun deleteMessage( + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.deleteMessage(owner, chatId, messageId, expectedEventSequence) + } + + suspend fun addReaction( + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.addReaction(owner, chatId, messageId, emoji) + } + + suspend fun removeReaction( + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.removeReaction(owner, chatId, messageId, emoji) + } + + suspend fun getReactors( + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions = QueryOptions(), + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getReactors(owner, chatId, messageId, emoji, queryOptions) + } + + suspend fun getReactionSummary( + chatId: ChatId, + messageId: Long, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getReactionSummary(owner, chatId, messageId) + } + + suspend fun getReactionSummaries( + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): Result> { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getReactionSummaries(owner, chatId, queryOptions) + } + suspend fun advancePointer( chatId: ChatId, pointerType: PointerType, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index b4b6bd7da..687754a73 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -28,6 +28,7 @@ class ChatMetadataMapper @Inject constructor( }, lastMessage = if (from.hasLastMessage()) from.lastMessage.toChatMessage() else null, lastActivity = Instant.fromEpochSeconds(from.lastActivity.seconds, from.lastActivity.nanos), + latestEventSequence = from.latestEventSequence, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt index 3b7f37c0f..35987db50 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt @@ -8,6 +8,8 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asChatId import com.flipcash.services.internal.network.extensions.asClientMessageId import com.flipcash.services.internal.network.extensions.asContent +import com.flipcash.services.internal.network.extensions.asEmoji +import com.flipcash.services.internal.network.extensions.asMessageId import com.flipcash.services.internal.network.extensions.asPointerType import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.asTypingState @@ -15,9 +17,12 @@ 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.ClientMessageId +import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow @@ -150,4 +155,160 @@ internal class ChatMessagingApi @Inject constructor( api.notifyIsTyping(request) } } + + fun getDelta( + owner: KeyPair, + chatId: ChatId, + afterSequence: Long, + ): Flow { + val request = RpcMessagingService.GetDeltaRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setAfterSequence(afterSequence) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return api.getDelta(request).flowOn(Dispatchers.IO) + } + + suspend fun editMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): RpcMessagingService.EditMessageResponse { + val request = RpcMessagingService.EditMessageRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .addAllContent(content.map { it.asContent() }) + .setExpectedEventSequence(expectedEventSequence) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.editMessage(request) + } + } + + suspend fun deleteMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): RpcMessagingService.DeleteMessageResponse { + val request = RpcMessagingService.DeleteMessageRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .setExpectedEventSequence(expectedEventSequence) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.deleteMessage(request) + } + } + + suspend fun addReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): RpcMessagingService.AddReactionResponse { + val request = RpcMessagingService.AddReactionRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .setEmoji(emoji.asEmoji()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.addReaction(request) + } + } + + suspend fun removeReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): RpcMessagingService.RemoveReactionResponse { + val request = RpcMessagingService.RemoveReactionRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .setEmoji(emoji.asEmoji()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.removeReaction(request) + } + } + + suspend fun getReactors( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions, + ): RpcMessagingService.GetReactorsResponse { + val request = RpcMessagingService.GetReactorsRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .setEmoji(emoji.asEmoji()) + .setOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getReactors(request) + } + } + + suspend fun getReactionSummary( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): RpcMessagingService.GetReactionSummaryResponse { + val request = RpcMessagingService.GetReactionSummaryRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getReactionSummary(request) + } + } + + suspend fun getReactionSummaries( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): RpcMessagingService.GetReactionSummariesResponse { + val request = RpcMessagingService.GetReactionSummariesRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getReactionSummaries(request) + } + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index ba1a3f6dc..6051adeb9 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -121,9 +121,48 @@ internal fun MessageContent.asContent(): MessagingModel.Content { ) ) .build() + is MessageContent.Reply -> MessagingModel.Content.newBuilder() + .setReply( + MessagingModel.ReplyContent.newBuilder() + .setRepliedMessageId(MessagingModel.MessageId.newBuilder().setValue(repliedMessageId)) + .addAllContent(content.map { it.asContent() }) + ) + .build() + is MessageContent.Media -> MessagingModel.Content.newBuilder() + .setMedia( + MessagingModel.MediaContent.newBuilder() + .addAllItems(items.map { it.asMediaItem() }) + .apply { if (caption != null) setCaption(MessagingModel.TextContent.newBuilder().setText(caption.text)) } + ) + .build() + is MessageContent.System -> MessagingModel.Content.newBuilder() + .setSystem(MessagingModel.SystemContent.newBuilder().setFallbackText(fallbackText)) + .build() + is MessageContent.Deleted -> { + val deletedBuilder = MessagingModel.DeletedContent.newBuilder() + .setDeletedTs(deletedTs.asTimestamp()) + deletedBy?.let { deletedBuilder.setDeletedBy(it.asUserId()) } + MessagingModel.Content.newBuilder() + .setDeleted(deletedBuilder) + .build() + } } } +internal fun com.flipcash.services.models.chat.MediaItem.asMediaItem(): MessagingModel.MediaItem { + return MessagingModel.MediaItem.newBuilder() + .setMediaId(MessagingModel.MediaId.newBuilder().setValue(mediaId.bytes.toByteString())) + .build() +} + +internal fun com.flipcash.services.models.chat.Emoji.asEmoji(): MessagingModel.Emoji { + return MessagingModel.Emoji.newBuilder().setValue(value).build() +} + +internal fun Long.asMessageId(): MessagingModel.MessageId { + return MessagingModel.MessageId.newBuilder().setValue(this).build() +} + internal fun PointerType.asPointerType(): MessagingModel.Pointer.Type { return when (this) { PointerType.SENT -> MessagingModel.Pointer.Type.SENT diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 0ffa8dbe8..8cc99c47b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -2,8 +2,6 @@ package com.flipcash.services.internal.network.extensions import com.codeinc.flipcash.gen.common.v1.Common -import com.codeinc.flipcash.gen.common.v1.Common.UserId -import com.codeinc.flipcash.gen.moderation.v1.ModerationService import com.codeinc.flipcash.gen.push.v1.navigationOrNull import com.codeinc.flipcash.gen.push.v1.Model as PushModels import com.flipcash.services.internal.extensions.toChecksum @@ -15,16 +13,26 @@ import com.flipcash.services.models.NotificationPayload import com.flipcash.services.models.PagingToken import com.flipcash.services.models.Substitution import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatEvent import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatMutation import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.MediaId +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MediaMetadata import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.PointerType +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.TypingNotification import com.flipcash.services.models.chat.TypingState import com.getcode.opencode.model.core.ID @@ -118,10 +126,41 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { ), mint = cash.amount.mint.value.toByteArray().toMint(), ) + MessagingModel.Content.TypeCase.REPLY -> MessageContent.Reply( + repliedMessageId = reply.repliedMessageId.value, + content = reply.contentList.map { it.toMessageContent() }, + ) + MessagingModel.Content.TypeCase.MEDIA -> MessageContent.Media( + items = media.itemsList.map { it.toMediaItem() }, + caption = if (media.hasCaption()) MessageContent.Text(media.caption.text) else null, + ) + MessagingModel.Content.TypeCase.SYSTEM -> MessageContent.System(system.fallbackText) + MessagingModel.Content.TypeCase.DELETED -> MessageContent.Deleted( + deletedTs = Instant.fromEpochSeconds(deleted.deletedTs.seconds, deleted.deletedTs.nanos), + deletedBy = if (deleted.hasDeletedBy()) deleted.deletedBy.toId() else null, + ) else -> MessageContent.Text("") } } +internal fun MessagingModel.MediaItem.toMediaItem(): MediaItem { + return MediaItem( + mediaId = MediaId(mediaId.value.toByteArray()), + metadata = if (hasMetadata()) metadata.toMediaMetadata() else null, + ) +} + +internal fun MessagingModel.MediaMetadata.toMediaMetadata(): MediaMetadata { + return MediaMetadata( + mimeType = mimeType, + sizeBytes = sizeBytes, + width = width, + height = height, + blurhash = blurhash, + durationMs = durationMs, + ) +} + internal fun MessagingModel.Message.toChatMessage(): ChatMessage { return ChatMessage( messageId = messageId.value, @@ -129,9 +168,70 @@ internal fun MessagingModel.Message.toChatMessage(): ChatMessage { content = contentList.map { it.toMessageContent() }, timestamp = Instant.fromEpochSeconds(ts.seconds, ts.nanos), unreadSeq = unreadSeq, + lastEditedTs = if (hasLastEditedTs()) Instant.fromEpochSeconds(lastEditedTs.seconds, lastEditedTs.nanos) else null, + eventSequence = eventSequence, + reactions = if (hasReactions()) reactions.toReactionSummary() else null, + ) +} + +internal fun MessagingModel.ReactionSummary.toReactionSummary(): ReactionSummary { + return ReactionSummary( + messageId = messageId.value, + reactions = reactionsList.map { it.toEmojiReaction() }, + ) +} + +internal fun MessagingModel.EmojiReaction.toEmojiReaction(): EmojiReaction { + return EmojiReaction( + emoji = Emoji(emoji.value), + count = count, + reactedBySelf = reactedBySelf, + sampleReactors = sampleReactorsList.map { it.toReactor() }, + sequence = sequence, ) } +internal fun MessagingModel.Reactor.toReactor(): Reactor { + return Reactor( + userId = userId.toId(), + reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), + ) +} + +internal fun MessagingModel.ReactionUpdate.toReactionUpdate(): ReactionUpdate { + return ReactionUpdate( + messageId = messageId.value, + emoji = Emoji(emoji.value), + actor = actor.toId(), + action = when (action) { + MessagingModel.ReactionUpdate.Action.ADDED -> ReactionUpdate.Action.ADDED + MessagingModel.ReactionUpdate.Action.REMOVED -> ReactionUpdate.Action.REMOVED + else -> ReactionUpdate.Action.UNKNOWN + }, + count = count, + sequence = sequence, + reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), + ) +} + +internal fun MessagingModel.Event.toChatEvent(): ChatEvent { + return ChatEvent( + sequence = sequence, + count = count, + ts = Instant.fromEpochSeconds(ts.seconds, ts.nanos), + mutations = mutationsList.map { it.toChatMutation() }, + ) +} + +internal fun MessagingModel.Mutation.toChatMutation(): ChatMutation { + return when (typeCase) { + MessagingModel.Mutation.TypeCase.MESSAGE_SENT -> ChatMutation.MessageSent(messageSent.toChatMessage()) + MessagingModel.Mutation.TypeCase.MESSAGE_EDITED -> ChatMutation.MessageEdited(messageEdited.toChatMessage()) + MessagingModel.Mutation.TypeCase.MESSAGE_DELETED -> ChatMutation.MessageDeleted(messageDeleted.toChatMessage()) + else -> ChatMutation.MessageSent(MessagingModel.Message.getDefaultInstance().toChatMessage()) + } +} + internal fun MessagingModel.Pointer.toPointer(): MessagePointer { return MessagePointer( type = type.toPointerType(), @@ -217,11 +317,13 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { }, lastMessage = if (hasLastMessage()) lastMessage.toChatMessage() else null, lastActivity = Instant.fromEpochSeconds(lastActivity.seconds, lastActivity.nanos), + latestEventSequence = latestEventSequence, ) } // -- EventModel.ChatUpdate -- +@Suppress("DEPRECATION") internal fun EventModel.ChatUpdate.toChatUpdate( metadataMapper: (ChatModel.Metadata) -> ChatMetadata = { it.toChatMetadata() }, ): ChatUpdate { @@ -231,5 +333,7 @@ internal fun EventModel.ChatUpdate.toChatUpdate( pointerUpdates = if (hasPointerUpdates()) pointerUpdates.pointersList.map { it.toPointer() } else emptyList(), typingNotifications = if (hasIsTypingNotifications()) isTypingNotifications.isTypingNotificationsList.map { it.toTypingNotification() } else emptyList(), metadataUpdates = metadataUpdatesList.map { it.toMetadataUpdate(metadataMapper) }, + events = if (hasEvents()) events.eventsList.map { it.toChatEvent() } else emptyList(), + reactionUpdates = if (hasReactionUpdates()) reactionUpdates.reactionUpdatesList.map { it.toReactionUpdate() } else emptyList(), ) } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt index de0c4b60a..41a6a3f03 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt @@ -3,20 +3,37 @@ package com.flipcash.services.internal.network.services import com.codeinc.flipcash.gen.messaging.v1.MessagingService as RpcMessagingService import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel import com.flipcash.services.internal.network.api.ChatMessagingApi +import com.flipcash.services.internal.network.extensions.toEmojiReaction +import com.flipcash.services.internal.network.extensions.toReactionSummary +import com.flipcash.services.internal.network.extensions.toReactor +import com.flipcash.services.models.AddReactionError import com.flipcash.services.models.AdvancePointerError +import com.flipcash.services.models.DeleteMessageError +import com.flipcash.services.models.EditMessageError +import com.flipcash.services.models.GetDeltaError +import com.flipcash.services.models.GetReactionSummariesError +import com.flipcash.services.models.GetReactionSummaryError +import com.flipcash.services.models.GetReactorsError import com.flipcash.services.models.SendMessageError import com.flipcash.services.models.GetMessageError import com.flipcash.services.models.GetMessagesError import com.flipcash.services.models.NotifyIsTypingError import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.RemoveReactionError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.Emoji 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.Reactor import com.flipcash.services.models.chat.TypingState import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.getcode.opencode.utils.toValidationOrElse +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import javax.inject.Inject internal class ChatMessagingService @Inject constructor( @@ -161,4 +178,216 @@ internal class ChatMessagingService @Inject constructor( } ) } + + fun getDelta( + owner: KeyPair, + chatId: ChatId, + afterSequence: Long, + ): Flow> { + return api.getDelta(owner, chatId, afterSequence).map { response -> + when (response.result) { + RpcMessagingService.GetDeltaResponse.Result.OK -> Result.success( + GetDeltaResult( + messages = if (response.hasMessages()) response.messages.messagesList else emptyList(), + latestSequence = response.latestSequence, + checkpointSequence = response.checkpointSequence, + ) + ) + RpcMessagingService.GetDeltaResponse.Result.DENIED -> Result.failure(GetDeltaError.Denied()) + RpcMessagingService.GetDeltaResponse.Result.RESET_REQUIRED -> Result.failure(GetDeltaError.ResetRequired()) + RpcMessagingService.GetDeltaResponse.Result.UNRECOGNIZED -> Result.failure(GetDeltaError.Unrecognized()) + else -> Result.failure(GetDeltaError.Other()) + } + } + } + + suspend fun editMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): Result { + return runCatching { + api.editMessage(owner, chatId, messageId, content, expectedEventSequence) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.EditMessageResponse.Result.OK -> Result.success(response.message) + RpcMessagingService.EditMessageResponse.Result.DENIED -> Result.failure(EditMessageError.Denied()) + RpcMessagingService.EditMessageResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(EditMessageError.MessageNotFound()) + RpcMessagingService.EditMessageResponse.Result.CANNOT_EDIT -> Result.failure(EditMessageError.CannotEdit()) + RpcMessagingService.EditMessageResponse.Result.CONFLICT -> Result.failure(EditMessageError.Conflict()) + RpcMessagingService.EditMessageResponse.Result.UNRECOGNIZED -> Result.failure(EditMessageError.Unrecognized()) + else -> Result.failure(EditMessageError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { EditMessageError.Other(cause = it) }) + } + ) + } + + suspend fun deleteMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): Result { + return runCatching { + api.deleteMessage(owner, chatId, messageId, expectedEventSequence) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.DeleteMessageResponse.Result.OK -> Result.success(response.message) + RpcMessagingService.DeleteMessageResponse.Result.DENIED -> Result.failure(DeleteMessageError.Denied()) + RpcMessagingService.DeleteMessageResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(DeleteMessageError.MessageNotFound()) + RpcMessagingService.DeleteMessageResponse.Result.CANNOT_DELETE -> Result.failure(DeleteMessageError.CannotDelete()) + RpcMessagingService.DeleteMessageResponse.Result.CONFLICT -> Result.failure(DeleteMessageError.Conflict()) + RpcMessagingService.DeleteMessageResponse.Result.UNRECOGNIZED -> Result.failure(DeleteMessageError.Unrecognized()) + else -> Result.failure(DeleteMessageError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { DeleteMessageError.Other(cause = it) }) + } + ) + } + + suspend fun addReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result { + return runCatching { + api.addReaction(owner, chatId, messageId, emoji) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.AddReactionResponse.Result.OK -> Result.success(response.reaction.toEmojiReaction()) + RpcMessagingService.AddReactionResponse.Result.DENIED -> Result.failure(AddReactionError.Denied()) + RpcMessagingService.AddReactionResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(AddReactionError.MessageNotFound()) + RpcMessagingService.AddReactionResponse.Result.CANNOT_REACT -> Result.failure(AddReactionError.CannotReact()) + RpcMessagingService.AddReactionResponse.Result.TOO_MANY_REACTION_TYPES -> Result.failure(AddReactionError.TooManyReactionTypes()) + RpcMessagingService.AddReactionResponse.Result.UNRECOGNIZED -> Result.failure(AddReactionError.Unrecognized()) + else -> Result.failure(AddReactionError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { AddReactionError.Other(cause = it) }) + } + ) + } + + suspend fun removeReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result { + return runCatching { + api.removeReaction(owner, chatId, messageId, emoji) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.RemoveReactionResponse.Result.OK -> Result.success(response.reaction.toEmojiReaction()) + RpcMessagingService.RemoveReactionResponse.Result.DENIED -> Result.failure(RemoveReactionError.Denied()) + RpcMessagingService.RemoveReactionResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(RemoveReactionError.MessageNotFound()) + RpcMessagingService.RemoveReactionResponse.Result.UNRECOGNIZED -> Result.failure(RemoveReactionError.Unrecognized()) + else -> Result.failure(RemoveReactionError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { RemoveReactionError.Other(cause = it) }) + } + ) + } + + suspend fun getReactors( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getReactors(owner, chatId, messageId, emoji, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetReactorsResponse.Result.OK -> Result.success( + GetReactorsResult( + reactors = response.reactorsList.map { it.toReactor() }, + hasMore = response.hasMore, + ) + ) + RpcMessagingService.GetReactorsResponse.Result.DENIED -> Result.failure(GetReactorsError.Denied()) + RpcMessagingService.GetReactorsResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(GetReactorsError.MessageNotFound()) + RpcMessagingService.GetReactorsResponse.Result.UNRECOGNIZED -> Result.failure(GetReactorsError.Unrecognized()) + else -> Result.failure(GetReactorsError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetReactorsError.Other(cause = it) }) + } + ) + } + + suspend fun getReactionSummary( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result { + return runCatching { + api.getReactionSummary(owner, chatId, messageId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetReactionSummaryResponse.Result.OK -> Result.success(response.summary.toReactionSummary()) + RpcMessagingService.GetReactionSummaryResponse.Result.DENIED -> Result.failure(GetReactionSummaryError.Denied()) + RpcMessagingService.GetReactionSummaryResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(GetReactionSummaryError.MessageNotFound()) + RpcMessagingService.GetReactionSummaryResponse.Result.UNRECOGNIZED -> Result.failure(GetReactionSummaryError.Unrecognized()) + else -> Result.failure(GetReactionSummaryError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetReactionSummaryError.Other(cause = it) }) + } + ) + } + + suspend fun getReactionSummaries( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> { + return runCatching { + api.getReactionSummaries(owner, chatId, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetReactionSummariesResponse.Result.OK -> + Result.success(response.summariesList.map { it.toReactionSummary() }) + RpcMessagingService.GetReactionSummariesResponse.Result.DENIED -> Result.failure(GetReactionSummariesError.Denied()) + RpcMessagingService.GetReactionSummariesResponse.Result.UNRECOGNIZED -> Result.failure(GetReactionSummariesError.Unrecognized()) + else -> Result.failure(GetReactionSummariesError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetReactionSummariesError.Other(cause = it) }) + } + ) + } } + +data class GetDeltaResult( + val messages: List, + val latestSequence: Long, + val checkpointSequence: Long, +) + +data class GetReactorsResult( + val reactors: List, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt index 05c8c9988..0b9d5e0b5 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt @@ -6,12 +6,19 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction 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.repository.ChatMessagingRepository +import com.flipcash.services.repository.DeltaUpdate +import com.flipcash.services.repository.ReactorsPage import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.utils.ErrorUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map internal class InternalChatMessagingRepository( private val service: ChatMessagingService, @@ -41,6 +48,23 @@ internal class InternalChatMessagingRepository( .onFailure { ErrorUtils.handleError(it) } .map { messages -> messages.map { it.toChatMessage() } } + override fun getDelta( + owner: KeyPair, + chatId: ChatId, + afterSequence: Long, + ): Flow> = service.getDelta(owner, chatId, afterSequence) + .map { result -> + result + .onFailure { ErrorUtils.handleError(it) } + .map { delta -> + DeltaUpdate( + messages = delta.messages.map { it.toChatMessage() }, + latestSequence = delta.latestSequence, + checkpointSequence = delta.checkpointSequence, + ) + } + } + override suspend fun sendMessage( owner: KeyPair, chatId: ChatId, @@ -50,6 +74,65 @@ internal class InternalChatMessagingRepository( .onFailure { ErrorUtils.handleError(it) } .map { it.toChatMessage() } + override suspend fun editMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): Result = service.editMessage(owner, chatId, messageId, content, expectedEventSequence) + .onFailure { ErrorUtils.handleError(it) } + .map { it.toChatMessage() } + + override suspend fun deleteMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): Result = service.deleteMessage(owner, chatId, messageId, expectedEventSequence) + .onFailure { ErrorUtils.handleError(it) } + .map { it.toChatMessage() } + + override suspend fun addReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result = service.addReaction(owner, chatId, messageId, emoji) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun removeReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result = service.removeReaction(owner, chatId, messageId, emoji) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun getReactors( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions, + ): Result = service.getReactors(owner, chatId, messageId, emoji, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { ReactorsPage(reactors = it.reactors, hasMore = it.hasMore) } + + override suspend fun getReactionSummary( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result = service.getReactionSummary(owner, chatId, messageId) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun getReactionSummaries( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> = service.getReactionSummaries(owner, chatId, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + override suspend fun advancePointer( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index 04e96fb3c..fcc23ec58 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -389,4 +389,89 @@ sealed class ResolveContactError( class Denied : ResolveContactError("Denied") class Unrecognized : ResolveContactError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : ResolveContactError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetDeltaError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetDeltaError("Denied") + class ResetRequired : GetDeltaError("Reset required") + class Unrecognized : GetDeltaError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetDeltaError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class EditMessageError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : EditMessageError("Denied") + class MessageNotFound : EditMessageError("Message not found") + class CannotEdit : EditMessageError("Cannot edit") + class Conflict : EditMessageError("Conflict") + class Unrecognized : EditMessageError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : EditMessageError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class DeleteMessageError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : DeleteMessageError("Denied") + class MessageNotFound : DeleteMessageError("Message not found") + class CannotDelete : DeleteMessageError("Cannot delete") + class Conflict : DeleteMessageError("Conflict") + class Unrecognized : DeleteMessageError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : DeleteMessageError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class AddReactionError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : AddReactionError("Denied") + class MessageNotFound : AddReactionError("Message not found") + class CannotReact : AddReactionError("Cannot react") + class TooManyReactionTypes : AddReactionError("Too many reaction types") + class Unrecognized : AddReactionError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : AddReactionError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class RemoveReactionError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : RemoveReactionError("Denied") + class MessageNotFound : RemoveReactionError("Message not found") + class Unrecognized : RemoveReactionError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : RemoveReactionError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetReactorsError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetReactorsError("Denied") + class MessageNotFound : GetReactorsError("Message not found") + class Unrecognized : GetReactorsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetReactorsError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetReactionSummaryError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetReactionSummaryError("Denied") + class MessageNotFound : GetReactionSummaryError("Message not found") + class Unrecognized : GetReactionSummaryError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetReactionSummaryError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetReactionSummariesError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetReactionSummariesError("Denied") + class Unrecognized : GetReactionSummariesError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetReactionSummariesError(message = cause?.message, cause = cause), NotifiableError } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatEvent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatEvent.kt new file mode 100644 index 000000000..0321daf19 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatEvent.kt @@ -0,0 +1,10 @@ +package com.flipcash.services.models.chat + +import kotlin.time.Instant + +data class ChatEvent( + val sequence: Long, + val count: Int, + val ts: Instant, + val mutations: List, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt index 1b4baf4fd..b82f660a8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt @@ -9,6 +9,9 @@ data class ChatMessage( val content: List, val timestamp: Instant, val unreadSeq: Long, + val lastEditedTs: Instant? = null, + val eventSequence: Long = 0, + val reactions: ReactionSummary? = null, val isFromSelf: Boolean = false, val deliveryStatus: DeliveryStatus = DeliveryStatus.SENT, val pendingClientIdHex: String? = null, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index fc93f3707..ff3137669 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -8,4 +8,5 @@ data class ChatMetadata( val members: List, val lastMessage: ChatMessage?, val lastActivity: Instant, + val latestEventSequence: Long = 0, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMutation.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMutation.kt new file mode 100644 index 000000000..3102de21c --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMutation.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +sealed interface ChatMutation { + val message: ChatMessage + + data class MessageSent(override val message: ChatMessage) : ChatMutation + data class MessageEdited(override val message: ChatMessage) : ChatMutation + data class MessageDeleted(override val message: ChatMessage) : ChatMutation +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt index 6bb655764..5d541ae4b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt @@ -2,8 +2,11 @@ package com.flipcash.services.models.chat data class ChatUpdate( val chatId: ChatId, - val newMessages: List, - val pointerUpdates: List, - val typingNotifications: List, - val metadataUpdates: List, + @Deprecated("Use events instead", replaceWith = ReplaceWith("events")) + val newMessages: List = emptyList(), + val pointerUpdates: List = emptyList(), + val typingNotifications: List = emptyList(), + val metadataUpdates: List = emptyList(), + val events: List = emptyList(), + val reactionUpdates: List = emptyList(), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt new file mode 100644 index 000000000..21c40d1e1 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt @@ -0,0 +1,4 @@ +package com.flipcash.services.models.chat + +@JvmInline +value class Emoji(val value: String) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt new file mode 100644 index 000000000..ea3b8ec73 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +data class EmojiReaction( + val emoji: Emoji, + val count: Long, + val reactedBySelf: Boolean, + val sampleReactors: List, + val sequence: Long, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt new file mode 100644 index 000000000..29971e4d6 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt @@ -0,0 +1,4 @@ +package com.flipcash.services.models.chat + +@JvmInline +value class MediaId(val bytes: ByteArray) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt new file mode 100644 index 000000000..1f3fae3cb --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt @@ -0,0 +1,6 @@ +package com.flipcash.services.models.chat + +data class MediaItem( + val mediaId: MediaId, + val metadata: MediaMetadata?, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt new file mode 100644 index 000000000..a67154a2a --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt @@ -0,0 +1,10 @@ +package com.flipcash.services.models.chat + +data class MediaMetadata( + val mimeType: String, + val sizeBytes: Long, + val width: Int, + val height: Int, + val blurhash: String, + val durationMs: Long, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 9d8dac8ee..96cf5f8c7 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -3,6 +3,7 @@ package com.flipcash.services.models.chat import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint +import kotlin.time.Instant sealed interface MessageContent { data class Text(val text: String) : MessageContent @@ -13,4 +14,17 @@ sealed interface MessageContent { val tokenName: String = "", val tokenImageUrl: String = "", ) : MessageContent + data class Reply( + val repliedMessageId: Long, + val content: List, + ) : MessageContent + data class Media( + val items: List, + val caption: Text?, + ) : MessageContent + data class System(val fallbackText: String) : MessageContent + data class Deleted( + val deletedTs: Instant, + val deletedBy: ID?, + ) : MessageContent } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionSummary.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionSummary.kt new file mode 100644 index 000000000..5c8dc00c3 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionSummary.kt @@ -0,0 +1,6 @@ +package com.flipcash.services.models.chat + +data class ReactionSummary( + val messageId: Long, + val reactions: List, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt new file mode 100644 index 000000000..b99e96eb9 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt @@ -0,0 +1,20 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +data class ReactionUpdate( + val messageId: Long, + val emoji: Emoji, + val actor: ID, + val action: Action, + val count: Long, + val sequence: Long, + val reactedAt: Instant, +) { + enum class Action { + UNKNOWN, + ADDED, + REMOVED, + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt new file mode 100644 index 000000000..dd9965a73 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +data class Reactor( + val userId: ID, + val reactedAt: Instant, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt index 6b5b350f9..9ee3e199f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt @@ -4,10 +4,15 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction 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.Reactor import com.flipcash.services.models.chat.TypingState import com.getcode.ed25519.Ed25519.KeyPair +import kotlinx.coroutines.flow.Flow interface ChatMessagingRepository { suspend fun getMessage( @@ -28,6 +33,12 @@ interface ChatMessagingRepository { messageIds: List, ): Result> + fun getDelta( + owner: KeyPair, + chatId: ChatId, + afterSequence: Long, + ): Flow> + suspend fun sendMessage( owner: KeyPair, chatId: ChatId, @@ -35,6 +46,55 @@ interface ChatMessagingRepository { clientMessageId: ClientMessageId, ): Result + suspend fun editMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): Result + + suspend fun deleteMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): Result + + suspend fun addReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result + + suspend fun removeReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result + + suspend fun getReactors( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions, + ): Result + + suspend fun getReactionSummary( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result + + suspend fun getReactionSummaries( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> + suspend fun advancePointer( owner: KeyPair, chatId: ChatId, @@ -48,3 +108,14 @@ interface ChatMessagingRepository { state: TypingState, ): Result } + +data class DeltaUpdate( + val messages: List, + val latestSequence: Long, + val checkpointSequence: Long, +) + +data class ReactorsPage( + val reactors: List, + val hasMore: Boolean, +) From 64b0d9687e424ac4e46d5e1a91893ab414f584b6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 24 Jun 2026 13:36:54 -0400 Subject: [PATCH 3/8] feat(messaging): wire proto changes through data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Room columns for eventSequence, lastEditedTs, reactions on ChatMessageEntity and latestEventSequence on ChatMetadataEntity (migration 20→21). ChatCoordinator now prefers ChatUpdate.events over deprecated newMessages, supports delta-based catch-up on reconnect via getDelta, and maintains an in-memory reaction overlay from reactionUpdates. DAO upsert guards against stale writes using event sequence LWW. Add TODOs for UI handling Signed-off-by: Brandon McAnsh --- .../flipcash/shared/chat/ChatCoordinator.kt | 151 +++- .../com/flipcash/shared/chat/ChatState.kt | 2 + .../21.json | 654 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 3 +- .../converters/ChatTypeConverters.kt | 63 ++ .../app/persistence/dao/ChatMessageDao.kt | 10 + .../app/persistence/dao/ChatMetadataDao.kt | 6 + .../persistence/entities/ChatMessageEntity.kt | 3 + .../entities/ChatMetadataEntity.kt | 1 + .../sources/ChatMetadataDataSource.kt | 7 + .../sources/mapper/chat/ChatEntityMapper.kt | 81 +++ 11 files changed, 970 insertions(+), 11 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/21.json diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index be956abb7..7cbfd1a4f 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -29,11 +29,16 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.ReactionUpdate import com.flipcash.services.models.chat.TypingNotification import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.models.GetDeltaError +import com.flipcash.services.repository.DeltaUpdate import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager @@ -57,6 +62,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -426,10 +432,19 @@ class ChatCoordinator @Inject constructor( _state.update { it.copy(feedSyncState = FeedSyncState.Synced) } trace(tag = TAG, message = "Feed synced: ${page.chats.size} chats", type = TraceType.Process) - // Prefetch first page of messages for chats with no cached messages - page.chats - .filterNot { messageDataSource.hasMessages(it.chatId) } - .forEach { chat -> loadMessages(chat.chatId) } + // Delta-sync for chats with a known event sequence; full load for new chats + for (chat in page.chats) { + if (chat.latestEventSequence > 0) { + val localSeq = metadataDataSource.getLatestEventSequence(chat.chatId) + if (localSeq > 0 && localSeq < chat.latestEventSequence) { + performDeltaSync(chat.chatId) + continue + } + } + if (!messageDataSource.hasMessages(chat.chatId)) { + loadMessages(chat.chatId) + } + } } .onFailure { error -> _state.update { it.copy(feedSyncState = FeedSyncState.Error) } @@ -485,23 +500,45 @@ class ChatCoordinator @Inject constructor( private suspend fun applyUpdate(update: ChatUpdate) { val chatId = update.chatId + + // --- Resolve messages: prefer events, fall back to deprecated newMessages --- + + val resolvedMessages = if (update.events.isNotEmpty()) { + update.events + .flatMap { event -> event.mutations.map { it.message } } + .sortedBy { it.eventSequence } + .distinctBy { it.messageId } + } else { + @Suppress("DEPRECATION") + update.newMessages + } + trace( tag = TAG, - message = "applyUpdate: chatId=$chatId, newMessages=${update.newMessages.size}, pointers=${update.pointerUpdates.size}, typing=${update.typingNotifications.size}", + message = "applyUpdate: chatId=$chatId, messages=${resolvedMessages.size}, events=${update.events.size}, pointers=${update.pointerUpdates.size}, reactions=${update.reactionUpdates.size}, typing=${update.typingNotifications.size}", type = TraceType.Process, ) // --- Persist to DB first (suspend, off main thread) --- - val lastMsg = if (update.newMessages.isNotEmpty()) { - trace(tag = TAG, message = "Upserting ${update.newMessages.size} new messages for $chatId", type = TraceType.Process) - messageDataSource.upsert(chatId, update.newMessages) - update.newMessages.maxByOrNull { it.messageId }?.also { msg -> + val lastMsg = if (resolvedMessages.isNotEmpty()) { + trace(tag = TAG, message = "Upserting ${resolvedMessages.size} messages for $chatId", type = TraceType.Process) + messageDataSource.upsert(chatId, resolvedMessages) + resolvedMessages.maxByOrNull { it.messageId }?.also { msg -> metadataDataSource.updateLastMessageId(chatId, msg.messageId) metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) } } else null + // Advance event sequence cursor when processing events + if (update.events.isNotEmpty()) { + val maxSequence = update.events.maxOf { it.sequence } + val currentSequence = metadataDataSource.getLatestEventSequence(chatId) + if (maxSequence > currentSequence) { + metadataDataSource.updateLatestEventSequence(chatId, maxSequence) + } + } + for (pointer in update.pointerUpdates) { memberDataSource.updatePointers(chatId, pointer) } @@ -525,10 +562,24 @@ class ChatCoordinator @Inject constructor( } } + // --- Process reaction updates into in-memory overlay --- + + if (update.reactionUpdates.isNotEmpty()) { + _state.update { state -> + val chatOverlays = state.reactionOverlays[chatId]?.toMutableMap() ?: mutableMapOf() + for (reactionUpdate in update.reactionUpdates) { + applyReactionUpdate(chatOverlays, reactionUpdate) + } + state.copy( + reactionOverlays = state.reactionOverlays + (chatId to chatOverlays.toMap()) + ) + } + } + // --- Eagerly update token balance for incoming cash --- val selfId = userManager.accountId - for (msg in update.newMessages) { + for (msg in resolvedMessages) { if (msg.senderId == selfId) continue for (content in msg.content) { if (content is MessageContent.Cash) { @@ -560,6 +611,86 @@ class ChatCoordinator @Inject constructor( } } + private fun applyReactionUpdate( + overlays: MutableMap, + update: ReactionUpdate, + ) { + val existing = overlays[update.messageId] + val existingReactions = existing?.reactions?.toMutableList() ?: mutableListOf() + + // Find existing reaction for this emoji + val idx = existingReactions.indexOfFirst { it.emoji == update.emoji } + if (idx >= 0) { + val current = existingReactions[idx] + // LWW guard using sequence + if (update.sequence <= current.sequence) return + existingReactions[idx] = EmojiReaction( + emoji = update.emoji, + count = update.count, + reactedBySelf = current.reactedBySelf, // preserved; server will correct on next full fetch + sampleReactors = current.sampleReactors, + sequence = update.sequence, + ) + } else { + existingReactions.add( + EmojiReaction( + emoji = update.emoji, + count = update.count, + reactedBySelf = false, + sampleReactors = emptyList(), + sequence = update.sequence, + ) + ) + } + + // Remove reactions with count == 0 + existingReactions.removeAll { it.count <= 0 } + + overlays[update.messageId] = ReactionSummary( + messageId = update.messageId, + reactions = existingReactions.toList(), + ) + } + + private suspend fun performDeltaSync(chatId: ChatId) { + val afterSequence = metadataDataSource.getLatestEventSequence(chatId) + trace(tag = TAG, message = "Delta sync for $chatId from sequence $afterSequence", type = TraceType.Process) + + try { + val result = messagingController.getDelta(chatId, afterSequence).first() + result + .onSuccess { delta -> + if (delta.messages.isNotEmpty()) { + messageDataSource.upsert(chatId, delta.messages) + val latest = delta.messages.maxByOrNull { it.messageId } + latest?.let { msg -> + metadataDataSource.updateLastMessageId(chatId, msg.messageId) + metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) + } + } + if (delta.latestSequence > afterSequence) { + metadataDataSource.updateLatestEventSequence(chatId, delta.latestSequence) + } + trace(tag = TAG, message = "Delta sync complete: ${delta.messages.size} messages, sequence ${delta.latestSequence}", type = TraceType.Process) + } + .onFailure { error -> + if (error is GetDeltaError.ResetRequired) { + trace(tag = TAG, message = "Delta sync reset required for $chatId, falling back to full load", type = TraceType.Process) + loadMessages(chatId) + } else { + trace(tag = TAG, message = "Delta sync failed for $chatId: ${error.message}", type = TraceType.Error) + } + } + } catch (e: Exception) { + trace(tag = TAG, message = "Delta sync exception for $chatId: ${e.message}", type = TraceType.Error) + } + } + + fun observeReactions(chatId: ChatId, messageId: Long): Flow { + return _state.map { it.reactionOverlays[chatId]?.get(messageId) } + .distinctUntilChanged() + } + private fun applyTypingNotification( typists: MutableSet, notification: TypingNotification, diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index 8ea8c2119..d9c021b87 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -2,12 +2,14 @@ package com.flipcash.shared.chat import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ReactionSummary import com.getcode.opencode.model.core.ID import kotlin.time.Instant data class ChatState( val feed: List = emptyList(), val typingIndicators: Map> = emptyMap(), + val reactionOverlays: Map> = emptyMap(), val feedSyncState: FeedSyncState = FeedSyncState.Idle, val activeChat: ChatId? = null, ) diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/21.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/21.json new file mode 100644 index 000000000..e4e2044c9 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/21.json @@ -0,0 +1,654 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "3b8651813c4108f23dff6c12b99597fd", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `user_profile_json` TEXT, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userProfileJson", + "columnName": "user_profile_json", + "affinity": "TEXT" + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b8651813c4108f23dff6c12b99597fd')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 20c1771de..e9d40645c 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -68,8 +68,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 17, to = 18), AutoMigration(from = 18, to = 19), AutoMigration(from = 19, to = 20), + AutoMigration(from = 20, to = 21), ], - version = 20, + version = 21, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index 0c20c9a28..c53fb7730 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -2,6 +2,7 @@ package com.flipcash.app.persistence.converters import androidx.room.TypeConverter import com.flipcash.app.persistence.entities.MessageStatus +import com.flipcash.services.models.chat.MediaItem import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -65,6 +66,20 @@ class ChatTypeConverters { } // endregion + + // region ReactionSummary + + @TypeConverter + fun fromReactionSummary(value: String?): ReactionSummarySerialized? { + return value?.let { json.decodeFromString(it) } + } + + @TypeConverter + fun toReactionSummary(summary: ReactionSummarySerialized?): String? { + return summary?.let { json.encodeToString(it) } + } + + // endregion } @Serializable @@ -83,6 +98,33 @@ sealed interface MessageContentSerialized { val tokenName: String = "", val tokenImageUrl: String = "", ) : MessageContentSerialized + + @Serializable + @SerialName("deleted") + data class Deleted( + val deletedAt: Long, + val deletedBy: String?, + ) : MessageContentSerialized + + @Serializable + @SerialName("reply") + data class Reply( + val repliedMessageId: Long, + val content: List, + ) : MessageContentSerialized + + @Serializable + @SerialName("media") + data class Media( + val items: List, + val caption: Text?, + ) : MessageContentSerialized + + @Serializable + @SerialName("system") + data class System(val fallbackText: String) : MessageContentSerialized + + } @Serializable @@ -117,3 +159,24 @@ sealed interface SocialAccountSerialized { val followerCount: Int, ) : SocialAccountSerialized } + +@Serializable +data class ReactionSummarySerialized( + val messageId: Long, + val reactions: List, +) + +@Serializable +data class EmojiReactionSerialized( + val emoji: String, + val count: Long, + val reactedBySelf: Boolean, + val sampleReactors: List, + val sequence: Long, +) + +@Serializable +data class ReactorSerialized( + val userIdHex: String, + val reactedAtEpochSeconds: Long, +) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 322ed5dee..b47741fe9 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -34,8 +34,18 @@ interface ChatMessageDao { @Query("SELECT pending_client_id_hex FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") suspend fun getPendingClientId(chatIdHex: String, messageId: Long): String? + @Query("SELECT event_sequence FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") + suspend fun getEventSequence(chatIdHex: String, messageId: Long): Long? + @Transaction suspend fun upsert(entity: ChatMessageEntity) { + // Event-sequence guard: skip if stored sequence is newer (last-writer-wins). + // Passthrough when eventSequence == 0 (legacy messages). + if (entity.eventSequence > 0) { + val stored = getEventSequence(entity.chatIdHex, entity.messageId) + if (stored != null && stored > entity.eventSequence) return + } + val existingPendingId = getPendingClientId(entity.chatIdHex, entity.messageId) val merged = if (existingPendingId != null && entity.pendingClientIdHex == null) { entity.copy(pendingClientIdHex = existingPendingId) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 325c4b64d..38b3ff909 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -28,6 +28,12 @@ interface ChatMetadataDao { @Query("UPDATE chat_metadata SET last_message_id = :messageId WHERE chat_id_hex = :chatIdHex") suspend fun updateLastMessageId(chatIdHex: String, messageId: Long) + @Query("UPDATE chat_metadata SET latest_event_sequence = :sequence WHERE chat_id_hex = :chatIdHex") + suspend fun updateLatestEventSequence(chatIdHex: String, sequence: Long) + + @Query("SELECT latest_event_sequence FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun getLatestEventSequence(chatIdHex: String): Long? + @Query("DELETE FROM chat_metadata") suspend fun deleteAll() } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt index 5608f9afd..e3d177e05 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -23,4 +23,7 @@ data class ChatMessageEntity( @ColumnInfo(name = "unread_seq") val unreadSeq: Long, @ColumnInfo(name = "status", defaultValue = "SENT") val status: MessageStatus = MessageStatus.SENT, @ColumnInfo(name = "pending_client_id_hex") val pendingClientIdHex: String? = null, + @ColumnInfo(name = "event_sequence", defaultValue = "0") val eventSequence: Long = 0, + @ColumnInfo(name = "last_edited_ts_epoch_ms") val lastEditedTsEpochMs: Long? = null, + @ColumnInfo(name = "reactions_json") val reactionsJson: String? = null, ) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index f2b25f2b4..0eef68d44 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -10,4 +10,5 @@ data class ChatMetadataEntity( @ColumnInfo(name = "chat_type") val chatType: String, @ColumnInfo(name = "last_activity_epoch_ms", index = true) val lastActivityEpochMs: Long, @ColumnInfo(name = "last_message_id") val lastMessageId: Long?, + @ColumnInfo(name = "latest_event_sequence", defaultValue = "0") val latestEventSequence: Long = 0, ) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 520f24b3d..a8f289abd 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -40,6 +40,13 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.updateLastMessageId(mapper.chatIdHex(chatId), messageId) } + suspend fun updateLatestEventSequence(chatId: ChatId, sequence: Long) { + db?.chatMetadataDao()?.updateLatestEventSequence(mapper.chatIdHex(chatId), sequence) + } + + suspend fun getLatestEventSequence(chatId: ChatId): Long = + db?.chatMetadataDao()?.getLatestEventSequence(mapper.chatIdHex(chatId)) ?: 0L + suspend fun exists(chatId: ChatId): Boolean = db?.chatMetadataDao()?.getById(mapper.chatIdHex(chatId)) != null diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index e99fa2baa..079ab7d59 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -1,7 +1,10 @@ package com.flipcash.app.persistence.sources.mapper.chat +import com.flipcash.app.persistence.converters.EmojiReactionSerialized import com.flipcash.app.persistence.converters.MessageContentSerialized import com.flipcash.app.persistence.converters.MessagePointerSerialized +import com.flipcash.app.persistence.converters.ReactorSerialized +import com.flipcash.app.persistence.converters.ReactionSummarySerialized import com.flipcash.app.persistence.converters.SocialAccountSerialized import com.flipcash.app.persistence.converters.UserProfileSerialized import com.flipcash.app.persistence.entities.ChatMemberEntity @@ -16,9 +19,13 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ClientMessageId import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode @@ -46,6 +53,7 @@ class ChatEntityMapper @Inject constructor() { chatType = metadata.type.name, lastActivityEpochMs = metadata.lastActivity.toEpochMilliseconds(), lastMessageId = metadata.lastMessage?.messageId, + latestEventSequence = metadata.latestEventSequence, ) } @@ -60,6 +68,7 @@ class ChatEntityMapper @Inject constructor() { members = members, lastMessage = lastMessage, lastActivity = Instant.fromEpochMilliseconds(entity.lastActivityEpochMs), + latestEventSequence = entity.latestEventSequence, ) } @@ -75,6 +84,11 @@ class ChatEntityMapper @Inject constructor() { contentJson = message.content.map { it.toSerialized() }, timestampEpochMs = message.timestamp.toEpochMilliseconds(), unreadSeq = message.unreadSeq, + eventSequence = message.eventSequence, + lastEditedTsEpochMs = message.lastEditedTs?.toEpochMilliseconds(), + reactionsJson = message.reactions?.toSerialized()?.let { + kotlinx.serialization.json.Json.encodeToString(it) + }, ) } @@ -85,6 +99,11 @@ class ChatEntityMapper @Inject constructor() { content = entity.contentJson?.map { it.toDomain() } ?: emptyList(), timestamp = Instant.fromEpochMilliseconds(entity.timestampEpochMs), unreadSeq = entity.unreadSeq, + eventSequence = entity.eventSequence, + lastEditedTs = entity.lastEditedTsEpochMs?.let { Instant.fromEpochMilliseconds(it) }, + reactions = entity.reactionsJson?.let { + kotlinx.serialization.json.Json.decodeFromString(it).toDomain() + }, deliveryStatus = when (entity.status) { MessageStatus.SENDING -> DeliveryStatus.SENDING MessageStatus.SENT -> DeliveryStatus.SENT @@ -192,6 +211,19 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this tokenName = tokenName, tokenImageUrl = tokenImageUrl, ) + is MessageContent.Reply -> MessageContentSerialized.Reply( + repliedMessageId = repliedMessageId, + content = content.map { it.toSerialized() }, + ) + is MessageContent.Media -> MessageContentSerialized.Media( + items = items, + caption = caption?.let { MessageContentSerialized.Text(it.text) }, + ) + is MessageContent.System -> MessageContentSerialized.System(fallbackText = fallbackText) + is MessageContent.Deleted -> MessageContentSerialized.Deleted( + deletedAt = deletedTs.epochSeconds, + deletedBy = deletedBy?.hexEncodedString(), + ) } private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { @@ -206,6 +238,19 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { tokenName = tokenName, tokenImageUrl = tokenImageUrl, ) + is MessageContentSerialized.Reply -> MessageContent.Reply( + repliedMessageId = repliedMessageId, + content = content.map { it.toDomain() }, + ) + is MessageContentSerialized.Media -> MessageContent.Media( + items = items, + caption = caption?.let { MessageContent.Text(it.text) }, + ) + is MessageContentSerialized.System -> MessageContent.System(fallbackText = fallbackText) + is MessageContentSerialized.Deleted -> MessageContent.Deleted( + deletedTs = Instant.fromEpochSeconds(deletedAt), + deletedBy = deletedBy?.hexToIdExt(), + ) } private fun MessagePointer.toSerialized(): MessagePointerSerialized = MessagePointerSerialized( @@ -273,4 +318,40 @@ private fun String.hexToIdExt(): List { return data.toList() } +private fun ReactionSummary.toSerialized(): ReactionSummarySerialized = ReactionSummarySerialized( + messageId = messageId, + reactions = reactions.map { it.toSerialized() }, +) + +private fun EmojiReaction.toSerialized(): EmojiReactionSerialized = EmojiReactionSerialized( + emoji = emoji.value, + count = count, + reactedBySelf = reactedBySelf, + sampleReactors = sampleReactors.map { it.toSerialized() }, + sequence = sequence, +) + +private fun Reactor.toSerialized(): ReactorSerialized = ReactorSerialized( + userIdHex = userId.hexEncodedString(), + reactedAtEpochSeconds = reactedAt.epochSeconds, +) + +private fun ReactionSummarySerialized.toDomain(): ReactionSummary = ReactionSummary( + messageId = messageId, + reactions = reactions.map { it.toDomain() }, +) + +private fun EmojiReactionSerialized.toDomain(): EmojiReaction = EmojiReaction( + emoji = Emoji(emoji), + count = count, + reactedBySelf = reactedBySelf, + sampleReactors = sampleReactors.map { it.toDomain() }, + sequence = sequence, +) + +private fun ReactorSerialized.toDomain(): Reactor = Reactor( + userId = userIdHex.hexToIdExt(), + reactedAt = Instant.fromEpochSeconds(reactedAtEpochSeconds), +) + // endregion From 6f20b3c4ab3538e9020237cc5bdcc8743335cde9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 24 Jun 2026 13:52:10 -0400 Subject: [PATCH 4/8] fix(messaging): gap-aware event sequence tracking Replace naive max-sequence cursor advancement with a contiguous- frontier tracker. Out-of-order events are held in a pending set; only the highest gapless sequence is persisted. If a gap is not filled within 2s, getDelta backfills from the frontier. Prevents permanently skipping events on reordered delivery, critical for large group chats. Signed-off-by: Brandon McAnsh --- .../flipcash/shared/chat/ChatCoordinator.kt | 32 +++++- .../shared/chat/EventSequenceTracker.kt | 100 ++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/EventSequenceTracker.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 7cbfd1a4f..1fae39e3f 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -94,12 +94,14 @@ class ChatCoordinator @Inject constructor( companion object { private const val TAG = "ChatCoordinator" private val HEARTBEAT_INTERVAL = 30.seconds + private val GAP_FILL_DELAY = 2.seconds } private val supervisorJob = SupervisorJob() private val scope = CoroutineScope(dispatchers.IO + supervisorJob) private val cluster = MutableStateFlow(null) private val _state = MutableStateFlow(ChatState()) + private val sequenceTracker = EventSequenceTracker() private var syncJob: Job? = null private var flagObserverJob: Job? = null private var eventStreamCollectJob: Job? = null @@ -349,6 +351,7 @@ class ChatCoordinator @Inject constructor( networkObserverJob?.cancel() feedObserverJob = null _state.value = ChatState() + sequenceTracker.clearAll() cluster.value = null metadataDataSource.clear() messageDataSource.clear() @@ -530,12 +533,18 @@ class ChatCoordinator @Inject constructor( } } else null - // Advance event sequence cursor when processing events + // Advance event sequence cursor — gap-aware (only advance contiguous frontier) if (update.events.isNotEmpty()) { - val maxSequence = update.events.maxOf { it.sequence } - val currentSequence = metadataDataSource.getLatestEventSequence(chatId) - if (maxSequence > currentSequence) { - metadataDataSource.updateLatestEventSequence(chatId, maxSequence) + val dbCursor = metadataDataSource.getLatestEventSequence(chatId) + val incomingSequences = update.events.map { it.sequence } + val result = sequenceTracker.processSequences(chatId, dbCursor, incomingSequences) + + if (result.newContiguousSequence > dbCursor) { + metadataDataSource.updateLatestEventSequence(chatId, result.newContiguousSequence) + } + + if (result.hasGap) { + scheduleGapFill(chatId) } } @@ -652,6 +661,16 @@ class ChatCoordinator @Inject constructor( ) } + private fun scheduleGapFill(chatId: ChatId) { + val job = scope.launch { + delay(GAP_FILL_DELAY) + if (!sequenceTracker.hasGap(chatId)) return@launch + trace(tag = TAG, message = "Gap fill timeout: fetching delta for $chatId", type = TraceType.Process) + performDeltaSync(chatId) + } + sequenceTracker.setGapFillJob(chatId, job) + } + private suspend fun performDeltaSync(chatId: ChatId) { val afterSequence = metadataDataSource.getLatestEventSequence(chatId) trace(tag = TAG, message = "Delta sync for $chatId from sequence $afterSequence", type = TraceType.Process) @@ -671,11 +690,14 @@ class ChatCoordinator @Inject constructor( if (delta.latestSequence > afterSequence) { metadataDataSource.updateLatestEventSequence(chatId, delta.latestSequence) } + // getDelta is authoritative — reset tracker to the server's sequence + sequenceTracker.resetTo(chatId, delta.latestSequence) trace(tag = TAG, message = "Delta sync complete: ${delta.messages.size} messages, sequence ${delta.latestSequence}", type = TraceType.Process) } .onFailure { error -> if (error is GetDeltaError.ResetRequired) { trace(tag = TAG, message = "Delta sync reset required for $chatId, falling back to full load", type = TraceType.Process) + sequenceTracker.clear(chatId) loadMessages(chatId) } else { trace(tag = TAG, message = "Delta sync failed for $chatId: ${error.message}", type = TraceType.Error) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/EventSequenceTracker.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/EventSequenceTracker.kt new file mode 100644 index 000000000..d77388e75 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/EventSequenceTracker.kt @@ -0,0 +1,100 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.Job +import java.util.TreeSet + +/** + * Tracks per-chat event sequences to detect out-of-order delivery gaps. + * + * Only advances the "contiguous frontier" — the highest sequence number with + * no gaps before it. Sequences that arrive ahead of the frontier are held in + * a pending set until the gap is filled (either by a late-arriving event or + * by a getDelta backfill). + * + * Session-scoped; initialized lazily from the DB cursor on first event per chat. + */ +internal class EventSequenceTracker { + + private val chatStates = mutableMapOf() + + private class GapState( + var lastContiguous: Long, + val pending: TreeSet = TreeSet(), + var gapFillJob: Job? = null, + ) + + data class SequenceResult( + val newContiguousSequence: Long, + val hasGap: Boolean, + ) + + /** + * Process incoming event sequences for a chat. + * + * @param chatId the chat these events belong to + * @param dbCursor the persisted cursor (used to initialize on first call) + * @param incomingSequences the sequence numbers from this batch of events + * @return the new contiguous frontier and whether a gap remains + */ + fun processSequences( + chatId: ChatId, + dbCursor: Long, + incomingSequences: List, + ): SequenceResult { + val state = chatStates.getOrPut(chatId) { GapState(lastContiguous = dbCursor) } + + // If the DB cursor advanced externally (e.g. delta sync), catch up + if (dbCursor > state.lastContiguous) { + state.lastContiguous = dbCursor + state.pending.headSet(dbCursor + 1).clear() + } + + state.pending.addAll(incomingSequences) + + // Consume contiguous sequences from the frontier + while (state.pending.isNotEmpty() && state.pending.first() == state.lastContiguous + 1) { + state.lastContiguous = state.pending.pollFirst()!! + } + + // Drop any sequences at or below the frontier (duplicates / late arrivals) + if (state.pending.isNotEmpty()) { + state.pending.headSet(state.lastContiguous + 1).clear() + } + + return SequenceResult( + newContiguousSequence = state.lastContiguous, + hasGap = state.pending.isNotEmpty(), + ) + } + + fun hasGap(chatId: ChatId): Boolean { + return chatStates[chatId]?.pending?.isNotEmpty() == true + } + + fun setGapFillJob(chatId: ChatId, job: Job) { + val state = chatStates[chatId] ?: return + state.gapFillJob?.cancel() + state.gapFillJob = job + } + + /** Reset tracker to a known-good sequence after an authoritative getDelta response. */ + fun resetTo(chatId: ChatId, sequence: Long) { + val state = chatStates[chatId] + if (state != null) { + state.gapFillJob?.cancel() + state.gapFillJob = null + state.lastContiguous = sequence + state.pending.clear() + } + } + + fun clear(chatId: ChatId) { + chatStates.remove(chatId)?.gapFillJob?.cancel() + } + + fun clearAll() { + chatStates.values.forEach { it.gapFillJob?.cancel() } + chatStates.clear() + } +} From 0c6a37eee4eeaa52b5c9d475833d580a0e0a0c5e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 24 Jun 2026 14:02:20 -0400 Subject: [PATCH 5/8] test(messaging): add coverage for event sequencing, reactions, and serialization EventSequenceTrackerTest (14): gap detection, contiguous advancement, out-of-order handling, resetTo, multi-chat isolation. ChatCoordinatorEventsTest (10): events-vs-newMessages preference, gap-aware cursor, reaction overlay LWW guard and pruning. ChatTypeConvertersTest (12): ReactionSummary round-trip, nulls, plus MessageContent/Status/UserProfile converters. --- .../shared/chat/ChatCoordinatorEventsTest.kt | 450 ++++++++++++++++++ .../shared/chat/EventSequenceTrackerTest.kt | 193 ++++++++ .../converters/ChatTypeConvertersTest.kt | 142 ++++++ 3 files changed, 785 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/EventSequenceTrackerTest.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt new file mode 100644 index 000000000..79c6fe9de --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -0,0 +1,450 @@ +package com.flipcash.shared.chat + +import androidx.core.app.NotificationManagerCompat +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatEvent +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMutation +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.ReactionUpdate +import com.getcode.utils.network.NetworkConnectivityListener +import com.flipcash.services.user.UserManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ChatCoordinatorEventsTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val chatId = ChatId("aabbccdd") + + private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) + + private lateinit var metadataDataSource: ChatMetadataDataSource + private lateinit var messageDataSource: ChatMessageDataSource + private lateinit var coordinator: ChatCoordinator + private lateinit var testDispatchers: TestDispatchers + + @Before + fun setUp() { + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + val eventStreamingController = mockk(relaxed = true) + every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow() + every { eventStreamingController.isConnected } returns true + every { eventStreamingController.isStreamActive } returns true + + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed() } returns Result.failure(RuntimeException("not needed")) + + metadataDataSource = mockk(relaxed = true) + messageDataSource = mockk(relaxed = true) + + testDispatchers = TestDispatchers(TestCoroutineScheduler()) + + coordinator = ChatCoordinator( + chatController = chatController, + messagingController = mockk(relaxed = true), + eventStreamingController = eventStreamingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = mockk(relaxed = true), + contactDataSource = mockk(relaxed = true), + networkObserver = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = userManager, + tokenCoordinator = mockk(relaxed = true), + featureFlags = mockk(relaxed = true), + dispatchers = testDispatchers, + ) + } + + private fun textMessage( + id: Long, + senderId: List? = otherId, + eventSequence: Long = 0, + ) = ChatMessage( + messageId = id, + senderId = senderId, + content = listOf(MessageContent.Text("msg-$id")), + timestamp = Instant.fromEpochSeconds(1000 + id), + unreadSeq = 0, + eventSequence = eventSequence, + ) + + private fun chatEvent(sequence: Long, message: ChatMessage) = ChatEvent( + sequence = sequence, + count = 1, + ts = message.timestamp, + mutations = listOf(ChatMutation.MessageSent(message)), + ) + + private suspend fun triggerCollection() { + coordinator.onUserLoggedIn(mockk(relaxed = true)) + } + + // region Events vs newMessages + + @Test + fun `events are preferred over deprecated newMessages`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val eventMsg = textMessage(id = 1, eventSequence = 1) + val deprecatedMsg = textMessage(id = 99) + + @Suppress("DEPRECATION") + val update = ChatUpdate( + chatId = chatId, + newMessages = listOf(deprecatedMsg), + events = listOf(chatEvent(1, eventMsg)), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + // Should upsert the event message, not the deprecated one + coVerify { + messageDataSource.upsert(chatId, match { messages -> + messages.size == 1 && messages[0].messageId == 1L + }) + } + coordinator.reset() + } + + @Test + fun `falls back to newMessages when events is empty`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val msg = textMessage(id = 42) + @Suppress("DEPRECATION") + val update = ChatUpdate( + chatId = chatId, + newMessages = listOf(msg), + events = emptyList(), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + coVerify { + messageDataSource.upsert(chatId, match { messages -> + messages.size == 1 && messages[0].messageId == 42L + }) + } + coordinator.reset() + } + + @Test + fun `multiple events are flattened and deduped by messageId`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val msg1 = textMessage(id = 1, eventSequence = 1) + val msg1Edited = textMessage(id = 1, eventSequence = 2) // same messageId, higher sequence + val msg2 = textMessage(id = 2, eventSequence = 3) + + val update = ChatUpdate( + chatId = chatId, + events = listOf( + chatEvent(1, msg1), + chatEvent(2, msg1Edited), + chatEvent(3, msg2), + ), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + // Should have 2 unique messages (deduped by messageId, taking first by sorted eventSequence) + coVerify { + messageDataSource.upsert(chatId, match { messages -> + messages.size == 2 + }) + } + coordinator.reset() + } + + // endregion + + // region Sequence advancement with gaps + + @Test + fun `contiguous events advance sequence cursor`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 0L + + val update = ChatUpdate( + chatId = chatId, + events = listOf( + chatEvent(1, textMessage(id = 1, eventSequence = 1)), + chatEvent(2, textMessage(id = 2, eventSequence = 2)), + ), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + coVerify { metadataDataSource.updateLatestEventSequence(chatId, 2L) } + coordinator.reset() + } + + @Test + fun `gap in events does not advance cursor past gap`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 0L + + // Send seq 1, then seq 3 (gap at 2) + val update1 = ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(1, textMessage(id = 1, eventSequence = 1))), + ) + chatUpdatesChannel.send(update1) + advanceTimeBy(500) + runCurrent() + + val update2 = ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(3, textMessage(id = 3, eventSequence = 3))), + ) + chatUpdatesChannel.send(update2) + advanceTimeBy(500) + runCurrent() + + // Cursor should advance to 1 (contiguous), not 3 + coVerify { metadataDataSource.updateLatestEventSequence(chatId, 1L) } + coVerify(exactly = 0) { metadataDataSource.updateLatestEventSequence(chatId, 3L) } + coordinator.reset() + } + + @Test + fun `late event fills gap and advances cursor`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 0L + + // Send 1, then 3 (gap), then 2 (fills gap) + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(1, textMessage(id = 1, eventSequence = 1))), + )) + advanceTimeBy(100) + runCurrent() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(3, textMessage(id = 3, eventSequence = 3))), + )) + advanceTimeBy(100) + runCurrent() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(2, textMessage(id = 2, eventSequence = 2))), + )) + advanceTimeBy(100) + runCurrent() + + // After filling the gap, cursor should advance to 3 + coVerify { metadataDataSource.updateLatestEventSequence(chatId, 3L) } + coordinator.reset() + } + + // endregion + + // region Reaction overlays + + @Test + fun `reaction update is applied to in-memory overlay`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val update = ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDE00"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 1, + sequence = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + val state = coordinator.state.value + val overlay = state.reactionOverlays[chatId] + assertNotNull(overlay) + val summary = overlay[1L] + assertNotNull(summary) + assertEquals(1, summary.reactions.size) + assertEquals("\uD83D\uDE00", summary.reactions[0].emoji.value) + assertEquals(1L, summary.reactions[0].count) + coordinator.reset() + } + + @Test + fun `reaction LWW guard rejects stale updates`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + // First update: count=3, sequence=5 + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDC4D"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 3, + sequence = 5, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(500) + runCurrent() + + // Stale update: count=1, sequence=2 (older) + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDC4D"), + actor = otherId, + action = ReactionUpdate.Action.REMOVED, + count = 1, + sequence = 2, // older than 5 + reactedAt = Instant.fromEpochSeconds(500), + ), + ), + )) + advanceTimeBy(500) + runCurrent() + + val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions + assertNotNull(reactions) + assertEquals(1, reactions.size) + assertEquals(3L, reactions[0].count) // stayed at 3, stale update rejected + assertEquals(5L, reactions[0].sequence) + coordinator.reset() + } + + @Test + fun `reaction with count zero is pruned`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + // Add reaction + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDE00"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 1, + sequence = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(500) + runCurrent() + + // Remove reaction (count=0) + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDE00"), + actor = otherId, + action = ReactionUpdate.Action.REMOVED, + count = 0, + sequence = 2, + reactedAt = Instant.fromEpochSeconds(2000), + ), + ), + )) + advanceTimeBy(500) + runCurrent() + + val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions + assertNotNull(reactions) + assertTrue(reactions.isEmpty()) + coordinator.reset() + } + + @Test + fun `multiple emoji reactions on same message`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDE00"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 2, + sequence = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDC4D"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 5, + sequence = 2, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(1_000) + runCurrent() + + val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions + assertNotNull(reactions) + assertEquals(2, reactions.size) + coordinator.reset() + } + + // endregion +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/EventSequenceTrackerTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/EventSequenceTrackerTest.kt new file mode 100644 index 000000000..12631d391 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/EventSequenceTrackerTest.kt @@ -0,0 +1,193 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EventSequenceTrackerTest { + + private val chatId = ChatId("aabbccdd") + + @Test + fun `in-order sequences advance contiguous frontier`() { + val tracker = EventSequenceTracker() + + val r1 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1)) + assertEquals(1, r1.newContiguousSequence) + assertFalse(r1.hasGap) + + val r2 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(2)) + assertEquals(2, r2.newContiguousSequence) + assertFalse(r2.hasGap) + + val r3 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(3)) + assertEquals(3, r3.newContiguousSequence) + assertFalse(r3.hasGap) + } + + @Test + fun `batch of in-order sequences advances in one call`() { + val tracker = EventSequenceTracker() + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 2, 3)) + assertEquals(3, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `gap is detected when sequence is skipped`() { + val tracker = EventSequenceTracker() + + // Receive seq 1 (ok), then seq 3 (skipping 2) + val r1 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1)) + assertEquals(1, r1.newContiguousSequence) + assertFalse(r1.hasGap) + + val r2 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(3)) + assertEquals(1, r2.newContiguousSequence) // stuck at 1 + assertTrue(r2.hasGap) + assertTrue(tracker.hasGap(chatId)) + } + + @Test + fun `late arrival fills gap and advances frontier`() { + val tracker = EventSequenceTracker() + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1)) + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(3)) // gap at 2 + + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(2)) + assertEquals(3, result.newContiguousSequence) + assertFalse(result.hasGap) + assertFalse(tracker.hasGap(chatId)) + } + + @Test + fun `multiple gaps only advance to first gap`() { + val tracker = EventSequenceTracker() + + // Receive 1, 3, 5 — gaps at 2 and 4 + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3, 5)) + assertEquals(1, result.newContiguousSequence) + assertTrue(result.hasGap) + + // Fill gap at 2 — advances to 3, still gap at 4 + val r2 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(2)) + assertEquals(3, r2.newContiguousSequence) + assertTrue(r2.hasGap) + + // Fill gap at 4 — advances to 5, no more gaps + val r3 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(4)) + assertEquals(5, r3.newContiguousSequence) + assertFalse(r3.hasGap) + } + + @Test + fun `duplicate sequences are ignored`() { + val tracker = EventSequenceTracker() + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 2)) + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 2)) + assertEquals(2, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `dbCursor initializes the frontier`() { + val tracker = EventSequenceTracker() + + // DB already at 5, incoming 6 should be contiguous + val result = tracker.processSequences(chatId, dbCursor = 5, incomingSequences = listOf(6)) + assertEquals(6, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `dbCursor advancing externally catches up tracker`() { + val tracker = EventSequenceTracker() + + // First call: gap at 2 + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3)) + assertTrue(tracker.hasGap(chatId)) + + // External delta sync advanced DB to 5 + val result = tracker.processSequences(chatId, dbCursor = 5, incomingSequences = listOf(6)) + assertEquals(6, result.newContiguousSequence) + assertFalse(result.hasGap) // old pending (3) is below new cursor, cleared + } + + @Test + fun `resetTo clears pending and sets frontier`() { + val tracker = EventSequenceTracker() + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3, 5)) + assertTrue(tracker.hasGap(chatId)) + + tracker.resetTo(chatId, 10) + assertFalse(tracker.hasGap(chatId)) + + // Next event should continue from 10 + val result = tracker.processSequences(chatId, dbCursor = 10, incomingSequences = listOf(11)) + assertEquals(11, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `clear removes all state for a chat`() { + val tracker = EventSequenceTracker() + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3)) + assertTrue(tracker.hasGap(chatId)) + + tracker.clear(chatId) + assertFalse(tracker.hasGap(chatId)) + } + + @Test + fun `clearAll removes state for all chats`() { + val tracker = EventSequenceTracker() + val chatId2 = ChatId("11223344") + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3)) + tracker.processSequences(chatId2, dbCursor = 0, incomingSequences = listOf(1, 5)) + + tracker.clearAll() + assertFalse(tracker.hasGap(chatId)) + assertFalse(tracker.hasGap(chatId2)) + } + + @Test + fun `independent chats track separately`() { + val tracker = EventSequenceTracker() + val chatId2 = ChatId("11223344") + + val r1 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 2, 3)) + val r2 = tracker.processSequences(chatId2, dbCursor = 5, incomingSequences = listOf(7)) + + assertEquals(3, r1.newContiguousSequence) + assertFalse(r1.hasGap) + + assertEquals(5, r2.newContiguousSequence) // stuck at 5, gap at 6 + assertTrue(r2.hasGap) + } + + @Test + fun `out-of-order batch is handled correctly`() { + val tracker = EventSequenceTracker() + + // Receive [3, 1, 2] all at once — should end up contiguous at 3 + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(3, 1, 2)) + assertEquals(3, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `sequences at or below cursor are ignored`() { + val tracker = EventSequenceTracker() + + val result = tracker.processSequences(chatId, dbCursor = 5, incomingSequences = listOf(3, 4, 5)) + assertEquals(5, result.newContiguousSequence) + assertFalse(result.hasGap) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt new file mode 100644 index 000000000..137d60b9c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -0,0 +1,142 @@ +package com.flipcash.app.persistence.converters + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ChatTypeConvertersTest { + + private val converter = ChatTypeConverters() + + // region ReactionSummary + + @Test + fun `fromReactionSummary and toReactionSummary roundtrip`() { + val original = ReactionSummarySerialized( + messageId = 42L, + reactions = listOf( + EmojiReactionSerialized( + emoji = "\uD83D\uDE00", + count = 3, + reactedBySelf = true, + sampleReactors = listOf( + ReactorSerialized(userIdHex = "aabb", reactedAtEpochSeconds = 1000L), + ReactorSerialized(userIdHex = "ccdd", reactedAtEpochSeconds = 2000L), + ), + sequence = 5, + ), + EmojiReactionSerialized( + emoji = "\uD83D\uDC4D", + count = 1, + reactedBySelf = false, + sampleReactors = emptyList(), + sequence = 3, + ), + ), + ) + val serialized = converter.toReactionSummary(original) + val deserialized = converter.fromReactionSummary(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromReactionSummary returns null for null input`() { + assertNull(converter.fromReactionSummary(null)) + } + + @Test + fun `toReactionSummary returns null for null input`() { + assertNull(converter.toReactionSummary(null)) + } + + @Test + fun `roundtrip with empty reactions list`() { + val original = ReactionSummarySerialized( + messageId = 1L, + reactions = emptyList(), + ) + val serialized = converter.toReactionSummary(original) + val deserialized = converter.fromReactionSummary(serialized) + assertEquals(original, deserialized) + } + + // endregion + + // region MessageContent + + @Test + fun `fromMessageContentList and toMessageContentList roundtrip text`() { + val original = listOf(MessageContentSerialized.Text("hello")) + val serialized = converter.toMessageContentList(original) + val deserialized = converter.fromMessageContentList(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromMessageContentList returns null for null input`() { + assertNull(converter.fromMessageContentList(null)) + } + + @Test + fun `toMessageContentList returns null for null input`() { + assertNull(converter.toMessageContentList(null)) + } + + // endregion + + // region MessageStatus + + @Test + fun `fromMessageStatus and toMessageStatus roundtrip`() { + for (status in com.flipcash.app.persistence.entities.MessageStatus.entries) { + val serialized = converter.fromMessageStatus(status) + val deserialized = converter.toMessageStatus(serialized) + assertEquals(status, deserialized) + } + } + + @Test + fun `toMessageStatus falls back to SENT for unknown value`() { + val result = converter.toMessageStatus("NONEXISTENT") + assertEquals(com.flipcash.app.persistence.entities.MessageStatus.SENT, result) + } + + // endregion + + // region UserProfile + + @Test + fun `fromUserProfile and toUserProfile roundtrip`() { + val original = UserProfileSerialized( + displayName = "Alice", + socialAccounts = listOf( + SocialAccountSerialized.TwitterX( + id = "123", + username = "alice", + name = "Alice", + description = "hello", + profilePicUrl = "https://example.com/pic.jpg", + verifiedType = "BLUE", + followerCount = 100, + ) + ), + verifiedPhoneNumber = "+1234567890", + verifiedEmailAddress = "alice@example.com", + ) + val serialized = converter.toUserProfile(original) + val deserialized = converter.fromUserProfile(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromUserProfile returns null for null input`() { + assertNull(converter.fromUserProfile(null)) + } + + @Test + fun `toUserProfile returns null for null input`() { + assertNull(converter.toUserProfile(null)) + } + + // endregion +} From 43dbecdb0bc8a82c935529cbd1dc1d377daeadba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 24 Jun 2026 14:24:21 -0400 Subject: [PATCH 6/8] chore: setup minimum UI for new chat metadata and types Signed-off-by: Brandon McAnsh --- .../app/directsend/internal/SendFlowViewModel.kt | 6 ++++++ .../kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt | 4 ++++ .../com/flipcash/shared/chat/ui/MessageBubble.kt | 10 ++++++++++ .../com/flipcash/services/models/chat/MediaId.kt | 3 +++ .../com/flipcash/services/models/chat/MediaItem.kt | 3 +++ .../com/flipcash/services/models/chat/MediaMetadata.kt | 3 +++ 6 files changed, 29 insertions(+) diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 725a4eb4a..b695fcf31 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -341,6 +341,12 @@ internal class SendFlowViewModel @Inject constructor( val label = if (name.isNotBlank()) "$formatted of $name" else formatted if (sentBySelf) "You sent $label" else "You received $label" } + + // TODO: + is MessageContent.Deleted -> null + is MessageContent.Media -> null + is MessageContent.Reply -> null + is MessageContent.System -> null } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt index 7d0ce3079..ccc8485ff 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt @@ -27,6 +27,10 @@ sealed interface ChatListItem { override val itemContentType: Any = when (content) { is MessageContent.Text -> "text-bubble" is MessageContent.Cash -> "cash-bubble" + is MessageContent.Deleted -> "deleted-message" + is MessageContent.Media -> "media" + is MessageContent.Reply -> "reply-message" + is MessageContent.System -> "system-message" } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 0fd9b8bbb..7d7632e08 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -65,6 +65,10 @@ fun ContentBubble( val bubbleMaxWidth = when (item.content) { is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.Deleted -> maxWidth + is MessageContent.Media -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.Reply -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.System -> maxWidth } Row( @@ -89,6 +93,12 @@ fun ContentBubble( position = position, maxWidth = bubbleMaxWidth, ) + + // TODO + is MessageContent.Deleted -> Unit + is MessageContent.Media -> Unit + is MessageContent.Reply -> Unit + is MessageContent.System -> Unit } } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt index 29971e4d6..9f7f43805 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt @@ -1,4 +1,7 @@ package com.flipcash.services.models.chat +import kotlinx.serialization.Serializable + +@Serializable @JvmInline value class MediaId(val bytes: ByteArray) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt index 1f3fae3cb..89b2fd7b6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt @@ -1,5 +1,8 @@ package com.flipcash.services.models.chat +import kotlinx.serialization.Serializable + +@Serializable data class MediaItem( val mediaId: MediaId, val metadata: MediaMetadata?, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt index a67154a2a..359aebb39 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt @@ -1,5 +1,8 @@ package com.flipcash.services.models.chat +import kotlinx.serialization.Serializable + +@Serializable data class MediaMetadata( val mimeType: String, val sizeBytes: Long, From ab25ef773fa13a2fe8520ff731354ff8a64e2f83 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 24 Jun 2026 14:25:20 -0400 Subject: [PATCH 7/8] chore(chat): decompose ChatCoordinator into focused delegates Split the 735-line monolithic ChatCoordinator into three delegates using Kotlin interface delegation, following the SessionController pattern: - FeedSyncDelegate: feed sync, DB observation, ChatSummary projection - EventStreamDelegate: event stream, applyUpdate, gap tracking, reactions, typing indicators - MessagingDelegate: per-chat messaging, read pointers, paging, notifications ChatCoordinator becomes an interface with sub-interfaces (FeedOperations, EventStreamOperations, MessagingOperations). RealChatCoordinator is the thin shell that wires delegates via Channel flows, handles lifecycle, and observes feature flags. Signed-off-by: Brandon McAnsh --- .../flipcash/shared/chat/ChatCoordinator.kt | 778 ++---------------- .../flipcash/shared/chat/inject/ChatModule.kt | 10 +- .../shared/chat/internal/ChatStateHolder.kt | 24 + .../chat/internal/RealChatCoordinator.kt | 227 +++++ .../internal/delegates/EventStreamDelegate.kt | 389 +++++++++ .../internal/delegates/FeedSyncDelegate.kt | 190 +++++ .../internal/delegates/MessagingDelegate.kt | 208 +++++ .../chat/ChatCoordinatorEagerBalanceTest.kt | 70 +- .../shared/chat/ChatCoordinatorEventsTest.kt | 82 +- 9 files changed, 1241 insertions(+), 737 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ChatStateHolder.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 1fae39e3f..e34eec357 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -1,735 +1,119 @@ -@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalPagingApi::class) +@file:OptIn(ExperimentalPagingApi::class) package com.flipcash.shared.chat -import androidx.core.app.NotificationManagerCompat -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner import androidx.paging.ExperimentalPagingApi -import androidx.paging.Pager -import androidx.paging.PagingConfig import androidx.paging.PagingData -import androidx.paging.map import com.flipcash.app.core.contacts.DeviceContact -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.persistence.sources.ChatMemberDataSource -import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator -import com.flipcash.app.persistence.sources.ChatMessageDataSource -import com.flipcash.app.persistence.sources.ChatMetadataDataSource -import com.flipcash.app.persistence.sources.ContactDataSource -import com.flipcash.app.persistence.entities.ChatMetadataEntity -import com.flipcash.services.controllers.ChatController -import com.flipcash.services.controllers.ChatMessagingController -import com.flipcash.services.controllers.EventStreamingController import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessagePointer -import com.flipcash.services.models.chat.ChatUpdate -import com.flipcash.services.models.chat.EmojiReaction -import com.flipcash.services.models.chat.MessageContent -import com.flipcash.services.models.chat.MetadataUpdate -import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.ReactionSummary -import com.flipcash.services.models.chat.ReactionUpdate -import com.flipcash.services.models.chat.TypingNotification import com.flipcash.services.models.chat.TypingState -import com.flipcash.services.models.GetDeltaError -import com.flipcash.services.repository.DeltaUpdate -import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.user.UserManager -import com.getcode.opencode.model.accounts.AccountCluster -import com.getcode.opencode.providers.SessionListener -import com.getcode.utils.TraceType -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.utils.decodeBase58 -import com.getcode.utils.trace -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.Clock -import kotlin.time.Duration.Companion.seconds - -@Singleton -class ChatCoordinator @Inject constructor( - private val chatController: ChatController, - private val messagingController: ChatMessagingController, - private val eventStreamingController: EventStreamingController, - private val metadataDataSource: ChatMetadataDataSource, - private val messageDataSource: ChatMessageDataSource, - private val memberDataSource: ChatMemberDataSource, - private val contactDataSource: ContactDataSource, - private val networkObserver: NetworkConnectivityListener, - private val notificationManager: NotificationManagerCompat, - private val userManager: UserManager, - private val tokenCoordinator: TokenCoordinator, - private val featureFlags: FeatureFlagController, - private val dispatchers: DispatcherProvider, -) : SessionListener, DefaultLifecycleObserver { - - companion object { - private const val TAG = "ChatCoordinator" - private val HEARTBEAT_INTERVAL = 30.seconds - private val GAP_FILL_DELAY = 2.seconds - } - - private val supervisorJob = SupervisorJob() - private val scope = CoroutineScope(dispatchers.IO + supervisorJob) - private val cluster = MutableStateFlow(null) - private val _state = MutableStateFlow(ChatState()) - private val sequenceTracker = EventSequenceTracker() - private var syncJob: Job? = null - private var flagObserverJob: Job? = null - private var eventStreamCollectJob: Job? = null - private var feedObserverJob: Job? = null - private var heartbeatJob: Job? = null - private var networkObserverJob: Job? = null - private var backgroundedActiveChat: ChatId? = null - - val state: StateFlow - get() = _state.asStateFlow() +/** + * Feed-level operations: observing the conversation list and its unread state. + * + * Implemented by [com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate]. + */ +interface FeedOperations { + /** Reactive list of all DM conversations, sorted by last activity. */ val feed: Flow> - get() = _state.map { state -> - val selfId = userManager.accountId - state.feed.mapNotNull { metadata -> - // Filter out anonymous chats (DMs where the other member has no name or phone) - val otherMember = metadata.members.firstOrNull { it.userId != selfId } - if (otherMember != null) { - val profile = otherMember.userProfile - val hasIdentity = !profile.displayName.isNullOrBlank() || - !profile.verifiedPhoneNumber.isNullOrBlank() - if (!hasIdentity) return@mapNotNull null - } - - val readPointer = metadata.members - .firstOrNull { it.userId == selfId } - ?.pointers - ?.firstOrNull { it.type == PointerType.READ } - ?.value ?: 0L - - val unreadCount = metadata.lastMessage?.let { lastMsg -> - if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0 - } ?: 0 - - ChatSummary(metadata = metadata, unreadCount = unreadCount) - } - } - - // region SessionListener - - override suspend fun onUserLoggedIn(cluster: AccountCluster) { - trace(tag = TAG, message = "User logged in, hydrating chat", type = TraceType.User) - this.cluster.value = cluster - observeFeedFromDb() - syncFeed() - openEventStream() - startHeartbeat() - observeFeatureFlag() - } - - // endregion - - // region Lifecycle - - init { - ProcessLifecycleOwner.get().lifecycle.addObserver(this) - - networkObserverJob = cluster.filterNotNull() - .flatMapLatest { networkObserver.state } - .distinctUntilChanged() - .filter { it.connected } - .debounce(1.seconds) - .onEach { - if (!isChatEnabled()) return@onEach - trace(tag = TAG, message = "Network connected, re-syncing chat feed", type = TraceType.Process) - syncFeed() - openEventStream() - } - .launchIn(scope) - } - - override fun onStart(owner: LifecycleOwner) { - backgroundedActiveChat?.let { - setActiveChatId(it) - backgroundedActiveChat = null - } - scope.launch { - if (cluster.value != null && isChatEnabled()) { - trace(tag = TAG, message = "Lifecycle resumed, syncing chat feed", type = TraceType.Process) - syncFeed() - openEventStream() - startHeartbeat() - } - } - } - - override fun onStop(owner: LifecycleOwner) { - backgroundedActiveChat = _state.value.activeChat - setActiveChatId(null) - stopHeartbeat() - closeEventStream() - } - - // endregion - - // region Public API - - suspend fun getChatId(contact: DeviceContact): Result { - val raw = contactDataSource.getDmChatId(contact.e164) - if (raw.isNullOrEmpty()) { - return Result.failure(NoDmChatInitializedException(contact.e164)) - } - return runCatching { ChatId(raw.decodeBase58()) } - } - - fun observeUnreadConversations(): Flow { - return feed.map { summaries -> summaries.count { it.unreadCount > 0 } } - } - - fun observeMessages(chatId: ChatId): Flow> { - return messageDataSource.observeMessages(chatId) - } - - fun observeMessagesPaged(chatId: ChatId): Flow> { - return Pager( - config = PagingConfig(pageSize = 50), - remoteMediator = ChatMessageRemoteMediator(chatId, messagingController, messageDataSource), - ) { - messageDataSource.observeForChat(chatId) - }.flow.map { page -> - page.map { entity -> messageDataSource.toChatMessage(entity) } - } - } - - fun observeTypingIndicators(chatId: ChatId): Flow> { - return _state.map { it.typingIndicators[chatId] ?: emptySet() } - } - - fun observeMembers(chatId: ChatId): Flow> { - return memberDataSource.observeMembers(chatId) - } - - fun observeOtherReadPointer(chatId: ChatId): Flow { - val selfId = userManager.accountId - return memberDataSource.observeMembers(chatId) - .map { members -> - members.firstOrNull { it.userId != selfId } - ?.pointers - ?.firstOrNull { it.type == PointerType.READ } - } - .distinctUntilChanged() - } - - suspend fun loadMessages(chatId: ChatId) { - messagingController.getMessages(chatId) - .onSuccess { messages -> - messageDataSource.upsert(chatId, messages) - - val latest = messages.maxByOrNull { it.messageId } ?: return@onSuccess - metadataDataSource.updateLastMessageId(chatId, latest.messageId) - metadataDataSource.updateLastActivity(chatId, latest.timestamp.toEpochMilliseconds()) - } - } - - suspend fun sendMessage(chatId: ChatId, content: String): Result { - val senderId = userManager.accountId - ?: return Result.failure(IllegalStateException("Cannot send message without an account")) - - val content = listOf(MessageContent.Text(content)) - val (_, clientMessageId) = messageDataSource.insertPending( - chatId = chatId, - content = content, - senderId = senderId, - ) - - return messagingController.sendMessage(chatId, content, clientMessageId) - .onSuccess { serverMessage -> - messageDataSource.confirmPending(chatId, clientMessageId, serverMessage) - advanceReadPointer(chatId, serverMessage.messageId) - - // Update feed metadata — reactive flow picks up the change - metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId) - metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds()) - } - .onFailure { - messageDataSource.failPending(chatId, clientMessageId) - } - } - - suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result { - val selfId = userManager.accountId ?: return Result.failure( - IllegalStateException("No account") - ) - - // Update local pointer — reactive flow updates the feed's unread count - val pointer = MessagePointer( - type = PointerType.READ, - userId = selfId, - value = messageId, - timestamp = Clock.System.now(), - ) - memberDataSource.updatePointers(chatId, pointer) - - return messagingController.advancePointer(chatId, PointerType.READ, messageId) - } - - fun setActiveChatId(chatId: ChatId?) { - _state.update { it.copy(activeChat = chatId) } - } - - fun isActiveChat(chatId: ChatId): Boolean { - return _state.value.activeChat == chatId - } - - suspend fun getOtherMemberE164(chatId: ChatId): String? { - val selfId = userManager.accountId - val localMembers = memberDataSource.getMembersForChat(chatId) - val otherMember = localMembers.firstOrNull { it.userId != selfId } - if (otherMember != null) return otherMember.userProfile.verifiedPhoneNumber - // Chat not persisted locally yet — fetch from server - val metadata = chatController.getChat(chatId).getOrNull() ?: return null - memberDataSource.upsert(chatId, metadata.members) - return metadata.members - .firstOrNull { it.userId != selfId } - ?.userProfile?.verifiedPhoneNumber - } + /** Emits the number of conversations that have unread messages. */ + fun observeUnreadConversations(): Flow - fun dismissNotifications(chatId: ChatId) { - notificationManager.cancel(chatId.hashCode()) - } - - suspend fun markAsRead(chatId: ChatId): Result { - val messageId = state.value.feed - .firstOrNull { it.chatId == chatId } - ?.lastMessage?.messageId - ?: messageDataSource.getLatestMessageId(chatId) - ?: return Result.success(Unit) - return advanceReadPointer(chatId, messageId) - .also { dismissNotifications(chatId) } - } - - suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result { - return messagingController.notifyIsTyping(chatId, typingState) - } - - fun refreshFeed() { - syncFeed() - } - - suspend fun reset() { - stopHeartbeat() - closeEventStream() - syncJob?.cancel() - flagObserverJob?.cancel() - feedObserverJob?.cancel() - networkObserverJob?.cancel() - feedObserverJob = null - _state.value = ChatState() - sequenceTracker.clearAll() - cluster.value = null - metadataDataSource.clear() - messageDataSource.clear() - memberDataSource.clear() - supervisorJob.cancel() - trace(tag = TAG, message = "reset complete", type = TraceType.Process) - } - - // endregion - - // region Internal - - private suspend fun isChatEnabled(): Boolean { - val featureFlag = featureFlags.get(FeatureFlag.PhoneNumberSend) - val serverFlag = userManager.state.value.flags?.enablePhoneNumberSend == true - return featureFlag || serverFlag - } - - private fun observeFeatureFlag() { - flagObserverJob?.cancel() - flagObserverJob = combine( - featureFlags.observe(FeatureFlag.PhoneNumberSend), - userManager.state.map { it.flags?.enablePhoneNumberSend == true }, - ) { featureFlag, serverFlag -> featureFlag || serverFlag } - .distinctUntilChanged() - .filter { it } - .onEach { - if (cluster.value != null) { - trace(tag = TAG, message = "Chat feature enabled, syncing feed", type = TraceType.Process) - syncFeed() - openEventStream() - startHeartbeat() - } - } - .launchIn(scope) - } - - private fun observeFeedFromDb() { - feedObserverJob?.cancel() - feedObserverJob = combine( - metadataDataSource.observeAll(), - memberDataSource.observeAll(), - ) { metadataEntities, membersByChat -> - buildFeedFromDb(metadataEntities, membersByChat) - }.onEach { feed -> - _state.update { it.copy(feed = feed) } - }.launchIn(scope) - } - - private suspend fun buildFeedFromDb( - metadataEntities: List, - membersByChat: Map>, - ): List { - return metadataEntities.map { entity -> - val members = membersByChat[entity.chatIdHex] ?: emptyList() - val lastMessage = entity.lastMessageId?.let { - messageDataSource.getLatest(entity.chatIdHex) - } - metadataDataSource.toMetadata(entity, members, lastMessage) - } - } - - private fun syncFeed() { - syncJob?.cancel() - syncJob = scope.launch { performFeedSync() } - } - - private suspend fun performFeedSync() { - _state.update { it.copy(feedSyncState = FeedSyncState.Syncing) } - chatController.getDmChatFeed() - .onSuccess { page -> - metadataDataSource.upsert(page.chats) - - for (chat in page.chats) { - memberDataSource.upsert(chat.chatId, chat.members) - chat.lastMessage?.let { msg -> - messageDataSource.upsert(chat.chatId, listOf(msg)) - } - } - - _state.update { it.copy(feedSyncState = FeedSyncState.Synced) } - trace(tag = TAG, message = "Feed synced: ${page.chats.size} chats", type = TraceType.Process) - - // Delta-sync for chats with a known event sequence; full load for new chats - for (chat in page.chats) { - if (chat.latestEventSequence > 0) { - val localSeq = metadataDataSource.getLatestEventSequence(chat.chatId) - if (localSeq > 0 && localSeq < chat.latestEventSequence) { - performDeltaSync(chat.chatId) - continue - } - } - if (!messageDataSource.hasMessages(chat.chatId)) { - loadMessages(chat.chatId) - } - } - } - .onFailure { error -> - _state.update { it.copy(feedSyncState = FeedSyncState.Error) } - trace(tag = TAG, message = "Feed sync failed: ${error.message}", type = TraceType.Error) - } - } - - private fun openEventStream() { - if (eventStreamingController.isConnected) { - trace(tag = TAG, message = "Event stream already connected, skipping open", type = TraceType.Process) - ensureCollector() - return - } - - eventStreamingController.open(scope) - ensureCollector() - } - - private fun ensureCollector() { - if (eventStreamCollectJob?.isActive != true) { - eventStreamCollectJob = scope.launch { - eventStreamingController.chatUpdates.collect { applyUpdate(it) } - } - } - } - - private fun closeEventStream() { - eventStreamCollectJob?.cancel() - eventStreamCollectJob = null - eventStreamingController.close() - } - - private fun startHeartbeat() { - stopHeartbeat() - heartbeatJob = scope.launch { - while (true) { - delay(HEARTBEAT_INTERVAL) - if (!eventStreamingController.isStreamActive) { - trace(tag = TAG, message = "Heartbeat: event stream dead, syncing feed and reconnecting", type = TraceType.Process) - syncFeed() - // Close the dead ref so open() creates a fresh one - eventStreamingController.close() - openEventStream() - } - } - } - } - - private fun stopHeartbeat() { - heartbeatJob?.cancel() - heartbeatJob = null - } - - private suspend fun applyUpdate(update: ChatUpdate) { - val chatId = update.chatId - - // --- Resolve messages: prefer events, fall back to deprecated newMessages --- - - val resolvedMessages = if (update.events.isNotEmpty()) { - update.events - .flatMap { event -> event.mutations.map { it.message } } - .sortedBy { it.eventSequence } - .distinctBy { it.messageId } - } else { - @Suppress("DEPRECATION") - update.newMessages - } - - trace( - tag = TAG, - message = "applyUpdate: chatId=$chatId, messages=${resolvedMessages.size}, events=${update.events.size}, pointers=${update.pointerUpdates.size}, reactions=${update.reactionUpdates.size}, typing=${update.typingNotifications.size}", - type = TraceType.Process, - ) - - // --- Persist to DB first (suspend, off main thread) --- - - val lastMsg = if (resolvedMessages.isNotEmpty()) { - trace(tag = TAG, message = "Upserting ${resolvedMessages.size} messages for $chatId", type = TraceType.Process) - messageDataSource.upsert(chatId, resolvedMessages) - resolvedMessages.maxByOrNull { it.messageId }?.also { msg -> - metadataDataSource.updateLastMessageId(chatId, msg.messageId) - metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) - } - } else null - - // Advance event sequence cursor — gap-aware (only advance contiguous frontier) - if (update.events.isNotEmpty()) { - val dbCursor = metadataDataSource.getLatestEventSequence(chatId) - val incomingSequences = update.events.map { it.sequence } - val result = sequenceTracker.processSequences(chatId, dbCursor, incomingSequences) - - if (result.newContiguousSequence > dbCursor) { - metadataDataSource.updateLatestEventSequence(chatId, result.newContiguousSequence) - } - - if (result.hasGap) { - scheduleGapFill(chatId) - } - } - - for (pointer in update.pointerUpdates) { - memberDataSource.updatePointers(chatId, pointer) - } - - for (metaUpdate in update.metadataUpdates) { - when (metaUpdate) { - is MetadataUpdate.FullRefresh -> { - metadataDataSource.upsert(metaUpdate.metadata) - memberDataSource.deleteForChat(metaUpdate.metadata.chatId) - memberDataSource.upsert(metaUpdate.metadata.chatId, metaUpdate.metadata.members) - metaUpdate.metadata.lastMessage?.let { msg -> - messageDataSource.upsert(metaUpdate.metadata.chatId, listOf(msg)) - } - } - is MetadataUpdate.LastActivityChanged -> { - metadataDataSource.updateLastActivity( - chatId, - metaUpdate.newLastActivity.toEpochMilliseconds(), - ) - } - } - } - - // --- Process reaction updates into in-memory overlay --- + /** Triggers a server-side feed sync. Safe to call redundantly. */ + fun refreshFeed() +} - if (update.reactionUpdates.isNotEmpty()) { - _state.update { state -> - val chatOverlays = state.reactionOverlays[chatId]?.toMutableMap() ?: mutableMapOf() - for (reactionUpdate in update.reactionUpdates) { - applyReactionUpdate(chatOverlays, reactionUpdate) - } - state.copy( - reactionOverlays = state.reactionOverlays + (chatId to chatOverlays.toMap()) - ) - } - } +/** + * Ephemeral real-time observations derived from the server event stream. + * + * Typing indicators and reaction overlays are held in memory only — they are + * not persisted to Room. + * + * Implemented by [com.flipcash.shared.chat.internal.delegates.EventStreamDelegate]. + */ +interface EventStreamOperations { + /** Emits the set of users currently typing in [chatId]. */ + fun observeTypingIndicators(chatId: ChatId): Flow> + + /** Emits the current reaction summary for a specific message, or `null` if none. */ + fun observeReactions(chatId: ChatId, messageId: Long): Flow +} - // --- Eagerly update token balance for incoming cash --- +/** + * Per-chat messaging operations: sending, receiving, read receipts, and identity. + * + * All methods target a single conversation identified by [ChatId]. + * + * Implemented by [com.flipcash.shared.chat.internal.delegates.MessagingDelegate]. + */ +interface MessagingOperations { + /** Resolves the [ChatId] for an existing DM with [contact]. */ + suspend fun getChatId(contact: DeviceContact): Result - val selfId = userManager.accountId - for (msg in resolvedMessages) { - if (msg.senderId == selfId) continue - for (content in msg.content) { - if (content is MessageContent.Cash) { - tokenCoordinator.add(content.mint, content.amount) - } - } - } + /** Returns the E.164 phone number of the other member in a DM, or `null` if unknown. */ + suspend fun getOtherMemberE164(chatId: ChatId): String? - // --- Check if unknown chat requires a full feed sync --- + /** Marks [chatId] as the currently-viewed chat (used to suppress notifications). */ + fun setActiveChatId(chatId: ChatId?) - if (lastMsg != null) { - if (!metadataDataSource.exists(chatId)) { - syncFeed() - } - } + /** Returns `true` if [chatId] is the currently-viewed chat. */ + fun isActiveChat(chatId: ChatId): Boolean - // --- Update ephemeral state (typing indicators are not DB-backed) --- + /** Cancels any pending system notifications for [chatId]. */ + fun dismissNotifications(chatId: ChatId) - if (update.typingNotifications.isNotEmpty()) { - _state.update { state -> - val currentTypists = state.typingIndicators[chatId]?.toMutableSet() ?: mutableSetOf() - for (notification in update.typingNotifications) { - applyTypingNotification(currentTypists, notification) - } - state.copy( - typingIndicators = state.typingIndicators + (chatId to currentTypists.toSet()) - ) - } - } - } + /** Observes all messages in [chatId] as a flat list. */ + fun observeMessages(chatId: ChatId): Flow> - private fun applyReactionUpdate( - overlays: MutableMap, - update: ReactionUpdate, - ) { - val existing = overlays[update.messageId] - val existingReactions = existing?.reactions?.toMutableList() ?: mutableListOf() + /** Observes messages in [chatId] via Paging 3, with remote-mediated page loads. */ + fun observeMessagesPaged(chatId: ChatId): Flow> - // Find existing reaction for this emoji - val idx = existingReactions.indexOfFirst { it.emoji == update.emoji } - if (idx >= 0) { - val current = existingReactions[idx] - // LWW guard using sequence - if (update.sequence <= current.sequence) return - existingReactions[idx] = EmojiReaction( - emoji = update.emoji, - count = update.count, - reactedBySelf = current.reactedBySelf, // preserved; server will correct on next full fetch - sampleReactors = current.sampleReactors, - sequence = update.sequence, - ) - } else { - existingReactions.add( - EmojiReaction( - emoji = update.emoji, - count = update.count, - reactedBySelf = false, - sampleReactors = emptyList(), - sequence = update.sequence, - ) - ) - } + /** Observes the member list for [chatId]. */ + fun observeMembers(chatId: ChatId): Flow> - // Remove reactions with count == 0 - existingReactions.removeAll { it.count <= 0 } + /** Observes the other member's read pointer in [chatId] (for read receipts). */ + fun observeOtherReadPointer(chatId: ChatId): Flow - overlays[update.messageId] = ReactionSummary( - messageId = update.messageId, - reactions = existingReactions.toList(), - ) - } + /** Fetches the full message history for [chatId] from the server and persists locally. */ + suspend fun loadMessages(chatId: ChatId) - private fun scheduleGapFill(chatId: ChatId) { - val job = scope.launch { - delay(GAP_FILL_DELAY) - if (!sequenceTracker.hasGap(chatId)) return@launch - trace(tag = TAG, message = "Gap fill timeout: fetching delta for $chatId", type = TraceType.Process) - performDeltaSync(chatId) - } - sequenceTracker.setGapFillJob(chatId, job) - } + /** Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. */ + suspend fun sendMessage(chatId: ChatId, content: String): Result - private suspend fun performDeltaSync(chatId: ChatId) { - val afterSequence = metadataDataSource.getLatestEventSequence(chatId) - trace(tag = TAG, message = "Delta sync for $chatId from sequence $afterSequence", type = TraceType.Process) + /** Advances the local and remote read pointer for [chatId] to [messageId]. */ + suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result - try { - val result = messagingController.getDelta(chatId, afterSequence).first() - result - .onSuccess { delta -> - if (delta.messages.isNotEmpty()) { - messageDataSource.upsert(chatId, delta.messages) - val latest = delta.messages.maxByOrNull { it.messageId } - latest?.let { msg -> - metadataDataSource.updateLastMessageId(chatId, msg.messageId) - metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) - } - } - if (delta.latestSequence > afterSequence) { - metadataDataSource.updateLatestEventSequence(chatId, delta.latestSequence) - } - // getDelta is authoritative — reset tracker to the server's sequence - sequenceTracker.resetTo(chatId, delta.latestSequence) - trace(tag = TAG, message = "Delta sync complete: ${delta.messages.size} messages, sequence ${delta.latestSequence}", type = TraceType.Process) - } - .onFailure { error -> - if (error is GetDeltaError.ResetRequired) { - trace(tag = TAG, message = "Delta sync reset required for $chatId, falling back to full load", type = TraceType.Process) - sequenceTracker.clear(chatId) - loadMessages(chatId) - } else { - trace(tag = TAG, message = "Delta sync failed for $chatId: ${error.message}", type = TraceType.Error) - } - } - } catch (e: Exception) { - trace(tag = TAG, message = "Delta sync exception for $chatId: ${e.message}", type = TraceType.Error) - } - } + /** Marks [chatId] as fully read (advances pointer to the latest message). */ + suspend fun markAsRead(chatId: ChatId): Result - fun observeReactions(chatId: ChatId, messageId: Long): Flow { - return _state.map { it.reactionOverlays[chatId]?.get(messageId) } - .distinctUntilChanged() - } + /** Notifies the server of the user's typing state in [chatId]. */ + suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result +} - private fun applyTypingNotification( - typists: MutableSet, - notification: TypingNotification, - ) { - when (notification.state) { - TypingState.STARTED_TYPING, TypingState.STILL_TYPING -> { - typists.removeAll { it.userId == notification.userId } - typists.add(ActiveTypist(userId = notification.userId, since = Clock.System.now())) - } - TypingState.STOPPED_TYPING, TypingState.TYPING_TIMED_OUT -> { - typists.removeAll { it.userId == notification.userId } - } - TypingState.UNKNOWN -> Unit - } - } +/** + * Unified facade for the chat subsystem, composing [FeedOperations], + * [EventStreamOperations], and [MessagingOperations]. + * + * The concrete implementation is + * [RealChatCoordinator][com.flipcash.shared.chat.internal.RealChatCoordinator], + * which delegates each sub-interface to a focused singleton and wires + * cross-delegate events in its `init` block. + * + * @see com.flipcash.shared.chat.internal.RealChatCoordinator + */ +interface ChatCoordinator : FeedOperations, EventStreamOperations, MessagingOperations { + /** Full observable snapshot of chat state (feed, typing, reactions, active chat). */ + val state: StateFlow - // endregion + /** Tears down all connections, clears persisted data, and resets in-memory state. */ + suspend fun reset() } class NoDmChatInitializedException(e164: String) : Exception("No DM chat for $e164") diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 53c1d1d01..59f1f2d9f 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -1,20 +1,28 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.internal.RealChatCoordinator import com.getcode.opencode.providers.SessionListener import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class ChatModule { + @Binds + @Singleton + abstract fun bindChatCoordinator( + impl: RealChatCoordinator + ): ChatCoordinator + @Binds @IntoSet abstract fun bindSessionListener( - coordinator: ChatCoordinator + impl: RealChatCoordinator ): SessionListener } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ChatStateHolder.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ChatStateHolder.kt new file mode 100644 index 000000000..bc8beb725 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ChatStateHolder.kt @@ -0,0 +1,24 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.shared.chat.ChatState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatStateHolder @Inject constructor() { + private val _state = MutableStateFlow(ChatState()) + val state: StateFlow = _state.asStateFlow() + val current: ChatState get() = _state.value + + fun update(transform: (ChatState) -> ChatState) { + _state.update(transform) + } + + fun reset() { + _state.value = ChatState() + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt new file mode 100644 index 000000000..e3b00f2ec --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -0,0 +1,227 @@ +@file:OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + +package com.flipcash.shared.chat.internal + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatState +import com.flipcash.shared.chat.EventStreamOperations +import com.flipcash.shared.chat.FeedOperations +import com.flipcash.shared.chat.MessagingOperations +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.providers.SessionListener +import com.getcode.utils.TraceType +import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +/** + * Thin orchestration shell that implements [ChatCoordinator] by composing three + * focused delegates via Kotlin `by` interface delegation: + * + * | Delegate | Interface | Responsibility | + * |----------|-----------|----------------| + * | [FeedSyncDelegate] | [FeedOperations] | Feed sync, DB observation, unread counts | + * | [EventStreamDelegate] | [EventStreamOperations] | Event stream, real-time updates, gap-aware sequencing, reactions, typing | + * | [MessagingDelegate] | [MessagingOperations] | Per-chat send/receive, read pointers, paging, notifications | + * + * **What lives here (and why):** + * - **Event routing** — each delegate exposes a `Flow` (backed by a `Channel`); + * the `init` block collects both and dispatches cross-delegate calls (e.g. + * feed-delegate's `DeltaSyncNeeded` → `eventStreamDelegate.performDeltaSync`, + * event-stream-delegate's `SyncFeedRequested` → `feedDelegate.syncFeed`). + * All cross-delegate wiring is visible in one place. + * - **Lifecycle methods** — [onStart]/[onStop] are inherently cross-cutting + * (stream connect/disconnect, heartbeat start/stop, active-chat save/restore). + * - **Flow observers** — network reconnect and feature-flag transitions that + * gate whether the chat subsystem should be active. + * + * Delegates require [initialize] with a shared [CoroutineScope] before use; + * this happens in [onUserLoggedIn]. + */ +@Singleton +class RealChatCoordinator @Inject constructor( + private val feedDelegate: FeedSyncDelegate, + private val eventStreamDelegate: EventStreamDelegate, + private val messagingDelegate: MessagingDelegate, + private val stateHolder: ChatStateHolder, + private val userManager: UserManager, + private val featureFlags: FeatureFlagController, + networkObserver: NetworkConnectivityListener, + dispatchers: DispatcherProvider, +) : ChatCoordinator, + SessionListener, + DefaultLifecycleObserver, + FeedOperations by feedDelegate, + EventStreamOperations by eventStreamDelegate, + MessagingOperations by messagingDelegate { + + companion object { + private const val TAG = "ChatCoordinator" + } + + private val supervisorJob = SupervisorJob() + private val scope = CoroutineScope(dispatchers.IO + supervisorJob) + private val cluster = MutableStateFlow(null) + private var flagObserverJob: Job? = null + private var networkObserverJob: Job? = null + private var backgroundedActiveChat: ChatId? = null + + override val state: StateFlow + get() = stateHolder.state + + // region SessionListener + + override suspend fun onUserLoggedIn(cluster: AccountCluster) { + trace(tag = TAG, message = "User logged in, hydrating chat", type = TraceType.User) + this.cluster.value = cluster + feedDelegate.initialize(scope) + eventStreamDelegate.initialize(scope) + feedDelegate.observeFeedFromDb() + feedDelegate.syncFeed() + eventStreamDelegate.open() + eventStreamDelegate.startHeartbeat { feedDelegate.syncFeed() } + observeFeatureFlag() + } + + // endregion + + // region Lifecycle + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + feedDelegate.events + .onEach { event -> + when (event) { + is FeedSyncDelegate.Event.LoadMessages -> + messagingDelegate.loadMessages(event.chatId) + is FeedSyncDelegate.Event.DeltaSyncNeeded -> + eventStreamDelegate.performDeltaSync(event.chatId) + } + }.launchIn(scope) + + eventStreamDelegate.events + .onEach { event -> + when (event) { + is EventStreamDelegate.Event.SyncFeedRequested -> + feedDelegate.syncFeed() + is EventStreamDelegate.Event.LoadMessages -> + messagingDelegate.loadMessages(event.chatId) + } + }.launchIn(scope) + + networkObserverJob = cluster.filterNotNull() + .flatMapLatest { networkObserver.state } + .distinctUntilChanged() + .filter { it.connected } + .debounce(1.seconds) + .onEach { + if (!isChatEnabled()) return@onEach + trace(tag = TAG, message = "Network connected, re-syncing chat feed", type = TraceType.Process) + feedDelegate.syncFeed() + eventStreamDelegate.open() + } + .launchIn(scope) + } + + override fun onStart(owner: LifecycleOwner) { + backgroundedActiveChat?.let { + messagingDelegate.setActiveChatId(it) + backgroundedActiveChat = null + } + scope.launch { + if (cluster.value != null && isChatEnabled()) { + trace(tag = TAG, message = "Lifecycle resumed, syncing chat feed", type = TraceType.Process) + feedDelegate.syncFeed() + eventStreamDelegate.open() + eventStreamDelegate.startHeartbeat { feedDelegate.syncFeed() } + } + } + } + + override fun onStop(owner: LifecycleOwner) { + backgroundedActiveChat = stateHolder.current.activeChat + messagingDelegate.setActiveChatId(null) + eventStreamDelegate.stopHeartbeat() + eventStreamDelegate.close() + } + + // endregion + + // region ChatCoordinator + + override suspend fun reset() { + eventStreamDelegate.stopHeartbeat() + eventStreamDelegate.close() + feedDelegate.cancelJobs() + flagObserverJob?.cancel() + networkObserverJob?.cancel() + stateHolder.reset() + eventStreamDelegate.clearAll() + cluster.value = null + messagingDelegate.clear() + supervisorJob.cancel() + trace(tag = TAG, message = "reset complete", type = TraceType.Process) + } + + // endregion + + // region Internal + + private suspend fun isChatEnabled(): Boolean { + val featureFlag = featureFlags.get(FeatureFlag.PhoneNumberSend) + val serverFlag = userManager.state.value.flags?.enablePhoneNumberSend == true + return featureFlag || serverFlag + } + + private fun observeFeatureFlag() { + flagObserverJob?.cancel() + flagObserverJob = combine( + featureFlags.observe(FeatureFlag.PhoneNumberSend), + userManager.state.map { it.flags?.enablePhoneNumberSend == true }, + ) { featureFlag, serverFlag -> featureFlag || serverFlag } + .distinctUntilChanged() + .filter { it } + .onEach { + if (cluster.value != null) { + trace(tag = TAG, message = "Chat feature enabled, syncing feed", type = TraceType.Process) + feedDelegate.syncFeed() + eventStreamDelegate.open() + eventStreamDelegate.startHeartbeat { feedDelegate.syncFeed() } + } + } + .launchIn(scope) + } + + // endregion +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt new file mode 100644 index 000000000..5c3a6cf3a --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -0,0 +1,389 @@ +package com.flipcash.shared.chat.internal.delegates + +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.EmojiReaction +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.TypingNotification +import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.models.GetDeltaError +import com.flipcash.shared.chat.ActiveTypist +import com.flipcash.shared.chat.EventSequenceTracker +import com.flipcash.shared.chat.EventStreamOperations +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.services.user.UserManager +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds + +/** + * Manages the server-sent event stream and applies real-time [ChatUpdate]s to + * local persistence and in-memory state. + * + * Responsibilities: + * - **Message persistence** — resolves messages from `ChatUpdate.events` (preferred) + * or the deprecated `newMessages` field, then upserts to Room. + * - **Gap-aware event sequencing** — uses [EventSequenceTracker] to maintain a + * contiguous frontier. Only the highest contiguous sequence is persisted; if a + * gap is detected, a timed [getDelta][performDeltaSync] backfill is scheduled. + * - **Reaction overlays** — merges [ReactionUpdate]s into an in-memory + * `Map>` with last-writer-wins on + * `EmojiReaction.sequence`. + * - **Typing indicators** — maintains per-chat `Set` in [ChatStateHolder]. + * - **Eager balance update** — credits incoming cash messages to [TokenCoordinator] + * before the server balance refresh arrives. + * - **Heartbeat** — periodically checks stream liveness and reconnects if dead. + * + * **Cross-delegate communication:** Emits [Event.SyncFeedRequested] when a message + * arrives for an unknown chat, and [Event.LoadMessages] when a delta reset requires + * a full message re-fetch. [RealChatCoordinator] routes these. + * + * Requires [initialize] with a [CoroutineScope] before any work can be launched. + * + * @see com.flipcash.shared.chat.internal.RealChatCoordinator + * @see EventSequenceTracker + */ +@Singleton +class EventStreamDelegate @Inject constructor( + private val eventStreamingController: EventStreamingController, + private val messagingController: ChatMessagingController, + private val metadataDataSource: ChatMetadataDataSource, + private val messageDataSource: ChatMessageDataSource, + private val memberDataSource: ChatMemberDataSource, + private val tokenCoordinator: TokenCoordinator, + private val userManager: UserManager, + private val stateHolder: ChatStateHolder, +) : EventStreamOperations { + + companion object { + private const val TAG = "EventStreamDelegate" + private val GAP_FILL_DELAY = 2.seconds + } + + sealed interface Event { + data object SyncFeedRequested : Event + data class LoadMessages(val chatId: ChatId) : Event + } + + private val _events = Channel(Channel.UNLIMITED) + val events: Flow = _events.receiveAsFlow() + + private val sequenceTracker = EventSequenceTracker() + private var scope: CoroutineScope? = null + private var eventStreamCollectJob: Job? = null + private var heartbeatJob: Job? = null + + // region EventStreamOperations + + override fun observeTypingIndicators(chatId: ChatId): Flow> { + return stateHolder.state.map { it.typingIndicators[chatId] ?: emptySet() } + } + + override fun observeReactions(chatId: ChatId, messageId: Long): Flow { + return stateHolder.state.map { it.reactionOverlays[chatId]?.get(messageId) } + .distinctUntilChanged() + } + + // endregion + + // region Internal + + internal fun initialize(scope: CoroutineScope) { + this.scope = scope + } + + internal fun open() { + val scope = scope ?: return + if (eventStreamingController.isConnected) { + trace(tag = TAG, message = "Event stream already connected, skipping open", type = TraceType.Process) + ensureCollector(scope) + return + } + + eventStreamingController.open(scope) + ensureCollector(scope) + } + + internal fun close() { + eventStreamCollectJob?.cancel() + eventStreamCollectJob = null + eventStreamingController.close() + } + + internal fun startHeartbeat(onReconnect: () -> Unit) { + val scope = scope ?: return + stopHeartbeat() + heartbeatJob = scope.launch { + while (true) { + delay(30.seconds) + if (!eventStreamingController.isStreamActive) { + trace(tag = TAG, message = "Heartbeat: event stream dead, syncing feed and reconnecting", type = TraceType.Process) + onReconnect() + eventStreamingController.close() + open() + } + } + } + } + + internal fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + + internal suspend fun performDeltaSync(chatId: ChatId) { + val afterSequence = metadataDataSource.getLatestEventSequence(chatId) + trace(tag = TAG, message = "Delta sync for $chatId from sequence $afterSequence", type = TraceType.Process) + + try { + val result = messagingController.getDelta(chatId, afterSequence).first() + result + .onSuccess { delta -> + if (delta.messages.isNotEmpty()) { + messageDataSource.upsert(chatId, delta.messages) + val latest = delta.messages.maxByOrNull { it.messageId } + latest?.let { msg -> + metadataDataSource.updateLastMessageId(chatId, msg.messageId) + metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) + } + } + if (delta.latestSequence > afterSequence) { + metadataDataSource.updateLatestEventSequence(chatId, delta.latestSequence) + } + sequenceTracker.resetTo(chatId, delta.latestSequence) + trace(tag = TAG, message = "Delta sync complete: ${delta.messages.size} messages, sequence ${delta.latestSequence}", type = TraceType.Process) + } + .onFailure { error -> + if (error is GetDeltaError.ResetRequired) { + trace(tag = TAG, message = "Delta sync reset required for $chatId, falling back to full load", type = TraceType.Process) + sequenceTracker.clear(chatId) + _events.send(Event.LoadMessages(chatId)) + } else { + trace(tag = TAG, message = "Delta sync failed for $chatId: ${error.message}", type = TraceType.Error) + } + } + } catch (e: Exception) { + trace(tag = TAG, message = "Delta sync exception for $chatId: ${e.message}", type = TraceType.Error) + } + } + + internal fun clearAll() { + sequenceTracker.clearAll() + } + + private fun ensureCollector(scope: CoroutineScope) { + if (eventStreamCollectJob?.isActive != true) { + eventStreamCollectJob = scope.launch { + eventStreamingController.chatUpdates.collect { applyUpdate(it) } + } + } + } + + private suspend fun applyUpdate(update: ChatUpdate) { + val chatId = update.chatId + + // --- Resolve messages: prefer events, fall back to deprecated newMessages --- + + val resolvedMessages = if (update.events.isNotEmpty()) { + update.events + .flatMap { event -> event.mutations.map { it.message } } + .sortedBy { it.eventSequence } + .distinctBy { it.messageId } + } else { + @Suppress("DEPRECATION") + update.newMessages + } + + trace( + tag = TAG, + message = "applyUpdate: chatId=$chatId, messages=${resolvedMessages.size}, events=${update.events.size}, pointers=${update.pointerUpdates.size}, reactions=${update.reactionUpdates.size}, typing=${update.typingNotifications.size}", + type = TraceType.Process, + ) + + // --- Persist to DB --- + + val lastMsg = if (resolvedMessages.isNotEmpty()) { + trace(tag = TAG, message = "Upserting ${resolvedMessages.size} messages for $chatId", type = TraceType.Process) + messageDataSource.upsert(chatId, resolvedMessages) + resolvedMessages.maxByOrNull { it.messageId }?.also { msg -> + metadataDataSource.updateLastMessageId(chatId, msg.messageId) + metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) + } + } else null + + // Advance event sequence cursor — gap-aware + if (update.events.isNotEmpty()) { + val dbCursor = metadataDataSource.getLatestEventSequence(chatId) + val incomingSequences = update.events.map { it.sequence } + val result = sequenceTracker.processSequences(chatId, dbCursor, incomingSequences) + + if (result.newContiguousSequence > dbCursor) { + metadataDataSource.updateLatestEventSequence(chatId, result.newContiguousSequence) + } + + if (result.hasGap) { + scheduleGapFill(chatId) + } + } + + for (pointer in update.pointerUpdates) { + memberDataSource.updatePointers(chatId, pointer) + } + + for (metaUpdate in update.metadataUpdates) { + when (metaUpdate) { + is MetadataUpdate.FullRefresh -> { + metadataDataSource.upsert(metaUpdate.metadata) + memberDataSource.deleteForChat(metaUpdate.metadata.chatId) + memberDataSource.upsert(metaUpdate.metadata.chatId, metaUpdate.metadata.members) + metaUpdate.metadata.lastMessage?.let { msg -> + messageDataSource.upsert(metaUpdate.metadata.chatId, listOf(msg)) + } + } + is MetadataUpdate.LastActivityChanged -> { + metadataDataSource.updateLastActivity( + chatId, + metaUpdate.newLastActivity.toEpochMilliseconds(), + ) + } + } + } + + // --- Process reaction updates --- + + if (update.reactionUpdates.isNotEmpty()) { + stateHolder.update { state -> + val chatOverlays = state.reactionOverlays[chatId]?.toMutableMap() ?: mutableMapOf() + for (reactionUpdate in update.reactionUpdates) { + applyReactionUpdate(chatOverlays, reactionUpdate) + } + state.copy( + reactionOverlays = state.reactionOverlays + (chatId to chatOverlays.toMap()) + ) + } + } + + // --- Eagerly update token balance for incoming cash --- + + val selfId = userManager.accountId + for (msg in resolvedMessages) { + if (msg.senderId == selfId) continue + for (content in msg.content) { + if (content is MessageContent.Cash) { + tokenCoordinator.add(content.mint, content.amount) + } + } + } + + // --- Unknown chat → full feed sync --- + + if (lastMsg != null) { + if (!metadataDataSource.exists(chatId)) { + _events.send(Event.SyncFeedRequested) + } + } + + // --- Typing indicators --- + + if (update.typingNotifications.isNotEmpty()) { + stateHolder.update { state -> + val currentTypists = state.typingIndicators[chatId]?.toMutableSet() ?: mutableSetOf() + for (notification in update.typingNotifications) { + applyTypingNotification(currentTypists, notification) + } + state.copy( + typingIndicators = state.typingIndicators + (chatId to currentTypists.toSet()) + ) + } + } + } + + private fun applyReactionUpdate( + overlays: MutableMap, + update: ReactionUpdate, + ) { + val existing = overlays[update.messageId] + val existingReactions = existing?.reactions?.toMutableList() ?: mutableListOf() + + val idx = existingReactions.indexOfFirst { it.emoji == update.emoji } + if (idx >= 0) { + val current = existingReactions[idx] + if (update.sequence <= current.sequence) return + existingReactions[idx] = EmojiReaction( + emoji = update.emoji, + count = update.count, + reactedBySelf = current.reactedBySelf, + sampleReactors = current.sampleReactors, + sequence = update.sequence, + ) + } else { + existingReactions.add( + EmojiReaction( + emoji = update.emoji, + count = update.count, + reactedBySelf = false, + sampleReactors = emptyList(), + sequence = update.sequence, + ) + ) + } + + existingReactions.removeAll { it.count <= 0 } + + overlays[update.messageId] = ReactionSummary( + messageId = update.messageId, + reactions = existingReactions.toList(), + ) + } + + private fun scheduleGapFill(chatId: ChatId) { + val scope = scope ?: return + val job = scope.launch { + delay(GAP_FILL_DELAY) + if (!sequenceTracker.hasGap(chatId)) return@launch + trace(tag = TAG, message = "Gap fill timeout: fetching delta for $chatId", type = TraceType.Process) + performDeltaSync(chatId) + } + sequenceTracker.setGapFillJob(chatId, job) + } + + private fun applyTypingNotification( + typists: MutableSet, + notification: TypingNotification, + ) { + when (notification.state) { + TypingState.STARTED_TYPING, TypingState.STILL_TYPING -> { + typists.removeAll { it.userId == notification.userId } + typists.add(ActiveTypist(userId = notification.userId, since = Clock.System.now())) + } + TypingState.STOPPED_TYPING, TypingState.TYPING_TIMED_OUT -> { + typists.removeAll { it.userId == notification.userId } + } + TypingState.UNKNOWN -> Unit + } + } + + // endregion +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt new file mode 100644 index 000000000..e6afd904c --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -0,0 +1,190 @@ +package com.flipcash.shared.chat.internal.delegates + +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.PointerType +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.FeedOperations +import com.flipcash.shared.chat.FeedSyncState +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.services.user.UserManager +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the chat feed: syncing conversation metadata from the server, observing + * the local Room database, and projecting [ChatSummary] items with unread counts. + * + * **Cross-delegate communication:** After a feed sync, this delegate may discover + * chats that need their message history loaded or their event sequence caught up. + * Rather than calling other delegates directly, it emits [Event.LoadMessages] or + * [Event.DeltaSyncNeeded] on [events], which [RealChatCoordinator] routes to the + * appropriate delegate. + * + * Requires [initialize] with a [CoroutineScope] before any work can be launched. + * + * @see com.flipcash.shared.chat.internal.RealChatCoordinator + */ +@Singleton +class FeedSyncDelegate @Inject constructor( + private val chatController: ChatController, + private val metadataDataSource: ChatMetadataDataSource, + private val messageDataSource: ChatMessageDataSource, + private val memberDataSource: ChatMemberDataSource, + private val stateHolder: ChatStateHolder, + private val userManager: UserManager, +) : FeedOperations { + + companion object { + private const val TAG = "FeedSyncDelegate" + } + + sealed interface Event { + data class LoadMessages(val chatId: ChatId) : Event + data class DeltaSyncNeeded(val chatId: ChatId) : Event + } + + private val _events = Channel(Channel.UNLIMITED) + val events: Flow = _events.receiveAsFlow() + + private var scope: CoroutineScope? = null + private var syncJob: Job? = null + private var feedObserverJob: Job? = null + + // region FeedOperations + + override val feed: Flow> + get() = stateHolder.state.map { state -> + val selfId = userManager.accountId + state.feed.mapNotNull { metadata -> + val otherMember = metadata.members.firstOrNull { it.userId != selfId } + if (otherMember != null) { + val profile = otherMember.userProfile + val hasIdentity = !profile.displayName.isNullOrBlank() || + !profile.verifiedPhoneNumber.isNullOrBlank() + if (!hasIdentity) return@mapNotNull null + } + + val readPointer = metadata.members + .firstOrNull { it.userId == selfId } + ?.pointers + ?.firstOrNull { it.type == PointerType.READ } + ?.value ?: 0L + + val unreadCount = metadata.lastMessage?.let { lastMsg -> + if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0 + } ?: 0 + + ChatSummary(metadata = metadata, unreadCount = unreadCount) + } + } + + override fun observeUnreadConversations(): Flow { + return feed.map { summaries -> summaries.count { it.unreadCount > 0 } } + } + + override fun refreshFeed() { + syncFeed() + } + + // endregion + + // region Internal + + internal fun initialize(scope: CoroutineScope) { + this.scope = scope + } + + internal fun observeFeedFromDb() { + val scope = scope ?: return + feedObserverJob?.cancel() + feedObserverJob = combine( + metadataDataSource.observeAll(), + memberDataSource.observeAll(), + ) { metadataEntities, membersByChat -> + buildFeedFromDb(metadataEntities, membersByChat) + }.onEach { feed -> + stateHolder.update { it.copy(feed = feed) } + }.launchIn(scope) + } + + internal fun syncFeed() { + val scope = scope ?: return + syncJob?.cancel() + syncJob = scope.launch { performFeedSync() } + } + + internal fun cancelJobs() { + syncJob?.cancel() + feedObserverJob?.cancel() + feedObserverJob = null + } + + private suspend fun buildFeedFromDb( + metadataEntities: List, + membersByChat: Map>, + ): List { + return metadataEntities.map { entity -> + val members = membersByChat[entity.chatIdHex] ?: emptyList() + val lastMessage = entity.lastMessageId?.let { + messageDataSource.getLatest(entity.chatIdHex) + } + metadataDataSource.toMetadata(entity, members, lastMessage) + } + } + + private suspend fun performFeedSync() { + stateHolder.update { it.copy(feedSyncState = FeedSyncState.Syncing) } + chatController.getDmChatFeed() + .onSuccess { page -> + metadataDataSource.upsert(page.chats) + + for (chat in page.chats) { + memberDataSource.upsert(chat.chatId, chat.members) + chat.lastMessage?.let { msg -> + messageDataSource.upsert(chat.chatId, listOf(msg)) + } + } + + stateHolder.update { it.copy(feedSyncState = FeedSyncState.Synced) } + trace(tag = TAG, message = "Feed synced: ${page.chats.size} chats", type = TraceType.Process) + + for (chat in page.chats) { + if (chat.latestEventSequence > 0) { + val localSeq = metadataDataSource.getLatestEventSequence(chat.chatId) + if (localSeq > 0 && localSeq < chat.latestEventSequence) { + _events.send(Event.DeltaSyncNeeded(chat.chatId)) + continue + } + } + if (!messageDataSource.hasMessages(chat.chatId)) { + _events.send(Event.LoadMessages(chat.chatId)) + } + } + } + .onFailure { error -> + stateHolder.update { it.copy(feedSyncState = FeedSyncState.Error) } + trace(tag = TAG, message = "Feed sync failed: ${error.message}", type = TraceType.Error) + } + } + + // endregion +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt new file mode 100644 index 000000000..589e639b6 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -0,0 +1,208 @@ +@file:OptIn(ExperimentalPagingApi::class) + +package com.flipcash.shared.chat.internal.delegates + +import androidx.core.app.NotificationManagerCompat +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingState +import com.flipcash.shared.chat.MessagingOperations +import com.flipcash.shared.chat.NoDmChatInitializedException +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.services.user.UserManager +import com.getcode.utils.decodeBase58 +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock + +/** + * Handles all per-chat messaging operations: sending and receiving messages, + * read-pointer advancement, paging, member identity resolution, and notification + * management. + * + * This delegate is self-contained — it does not emit cross-delegate events. + * All methods target a single conversation identified by [ChatId]. + * + * Message sending follows an optimistic-insert pattern: + * 1. [insertPending][ChatMessageDataSource.insertPending] writes a local placeholder. + * 2. The server call returns the confirmed [ChatMessage]. + * 3. [confirmPending][ChatMessageDataSource.confirmPending] replaces the placeholder, + * or [failPending][ChatMessageDataSource.failPending] marks it as failed. + * + * @see com.flipcash.shared.chat.internal.RealChatCoordinator + */ +@Singleton +class MessagingDelegate @Inject constructor( + private val chatController: ChatController, + private val messagingController: ChatMessagingController, + private val metadataDataSource: ChatMetadataDataSource, + private val messageDataSource: ChatMessageDataSource, + private val memberDataSource: ChatMemberDataSource, + private val contactDataSource: ContactDataSource, + private val notificationManager: NotificationManagerCompat, + private val userManager: UserManager, + private val stateHolder: ChatStateHolder, +) : MessagingOperations { + + // region MessagingOperations + + override suspend fun getChatId(contact: DeviceContact): Result { + val raw = contactDataSource.getDmChatId(contact.e164) + if (raw.isNullOrEmpty()) { + return Result.failure(NoDmChatInitializedException(contact.e164)) + } + return runCatching { ChatId(raw.decodeBase58()) } + } + + override suspend fun getOtherMemberE164(chatId: ChatId): String? { + val selfId = userManager.accountId + val localMembers = memberDataSource.getMembersForChat(chatId) + val otherMember = localMembers.firstOrNull { it.userId != selfId } + if (otherMember != null) return otherMember.userProfile.verifiedPhoneNumber + + val metadata = chatController.getChat(chatId).getOrNull() ?: return null + memberDataSource.upsert(chatId, metadata.members) + return metadata.members + .firstOrNull { it.userId != selfId } + ?.userProfile?.verifiedPhoneNumber + } + + override fun setActiveChatId(chatId: ChatId?) { + stateHolder.update { it.copy(activeChat = chatId) } + } + + override fun isActiveChat(chatId: ChatId): Boolean { + return stateHolder.current.activeChat == chatId + } + + override fun dismissNotifications(chatId: ChatId) { + notificationManager.cancel(chatId.hashCode()) + } + + override fun observeMessages(chatId: ChatId): Flow> { + return messageDataSource.observeMessages(chatId) + } + + override fun observeMessagesPaged(chatId: ChatId): Flow> { + return Pager( + config = PagingConfig(pageSize = 50), + remoteMediator = ChatMessageRemoteMediator(chatId, messagingController, messageDataSource), + ) { + messageDataSource.observeForChat(chatId) + }.flow.map { page -> + page.map { entity -> messageDataSource.toChatMessage(entity) } + } + } + + override fun observeMembers(chatId: ChatId): Flow> { + return memberDataSource.observeMembers(chatId) + } + + override fun observeOtherReadPointer(chatId: ChatId): Flow { + val selfId = userManager.accountId + return memberDataSource.observeMembers(chatId) + .map { members -> + members.firstOrNull { it.userId != selfId } + ?.pointers + ?.firstOrNull { it.type == PointerType.READ } + } + .distinctUntilChanged() + } + + override suspend fun loadMessages(chatId: ChatId) { + messagingController.getMessages(chatId) + .onSuccess { messages -> + messageDataSource.upsert(chatId, messages) + + val latest = messages.maxByOrNull { it.messageId } ?: return@onSuccess + metadataDataSource.updateLastMessageId(chatId, latest.messageId) + metadataDataSource.updateLastActivity(chatId, latest.timestamp.toEpochMilliseconds()) + } + } + + override suspend fun sendMessage(chatId: ChatId, content: String): Result { + val senderId = userManager.accountId + ?: return Result.failure(IllegalStateException("Cannot send message without an account")) + + val content = listOf(MessageContent.Text(content)) + val (_, clientMessageId) = messageDataSource.insertPending( + chatId = chatId, + content = content, + senderId = senderId, + ) + + return messagingController.sendMessage(chatId, content, clientMessageId) + .onSuccess { serverMessage -> + messageDataSource.confirmPending(chatId, clientMessageId, serverMessage) + advanceReadPointer(chatId, serverMessage.messageId) + + metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId) + metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds()) + } + .onFailure { + messageDataSource.failPending(chatId, clientMessageId) + } + } + + override suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result { + val selfId = userManager.accountId ?: return Result.failure( + IllegalStateException("No account") + ) + + val pointer = MessagePointer( + type = PointerType.READ, + userId = selfId, + value = messageId, + timestamp = Clock.System.now(), + ) + memberDataSource.updatePointers(chatId, pointer) + + return messagingController.advancePointer(chatId, PointerType.READ, messageId) + } + + override suspend fun markAsRead(chatId: ChatId): Result { + val messageId = stateHolder.current.feed + .firstOrNull { it.chatId == chatId } + ?.lastMessage?.messageId + ?: messageDataSource.getLatestMessageId(chatId) + ?: return Result.success(Unit) + return advanceReadPointer(chatId, messageId) + .also { dismissNotifications(chatId) } + } + + override suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result { + return messagingController.notifyIsTyping(chatId, typingState) + } + + // endregion + + // region Internal + + internal suspend fun clear() { + metadataDataSource.clear() + messageDataSource.clear() + memberDataSource.clear() + } + + // endregion +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index 572525c26..a01fd45e9 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -1,6 +1,5 @@ package com.flipcash.shared.chat -import androidx.core.app.NotificationManagerCompat import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource @@ -15,6 +14,11 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.RealChatCoordinator +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint @@ -35,6 +39,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) @@ -49,7 +54,7 @@ class ChatCoordinatorEagerBalanceTest { private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) private lateinit var tokenCoordinator: TokenCoordinator - private lateinit var coordinator: ChatCoordinator + private lateinit var coordinator: RealChatCoordinator private lateinit var testDispatchers: TestDispatchers @Before @@ -67,19 +72,52 @@ class ChatCoordinatorEagerBalanceTest { testDispatchers = TestDispatchers(TestCoroutineScheduler()) - coordinator = ChatCoordinator( + val stateHolder = ChatStateHolder() + val memberDataSource = mockk(relaxed = true) + val messagingController = mockk(relaxed = true) + val metadataDataSource = mockk(relaxed = true) + val messageDataSource = mockk(relaxed = true) + + val feedDelegate = FeedSyncDelegate( chatController = chatController, - messagingController = mockk(relaxed = true), - eventStreamingController = eventStreamingController, - metadataDataSource = mockk(relaxed = true), - messageDataSource = mockk(relaxed = true), - memberDataSource = mockk(relaxed = true), - contactDataSource = mockk(relaxed = true), - networkObserver = mockk(relaxed = true), - notificationManager = mockk(relaxed = true), + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = stateHolder, userManager = userManager, + ) + + val eventStreamDelegate = EventStreamDelegate( + eventStreamingController = eventStreamingController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, tokenCoordinator = tokenCoordinator, + userManager = userManager, + stateHolder = stateHolder, + ) + + val messagingDelegate = MessagingDelegate( + chatController = chatController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + contactDataSource = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + ) + + coordinator = RealChatCoordinator( + feedDelegate = feedDelegate, + eventStreamDelegate = eventStreamDelegate, + messagingDelegate = messagingDelegate, + stateHolder = stateHolder, + userManager = userManager, featureFlags = mockk(relaxed = true), + networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, ) } @@ -125,7 +163,7 @@ class ChatCoordinatorEagerBalanceTest { triggerCollection() val amount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD) chatUpdatesChannel.send(chatUpdate(cashMessage(senderId = otherId, amount = amount))) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() coVerify(exactly = 1) { tokenCoordinator.add(mint, amount) } @@ -136,7 +174,7 @@ class ChatCoordinatorEagerBalanceTest { fun `self-sent cash message does not trigger tokenCoordinator add`() = runTest(testDispatchers.dispatcher) { triggerCollection() chatUpdatesChannel.send(chatUpdate(cashMessage(senderId = selfId))) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() coVerify(exactly = 0) { tokenCoordinator.add(any(), any()) } @@ -147,7 +185,7 @@ class ChatCoordinatorEagerBalanceTest { fun `text message does not trigger tokenCoordinator add`() = runTest(testDispatchers.dispatcher) { triggerCollection() chatUpdatesChannel.send(chatUpdate(textMessage(senderId = otherId))) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() coVerify(exactly = 0) { tokenCoordinator.add(any(), any()) } @@ -164,7 +202,7 @@ class ChatCoordinatorEagerBalanceTest { val msg2 = cashMessage(senderId = otherId, amount = amount2, mint = mintB).copy(messageId = 3L) chatUpdatesChannel.send(chatUpdate(msg1, msg2)) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() coVerify(exactly = 1) { tokenCoordinator.add(mint, amount1) } @@ -179,7 +217,7 @@ class ChatCoordinatorEagerBalanceTest { val outgoing = cashMessage(senderId = selfId).copy(messageId = 3L) chatUpdatesChannel.send(chatUpdate(incoming, outgoing)) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() coVerify(exactly = 1) { tokenCoordinator.add(any(), any()) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index 79c6fe9de..87d268598 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -1,6 +1,5 @@ package com.flipcash.shared.chat -import androidx.core.app.NotificationManagerCompat import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource @@ -19,6 +18,11 @@ import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.ReactionUpdate +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.RealChatCoordinator +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate import com.getcode.utils.network.NetworkConnectivityListener import com.flipcash.services.user.UserManager import io.mockk.coEvery @@ -40,6 +44,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) @@ -54,7 +59,7 @@ class ChatCoordinatorEventsTest { private lateinit var metadataDataSource: ChatMetadataDataSource private lateinit var messageDataSource: ChatMessageDataSource - private lateinit var coordinator: ChatCoordinator + private lateinit var coordinator: RealChatCoordinator private lateinit var testDispatchers: TestDispatchers @Before @@ -71,22 +76,53 @@ class ChatCoordinatorEventsTest { metadataDataSource = mockk(relaxed = true) messageDataSource = mockk(relaxed = true) + val memberDataSource = mockk(relaxed = true) + val messagingController = mockk(relaxed = true) testDispatchers = TestDispatchers(TestCoroutineScheduler()) - coordinator = ChatCoordinator( + val stateHolder = ChatStateHolder() + + val feedDelegate = FeedSyncDelegate( chatController = chatController, - messagingController = mockk(relaxed = true), + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = stateHolder, + userManager = userManager, + ) + + val eventStreamDelegate = EventStreamDelegate( eventStreamingController = eventStreamingController, + messagingController = messagingController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, - memberDataSource = mockk(relaxed = true), + memberDataSource = memberDataSource, + tokenCoordinator = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + ) + + val messagingDelegate = MessagingDelegate( + chatController = chatController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, contactDataSource = mockk(relaxed = true), - networkObserver = mockk(relaxed = true), - notificationManager = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + ) + + coordinator = RealChatCoordinator( + feedDelegate = feedDelegate, + eventStreamDelegate = eventStreamDelegate, + messagingDelegate = messagingDelegate, + stateHolder = stateHolder, userManager = userManager, - tokenCoordinator = mockk(relaxed = true), featureFlags = mockk(relaxed = true), + networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, ) } @@ -131,7 +167,7 @@ class ChatCoordinatorEventsTest { events = listOf(chatEvent(1, eventMsg)), ) chatUpdatesChannel.send(update) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() // Should upsert the event message, not the deprecated one @@ -155,7 +191,7 @@ class ChatCoordinatorEventsTest { events = emptyList(), ) chatUpdatesChannel.send(update) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() coVerify { @@ -183,7 +219,7 @@ class ChatCoordinatorEventsTest { ), ) chatUpdatesChannel.send(update) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() // Should have 2 unique messages (deduped by messageId, taking first by sorted eventSequence) @@ -212,7 +248,7 @@ class ChatCoordinatorEventsTest { ), ) chatUpdatesChannel.send(update) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() coVerify { metadataDataSource.updateLatestEventSequence(chatId, 2L) } @@ -230,7 +266,7 @@ class ChatCoordinatorEventsTest { events = listOf(chatEvent(1, textMessage(id = 1, eventSequence = 1))), ) chatUpdatesChannel.send(update1) - advanceTimeBy(500) + advanceTimeBy(500.milliseconds) runCurrent() val update2 = ChatUpdate( @@ -238,7 +274,7 @@ class ChatCoordinatorEventsTest { events = listOf(chatEvent(3, textMessage(id = 3, eventSequence = 3))), ) chatUpdatesChannel.send(update2) - advanceTimeBy(500) + advanceTimeBy(500.milliseconds) runCurrent() // Cursor should advance to 1 (contiguous), not 3 @@ -257,21 +293,21 @@ class ChatCoordinatorEventsTest { chatId = chatId, events = listOf(chatEvent(1, textMessage(id = 1, eventSequence = 1))), )) - advanceTimeBy(100) + advanceTimeBy(100.milliseconds) runCurrent() chatUpdatesChannel.send(ChatUpdate( chatId = chatId, events = listOf(chatEvent(3, textMessage(id = 3, eventSequence = 3))), )) - advanceTimeBy(100) + advanceTimeBy(100.milliseconds) runCurrent() chatUpdatesChannel.send(ChatUpdate( chatId = chatId, events = listOf(chatEvent(2, textMessage(id = 2, eventSequence = 2))), )) - advanceTimeBy(100) + advanceTimeBy(100.milliseconds) runCurrent() // After filling the gap, cursor should advance to 3 @@ -302,7 +338,7 @@ class ChatCoordinatorEventsTest { ), ) chatUpdatesChannel.send(update) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() val state = coordinator.state.value @@ -335,7 +371,7 @@ class ChatCoordinatorEventsTest { ), ), )) - advanceTimeBy(500) + advanceTimeBy(500.milliseconds) runCurrent() // Stale update: count=1, sequence=2 (older) @@ -353,7 +389,7 @@ class ChatCoordinatorEventsTest { ), ), )) - advanceTimeBy(500) + advanceTimeBy(500.milliseconds) runCurrent() val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions @@ -383,7 +419,7 @@ class ChatCoordinatorEventsTest { ), ), )) - advanceTimeBy(500) + advanceTimeBy(500.milliseconds) runCurrent() // Remove reaction (count=0) @@ -401,7 +437,7 @@ class ChatCoordinatorEventsTest { ), ), )) - advanceTimeBy(500) + advanceTimeBy(500.milliseconds) runCurrent() val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions @@ -437,7 +473,7 @@ class ChatCoordinatorEventsTest { ), ), )) - advanceTimeBy(1_000) + advanceTimeBy(1_000.milliseconds) runCurrent() val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions From 14b4fe307ac423fd851b92e12412783d4e5ba4fa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 24 Jun 2026 14:37:15 -0400 Subject: [PATCH 8/8] test(messaging): add tests for new ChatMessagingController RPCs Cover editMessage, deleteMessage, addReaction, removeReaction, getReactors, getReactionSummary, getReactionSummaries, and getDelta with no-account-cluster, parameter-forwarding, and result-surfacing tests. Wire up FakeChatMessagingRepository with tracked state for all new repository methods. --- .../ChatMessagingControllerTest.kt | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt index d19fb609f..e8b0a5812 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt @@ -4,10 +4,18 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.TypingState import com.flipcash.services.repository.ChatMessagingRepository +import com.flipcash.services.repository.DeltaUpdate +import com.flipcash.services.repository.ReactorsPage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 import com.getcode.opencode.model.accounts.AccountCluster @@ -47,6 +55,14 @@ class ChatMessagingControllerTest { unreadSeq = id, ) + private fun stubReaction(emoji: Emoji, count: Long = 1) = EmojiReaction( + emoji = emoji, + count = count, + reactedBySelf = false, + sampleReactors = emptyList(), + sequence = 1, + ) + // region getMessage @Test @@ -183,6 +199,281 @@ class ChatMessagingControllerTest { } // endregion + + // region editMessage + + @Test + fun `editMessage fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.editMessage(testChatId, 1, listOf(MessageContent.Text("edited")), 5) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `editMessage forwards parameters`() = runTest { + stubOwner() + val content = listOf(MessageContent.Text("edited")) + repository.editMessageResult = Result.success(stubMessage(1, "edited")) + + controller.editMessage(testChatId, 1, content, 5) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(content, repository.lastContent) + assertEquals(5L, repository.lastExpectedEventSequence) + } + + @Test + fun `editMessage returns updated message`() = runTest { + stubOwner() + val edited = stubMessage(1, "edited") + repository.editMessageResult = Result.success(edited) + + val result = controller.editMessage(testChatId, 1, listOf(MessageContent.Text("edited")), 5) + + assertEquals("edited", (result.getOrThrow().content.first() as MessageContent.Text).text) + } + + // endregion + + // region deleteMessage + + @Test + fun `deleteMessage fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.deleteMessage(testChatId, 1, 5) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `deleteMessage forwards parameters`() = runTest { + stubOwner() + repository.deleteMessageResult = Result.success(stubMessage(1)) + + controller.deleteMessage(testChatId, 1, 5) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(5L, repository.lastExpectedEventSequence) + } + + @Test + fun `deleteMessage surfaces repository failure`() = runTest { + stubOwner() + val cause = RuntimeException("not found") + repository.deleteMessageResult = Result.failure(cause) + + val result = controller.deleteMessage(testChatId, 1, 5) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region addReaction + + @Test + fun `addReaction fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.addReaction(testChatId, 1, Emoji("\uD83D\uDC4D")) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `addReaction forwards parameters`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDC4D") + repository.addReactionResult = Result.success(stubReaction(emoji)) + + controller.addReaction(testChatId, 1, emoji) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(emoji, repository.lastEmoji) + } + + @Test + fun `addReaction returns reaction from repository`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDC4D") + val reaction = stubReaction(emoji, count = 3) + repository.addReactionResult = Result.success(reaction) + + val result = controller.addReaction(testChatId, 1, emoji) + + assertEquals(3L, result.getOrThrow().count) + assertEquals(emoji, result.getOrThrow().emoji) + } + + // endregion + + // region removeReaction + + @Test + fun `removeReaction fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.removeReaction(testChatId, 1, Emoji("\uD83D\uDC4D")) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `removeReaction forwards parameters`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDE00") + repository.removeReactionResult = Result.success(stubReaction(emoji, count = 0)) + + controller.removeReaction(testChatId, 1, emoji) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(emoji, repository.lastEmoji) + } + + // endregion + + // region getReactors + + @Test + fun `getReactors fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.getReactors(testChatId, 1, Emoji("\uD83D\uDC4D")) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getReactors forwards parameters with default QueryOptions`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDC4D") + repository.getReactorsResult = Result.success(ReactorsPage(emptyList(), hasMore = false)) + + controller.getReactors(testChatId, 1, emoji) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(emoji, repository.lastEmoji) + assertEquals(QueryOptions(), repository.lastQueryOptions) + } + + @Test + fun `getReactors returns page from repository`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDC4D") + val page = ReactorsPage( + reactors = listOf(Reactor(userId = listOf(1.toByte()), reactedAt = Instant.fromEpochSeconds(1000))), + hasMore = true, + ) + repository.getReactorsResult = Result.success(page) + + val result = controller.getReactors(testChatId, 1, emoji) + + assertEquals(1, result.getOrThrow().reactors.size) + assertTrue(result.getOrThrow().hasMore) + } + + // endregion + + // region getReactionSummary + + @Test + fun `getReactionSummary fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.getReactionSummary(testChatId, 1) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getReactionSummary forwards chatId and messageId`() = runTest { + stubOwner() + repository.getReactionSummaryResult = Result.success(ReactionSummary(messageId = 42, reactions = emptyList())) + + controller.getReactionSummary(testChatId, 42) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(42L, repository.lastMessageId) + } + + @Test + fun `getReactionSummary returns summary from repository`() = runTest { + stubOwner() + val summary = ReactionSummary( + messageId = 1, + reactions = listOf(stubReaction(Emoji("\uD83D\uDC4D"), count = 5)), + ) + repository.getReactionSummaryResult = Result.success(summary) + + val result = controller.getReactionSummary(testChatId, 1) + + assertEquals(1, result.getOrThrow().reactions.size) + assertEquals(5L, result.getOrThrow().reactions.first().count) + } + + // endregion + + // region getReactionSummaries + + @Test + fun `getReactionSummaries fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.getReactionSummaries(testChatId) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getReactionSummaries uses default QueryOptions`() = runTest { + stubOwner() + repository.getReactionSummariesResult = Result.success(emptyList()) + + controller.getReactionSummaries(testChatId) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(QueryOptions(), repository.lastQueryOptions) + } + + @Test + fun `getReactionSummaries returns list from repository`() = runTest { + stubOwner() + val summaries = listOf( + ReactionSummary(messageId = 1, reactions = listOf(stubReaction(Emoji("\uD83D\uDC4D")))), + ReactionSummary(messageId = 2, reactions = emptyList()), + ) + repository.getReactionSummariesResult = Result.success(summaries) + + val result = controller.getReactionSummaries(testChatId) + + assertEquals(2, result.getOrThrow().size) + } + + // endregion + + // region getDelta + + @Test + fun `getDelta fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = runCatching { controller.getDelta(testChatId, 0) } + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getDelta forwards chatId and afterSequence`() = runTest { + stubOwner() + + controller.getDelta(testChatId, 10) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(10L, repository.lastAfterSequence) + } + + // endregion } // region Fakes @@ -192,15 +483,25 @@ private class FakeChatMessagingRepository : ChatMessagingRepository { var getMessagesResult: Result> = Result.failure(RuntimeException("not configured")) var getMessagesByIdsResult: Result> = Result.failure(RuntimeException("not configured")) var sendMessageResult: Result = Result.failure(RuntimeException("not configured")) + var editMessageResult: Result = Result.failure(RuntimeException("not configured")) + var deleteMessageResult: Result = Result.failure(RuntimeException("not configured")) + var addReactionResult: Result = Result.failure(RuntimeException("not configured")) + var removeReactionResult: Result = Result.failure(RuntimeException("not configured")) + var getReactorsResult: Result = Result.failure(RuntimeException("not configured")) + var getReactionSummaryResult: Result = Result.failure(RuntimeException("not configured")) + var getReactionSummariesResult: Result> = Result.failure(RuntimeException("not configured")) var advancePointerResult: Result = Result.failure(RuntimeException("not configured")) var notifyIsTypingResult: Result = Result.failure(RuntimeException("not configured")) var lastChatId: ChatId? = null var lastMessageId: Long? = null var lastMessageIds: List? = null + var lastAfterSequence: Long? = null var lastQueryOptions: QueryOptions? = null var lastContent: List? = null var lastClientMessageId: ClientMessageId? = null + var lastExpectedEventSequence: Long? = null + var lastEmoji: Emoji? = null var lastPointerType: PointerType? = null var lastTypingState: TypingState? = null @@ -219,11 +520,51 @@ private class FakeChatMessagingRepository : ChatMessagingRepository { return getMessagesByIdsResult } + override fun getDelta(owner: Ed25519.KeyPair, chatId: ChatId, afterSequence: Long): Flow> { + lastChatId = chatId; lastAfterSequence = afterSequence + return flowOf(Result.failure(RuntimeException("not configured"))) + } + override suspend fun sendMessage(owner: Ed25519.KeyPair, chatId: ChatId, content: List, clientMessageId: ClientMessageId): Result { lastChatId = chatId; lastContent = content; lastClientMessageId = clientMessageId return sendMessageResult } + override suspend fun editMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, content: List, expectedEventSequence: Long): Result { + lastChatId = chatId; lastMessageId = messageId; lastContent = content; lastExpectedEventSequence = expectedEventSequence + return editMessageResult + } + + override suspend fun deleteMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, expectedEventSequence: Long): Result { + lastChatId = chatId; lastMessageId = messageId; lastExpectedEventSequence = expectedEventSequence + return deleteMessageResult + } + + override suspend fun addReaction(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, emoji: Emoji): Result { + lastChatId = chatId; lastMessageId = messageId; lastEmoji = emoji + return addReactionResult + } + + override suspend fun removeReaction(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, emoji: Emoji): Result { + lastChatId = chatId; lastMessageId = messageId; lastEmoji = emoji + return removeReactionResult + } + + override suspend fun getReactors(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, emoji: Emoji, queryOptions: QueryOptions): Result { + lastChatId = chatId; lastMessageId = messageId; lastEmoji = emoji; lastQueryOptions = queryOptions + return getReactorsResult + } + + override suspend fun getReactionSummary(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long): Result { + lastChatId = chatId; lastMessageId = messageId + return getReactionSummaryResult + } + + override suspend fun getReactionSummaries(owner: Ed25519.KeyPair, chatId: ChatId, queryOptions: QueryOptions): Result> { + lastChatId = chatId; lastQueryOptions = queryOptions + return getReactionSummariesResult + } + override suspend fun advancePointer(owner: Ed25519.KeyPair, chatId: ChatId, pointerType: PointerType, messageId: Long): Result { lastChatId = chatId; lastPointerType = pointerType; lastMessageId = messageId return advancePointerResult