Repository navigation
chore(deps): move to flipcash2 contract dd5e92db - #804
Merged
Merged
Conversation
…reporting) Wraps four themes from the flipcash2 proto range e1f4116c..dd5e92db: - Redaction/ViewMode: ConversationMessage.redacted, ConversationViewMode plumbed through getChat/getMessages/getDelta and their FlipClient wrappers, defaulting to .full so existing callers are unaffected. - Chat muting: MuteChat/UnmuteChat wrappers on ChatService, the ConversationViewerState/ConversationMuteState domain types, a viewerStateChanged stream event applied convergently by version in ConversationStore, and NotificationPayload.isMuted for the push ChatMetadata.muted flag. - Reporting: new ReportingService/FlipClient+Reporting wrapping the wholly additive Report RPC, with a ReportTarget enum over user/chat/message/blob. - EventStreamer.handleStreamError also gets .notFound/.streamExpired arms; the contract added these StreamError.Code cases and the existing exhaustive switch would not compile against it. No reacted_by_self call sites exist in this repo, so that rename needed no changes. FlipcashAPI/Package.swift keeps the flipcash2 pin at 0.9.0; this is scaffolding against the local override, not a release bump. Out of scope: the muting UI (duration picker, countdown, notification suppression) and ViewMode on GetMessageRequest/ChatPreviewParams, none of which exist yet on either side.
bmc08gt
marked this pull request as ready for review
September 18, 2026 23:30
bmc08gt
added a commit
that referenced
this pull request
Sep 19, 2026
* feat(chat): mute a group chat, and keep a muted chat quiet The contract sync to dd5e92db added MuteChat/UnmuteChat and the muted flag on the push payload; the RPCs were wrapped but nothing called them and nothing read the flag. This adds the UI and both notification paths. Entry point is the group chat profile, between the invite row and Leave Group. Tapping it opens a sheet offering 1 hour, 8 hours, 1 week, or Always — WhatsApp's set plus Telegram's 1 hour. Once muted, the row flips to "Unmute Notifications" and reads "Muted until 5:56 PM" on the right; unmuting is that same row, since UnmuteChat is its own RPC with nothing to ask. DMs have no settings screen to hang this on, so it is group-only. A timed mute lapses with no server signal, so nothing stores a computed muted boolean: the until-timestamp is kept and compared against now on read, and the profile screen re-reads `now` exactly at the expiry so the row flips back on its own. Mute is cleared server-side on leave and its version restarts, so leaving drops the cached viewer state entirely — keeping the old version would make a rejoined chat's first update look stale and be swallowed. Both notification paths read the payload flag rather than local state, which is what the extension has to do anyway: - Foreground: `willPresent` returns empty presentation options for a muted chat. Everything above that point still runs, so the message is still stored and still counted as unread. - Background: the service extension cannot drop a notification — whatever it returns is displayed — so `.passive` with no sound is the floor. A muted chat's message still joins the notification list. Unread counts are deliberately untouched. They come from the stored message and the read watermark, never from the notification, so muting silences the banner without zeroing the badge. Viewer state is now cached in the conversations table as JSON, the same way `rules` is, storing the whole struct so the version survives a cold start and a reload cannot beat a fresher stream update. Without it a muted chat renders unmuted between the cache load and the feed refresh; Android is persisting it too. Schema version bumped accordingly. Also fixes two switches the scaffold commit left non-exhaustive (`persist(event:)` and `hydrateIfUnknown`), and restores the exact signatures `ConversationFetching`/`ConversationMessaging` require — a defaulted `viewMode` does not witness a requirement that omits it, so the protocol conformance had stopped compiling. * feat(chat): mute the DM with someone from their profile Muting was reachable only from a group's profile screen. A DM has no such screen — a tip DM's title opens the counterpart's user profile, which carried only Block — so the chat type most likely to be noisy had no way to be silenced. Move the control out of ChatProfileScreen into ChatMuteRow and host it on both screens. The row owns the picker presentation, the unmute call and the countdown that lapses a timed mute, so the two surfaces can't drift; they differ only in the chevron they draw, which each screen already styles its own way. UserProfileScreen resolves the chat through tipDM(withUserID:), which matches the roster rather than going through counterpart(excluding:) — that falls back to the first member and would answer a malformed single-member chat with the viewer themselves. Hidden chats are excluded, since a blocked counterpart's DM is off the feed and muting a chat the user can't see isn't a control worth offering. The lookup re-runs on every pass, so a DM a tip creates while the screen is open brings its mute row with it. * chore(deps): resolve flipcash2-client-protocol 0.10.0 in the workspace #804 bumped FlipcashAPI's exact pin to 0.10.0 but left the workspace Package.resolved on 0.9.0, so every clean resolve rewrites the file and shows up as a dirty tree. Commit the resolution the pin already requires. * feat(chat): move the mute state off the row and unmute onto the sheet The mute row overflowed in the state that needed it most. Muted, it drew "Unmute Notifications" at 20pt next to "Muted until 5:56 PM" at 14pt, a 45pt icon slot and a chevron — the two longest strings always appeared together, because the wording that grew was the muted one. The label also swapped under the tap that changed it, which read as a missed tap. The row is now always "Mute Notifications" and always opens the picker. What it used to report moves under the chat title beside the member count, as ChatMuteStatusLabel — shared for the same reason the row is, since a DM and a group are the same mute. The sheet gains "Never", which leads the list once there is a mute to clear and is left off when there isn't; it calls UnmuteChat rather than muting for zero seconds, and is worded as a duration only because every label there names the resulting state rather than the action. With no state left to draw, the row keeps one piece of state instead of three. * fix(chat): hold the mute line's height and cross-fade it The line was absent while a chat was audible, so the rows below it jumped up the moment a mute was cleared or lapsed — the whole screen appeared to re-lay itself out rather than one fact going away. It now always renders, falling back to a placeholder it never shows, and carries its visibility on opacity with a 0.25s ease. Nothing moves, and the line is hidden from accessibility while it is invisible. * feat(chat): draw the mute status as a chip Under the chat title sit the chat's own facts — its name, its size, when someone joined. The mute is the viewer's setting, and given the same secondary styling it read as a fourth fact about the chat; on a counterpart's profile it was the fourth such line in a row. chip(.standard) is the existing treatment for a non-interactive affordance in this flow, and its fill gives the line its own ground without adding a colour. * feat(chat): spring the mute chip in, fade it out Arriving is the event worth seeing — the user has just chosen it — so the chip scales up from 0.85 with a little overshoot. Going away is either their unmute or a deadline passing quietly, and neither wants attention drawn to it, so it leaves on a plain ease. The animation is read against the new state, which is what lets one value drive two curves. * feat(chat): tint the mute chip amber The mute is the one thing on a profile screen that stops being true on its own, and white on the row fill gave it the same weight as the chat's name and size. Amber separates it, and the tint keeps it under the title — at full strength on the row fill it out-shouted the title instead. ChipStyle becomes a struct of statics so `tinted(_:on:)` can name its colours. The enum had no references outside the file and `chip()` had one call site, so a case would have cost a new name and two switch arms for a single use. The style now carries the shape as well: a status chip draws as a capsule rather than at `Metrics.buttonRadius`, which is the corner every button on these screens uses and invites a tap the chip has nothing to do with. `warningSecondary` is amber at a tenth of its strength over `backgroundMain`, baked flat the way the sentiment pair secondaries are. * fix(chat): settle the mute chip's width and the sheet's row list Three things visible on device: The chip drew its text outside the capsule for the length of the spring. Geometry interpolates and glyphs don't, so the fill grew from the old string's width while the new string was already drawn in full. The chip now takes its width at once inside the spring, which still carries the scale and the fade. Fading out, the label changed what it said on the way: the text is nil the instant a mute is cleared, so the placeholder it fell back to replaced the deadline mid-fade. It now falls back to what it last said, which reads as the chip going quiet rather than correcting itself. "Never" appeared on the sheet while the sheet was closing. The store converges before the dismissal finishes, so a list read live gained its first row on the way out. `apply` pins the row's presence; left idle the list is still live, so a mute lapsing under an open sheet drops it. Also drops the trailing period from the sheet's caption, matching the copy elsewhere. * fix(chat): replace the mute chip instead of resizing it The chip slid sideways into place on every text change. Frame-by-frame, its glyph box travelled 63px right-to-left over the spring while its width barely moved: the string had snapped to full size, but the frame was still interpolating from the old string's width, and the content sits at that frame's leading edge. Suppressing the glyph animation only swapped one symptom for the other — before it the text spilled out of the capsule, after it the whole chip slid. Anything that keeps one view and changes its string interpolates the width. Key the chip's identity on the string instead, so a change is an insert and a remove with a scale-and-fade transition. There is no width to interpolate, and the scale is anchored at the centre. A hidden placeholder holds the line's height, and the outgoing copy keeps its own wording as it fades, which drops the `lastText` cache that was doing that by hand. * feat(chat): show a bell where a chat is named The amber chip only exists on the chat's own profile, so a muted chat was indistinguishable from an audible one everywhere the user actually looks — the chats list, the transcript's title bar, the card at the head of the transcript. `MuteBell` draws `bell.slash.fill` at caption size in `textSecondary`, the weight the chevron and the timestamp beside it already carry. It takes the mute rather than a muted flag and keeps its own clock, for the same reason the status label does: a timed mute lapses with nothing sent from the server, so a bell handed a boolean stays lit until something unrelated redraws its host. Both head cards are configured from `Codable` structs in FlipcashCore, so the mute travels on the card. On the chat list row it sits at the trailing end of the subtitle line, past a spacer — it belongs to the chat, not to the message the preview quotes. On the head cards it goes before the chevron, so the disclosure indicator stays last. The row and the title button both collapse to a single accessibility element, which discards the bell's own label; both now append "muted" to the container label from a plain read. * fix(chat): drop the mute bell from the head card The bell stays on the two surfaces that list or title a chat: the chats row and the transcript's title bar. The head card sits directly under that title bar, so it was repeating the bell a few points below it. The `mute` field comes off `ChatProfileCard` and `ChatGroupCard` with it. It existed only because the cells are hosted from those structs, and `ChatProfileCard` rides in the byte-compared `ConversationLoadCoordinator.Inputs`, so a mute change was re-mapping the transcript to draw a glyph the title bar already carried. * fix(chat): stop reserving preview width for an absent bell The bell sat behind `Spacer(minLength: 12)` in an `HStack(spacing: 6)`, and both are unconditional: an audible chat drew no bell but still paid 18pt off the end of its message preview. Rendered at a 200pt width, the subtitle's ink stopped at 174pt where it now reaches 197pt. The gap moves inside `MuteBell`, behind the same condition as the glyph, so it appears and disappears with it. `leadingGap` defaults to zero, which is what the transcript title bar wants — its stack spacing is already right and its title is not competing with the bell for width.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves iOS onto flipcash2 contract
dd5e92dband scaffolds the seams it opens.Blocked on code-payments/flipcash2-client-protocol#16. This branch pins
0.10.0as anexact:requirement inFlipcashAPI/Package.swift, and no such tag exists yet, so resolution fails until that PR merges andpublish.ymlruns. Draft until then. The work here was built against the client checkout throughFLIPCASH_PROTO_LOCAL/FLIPCASH_PROTO_LOCAL_PACKAGES, which CI never sees.The breaking change is a no-op here
EmojiReaction.reacted_by_selfis replaced in place byself_reactor, which is wire-breaking. iOS never read that field — a repo-wide grep finds noreacted_by_selfand noreactedBySelf— so nothing needed migrating. Android's PR carries the real migration.What did break the build was smaller and easier to miss: two cases appended to the end of
StreamEventsResponse.Error.Code. Appending is safe on the wire and safe for the positionalError*(rawValue:)mapping, but not for an exhaustiveswitch.EventStreamer.handleStreamErrornow routes.notFoundand.streamExpiredtoreconnect().Scaffolded, not implemented
One commit, all under
FlipcashCore/:ConversationViewModeand theview_modeplumbing,redactedonConversationMessage,ConversationViewerState/ConversationMuteState,MuteChat/UnmuteChatonChatServicewith their typed errors, and a newReportingServicewith itsFlipClient+Reportingwrapper.ConversationStore.applyViewerStateChangedconverges by version rather than by arrival order.What is deliberately absent, because it is feature work rather than contract work: no mute-duration picker, no countdown for a lapsing timed mute, and no notification suppression.
NotificationPayload.isMutedexists and nothing calls it yet — the server still delivers muted pushes, so suppression is the client's to build.xcodebuildbuild and test both succeed against the local override.Package.resolvedis untouched relative tomain; the override rewrites it, and it was restored before committing.