Skip to content

chore(deps): move to flipcash2 contract dd5e92db - #804

Merged
bmc08gt merged 2 commits into
mainfrom
chore/flipcash2-contract-dd5e92db
Sep 18, 2026
Merged

bmc08gt merged 2 commits into
mainfrom
chore/flipcash2-contract-dd5e92db

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Moves iOS onto flipcash2 contract dd5e92db and scaffolds the seams it opens.

Blocked on code-payments/flipcash2-client-protocol#16. This branch pins 0.10.0 as an exact: requirement in FlipcashAPI/Package.swift, and no such tag exists yet, so resolution fails until that PR merges and publish.yml runs. Draft until then. The work here was built against the client checkout through FLIPCASH_PROTO_LOCAL / FLIPCASH_PROTO_LOCAL_PACKAGES, which CI never sees.

The breaking change is a no-op here

EmojiReaction.reacted_by_self is replaced in place by self_reactor, which is wire-breaking. iOS never read that field — a repo-wide grep finds no reacted_by_self and no reactedBySelf — 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 positional Error*(rawValue:) mapping, but not for an exhaustive switch. EventStreamer.handleStreamError now routes .notFound and .streamExpired to reconnect().

Scaffolded, not implemented

One commit, all under FlipcashCore/: ConversationViewMode and the view_mode plumbing, redacted on ConversationMessage, ConversationViewerState / ConversationMuteState, MuteChat / UnmuteChat on ChatService with their typed errors, and a new ReportingService with its FlipClient+Reporting wrapper. ConversationStore.applyViewerStateChanged converges 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.isMuted exists and nothing calls it yet — the server still delivers muted pushes, so suppression is the client's to build.

xcodebuild build and test both succeed against the local override. Package.resolved is untouched relative to main; the override rewrites it, and it was restored before committing.

…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 bmc08gt self-assigned this Sep 18, 2026
@bmc08gt
bmc08gt marked this pull request as ready for review September 18, 2026 23:30
@bmc08gt
bmc08gt merged commit b4b0b2e into main Sep 18, 2026
1 check failed
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.
@bmc08gt
bmc08gt deleted the chore/flipcash2-contract-dd5e92db branch October 5, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant