<feed xmlns='http://www.w3.org/2005/Atom'>
<title>hutch.git/Hutch, branch v3.7.0</title>
<subtitle>ios client for sourcehut. swiftui.
</subtitle>
<id>http://git.krz.sh/krz/hutch.git/atom?h=v3.7.0</id>
<link rel='self' href='http://git.krz.sh/krz/hutch.git/atom?h=v3.7.0'/>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/'/>
<updated>2026-07-16T03:08:53+00:00</updated>
<entry>
<title>fix: render patchsets on a plain list</title>
<updated>2026-07-16T03:08:53+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T03:08:53+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=1d2769fc7a347939275e9130ee174d61d96ea401'/>
<id>urn:sha1:1d2769fc7a347939275e9130ee174d61d96ea401</id>
<content type='text'>
Collapsing patches shrank the layout loop from 3674pt/1647pt to 718pt/600pt but
did not end it. The oscillating item is section 1 item 0 — the cover letter, not
a patch — so size alone was not the cause.

The log shows the cell laid out at width 390.0 while the content reports its
preferred size at 390.333. That is inset grouped's 20pt insets landing on a
fractional width: the Text reflows to a different height than the cell was sized
for, each size triggers the other, and it never settles.

ThreadDetailView renders the same bodies through the same DiffView with the same
modifiers and does not loop. The difference is .listStyle(.plain), which this
view never set and so inherited inset grouped.
</content>
</entry>
<entry>
<title>fix: collapse patches to stop a recursive layout loop</title>
<updated>2026-07-16T03:02:55+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T03:02:55+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=2913d950a5c82d80226050e05bd64be7e30ff87d'/>
<id>urn:sha1:2913d950a5c82d80226050e05bd64be7e30ff87d</id>
<content type='text'>
Opening a patchset wedged the app. UICollectionView reported a row oscillating
between 3674pt and 1647pt and trapped in a recursive layout loop, leaving the
UI unresponsive.

The detail view rendered every patch in the series expanded, so a List held one
enormous self-sizing row per patch, each with a full diff. Self-sizing cells
that large do not settle.

Patches now start collapsed and expand on tap, so at most the ones a reviewer
opens are measured. This is what ThreadDetailView already does — it collapses
every message but the last, and renders the same diffs through the same
DiffView without trouble. Reviewing a series one patch at a time is also closer
to how the reading actually goes.

The rendering of a block list is shared between the cover letter and patches
rather than duplicated.
</content>
</entry>
<entry>
<title>fix: push patchset views directly instead of by route</title>
<updated>2026-07-16T02:56:56+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T02:56:56+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=575e62f6dab44b0c9836623fe8b7d17a219f8e0f'/>
<id>urn:sha1:575e62f6dab44b0c9836623fe8b7d17a219f8e0f</id>
<content type='text'>
Tapping a patch failed with "no matching navigationDestination declaration
visible from the location of the link".

MailingListDetailView is presented from four places, but only the More tab and
Lookup declare a MoreRoute destination. Reached from a project, via
ProjectMailingListView, there is no such destination in the surrounding stack,
so a NavigationLink carrying MoreRoute.patchset had nowhere to resolve. The
thread rows beside it already use the closure form for exactly this reason.

Push PatchsetDetailView directly, from the rows and from the version-chain
links inside the detail view, which inherits whatever stack presented it. That
leaves MoreRoute.patchset with no users, so it and its two destinations are
removed rather than left as a route nothing links to.

Neither the compiler nor the tests catch this: it is a runtime SwiftUI
resolution failure.
</content>
</entry>
<entry>
<title>feat: review patchsets</title>
<updated>2026-07-16T02:48:58+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T02:48:58+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=0eceec357b7ef5251ed0ae9d42b513c17380e0af'/>
<id>urn:sha1:0eceec357b7ef5251ed0ae9d42b513c17380e0af</id>
<content type='text'>
Patchsets are how contributions reach sourcehut, and Hutch had no reference to
them anywhere. This adds review and triage: read a series, see its checks and
version chain, and set its status.

Two schema facts shaped the design.

MailingList exposes no patchsets field, so a list's patchsets cannot be queried
directly. They are reachable only through thread roots, so the existing threads
query now also selects root.patchset — no extra request — and the Patches tab is
derived from that. It appears only on lists that actually carry patches.

Patch carries no diff. index, count, version, prefix, subject, and trailers are
all it has; the diff exists only inside the email body. Patch bodies are split
with the same InboxThreadUtilities.segmentMessageBody the inbox uses and
rendered through the existing DiffView.

Patches are ordered by their [PATCH n/m] index rather than receipt order, since
mail arrives out of sequence. Patches with no index are kept at the end rather
than dropped, because a one-off patch has no prefix.

updatePatchset is nullable, so a null response is treated as a declined change
and the local status is left alone rather than advanced optimistically. UNKNOWN
and SUPERSEDED are not offered: the first is a sentinel, the second is set by
the server when a newer version lands.

Patch submission stays out of scope. It is a git send-email flow, not a GraphQL
mutation.
</content>
</entry>
<entry>
<title>refactor: share the email body diff splitter</title>
<updated>2026-07-16T02:42:38+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T02:42:38+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=ccec322f8eac4d14638f5abdae7dae7abc95eb7e'/>
<id>urn:sha1:ccec322f8eac4d14638f5abdae7dae7abc95eb7e</id>
<content type='text'>
segmentMessageBody and its helpers were private to ThreadViewModel, reachable
from tests only through a segmentMessageBodyForTesting shim. Patchset review
needs the same splitting, because sr.ht's Patch type carries no diff — the diff
only exists inside the email body — so this has to be shared rather than
duplicated.

Moved to InboxThreadUtilities. The shim is gone; the existing test calls the
real function directly now.

Also adds the Patchset model layer that the coming views build on.
</content>
</entry>
<entry>
<title>Phase 1: close the write gaps (#3)</title>
<updated>2026-07-16T02:33:59+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T02:33:59+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=b9ec80716ea015de5b6b31395fdc5ff03191398c'/>
<id>urn:sha1:b9ec80716ea015de5b6b31395fdc5ff03191398c</id>
<content type='text'>
* refactor: collapse duplicated request paths in SRHTClient

Five request paths each repeated the token guard, header setup, status-code
handling, and a ~35-line #if DEBUG logging block. The file carried that block
five times over.

Extract makeAuthorizedRequest, send, and encodedGraphQLBody, and route execute,
executeAndCache, executeMultipartFiles, and performGraphQLRequest through them.
executeMultipart is now the single-file case of executeMultipartFiles, which it
already was byte for byte. 938 lines to 612, with one copy of the logging block.

fetchText keeps its own guard: it is a GET to an allowlisted URL and must not
run GraphQL error checks over what is usually a plain-text build log.

One behavior change falls out. executeAndCache wrote the raw response to the
cache before decoding, so a 200 carrying GraphQL errors was cached and then
thrown. Routing it through performGraphQLRequest surfaces those errors first,
so error payloads are no longer cached.

* feat: edit and delete tickets

updateTicket and deleteTicket both existed in todo.sr.ht's API but were never
called, so a ticket could be filed and its status changed but its subject and
body were frozen from the moment it was created, and it could never be removed.

Edit opens a sheet seeded with the current subject and body. The input carries
only fields that actually changed, so an edit cannot clobber a field the user
did not touch, and Save stays disabled until something differs.

Clearing the body sends an explicit null via updateValue rather than a nil
subscript assignment, which would drop the key and silently leave the old body
in place — the same trap fixed for repository descriptions in 7ffef07.

Delete is destructive and irreversible, so it sits behind a confirmation
dialog naming the ticket and pops the detail view on success.

* feat: subscribe to and unsubscribe from tickets

ticketSubscribe and ticketUnsubscribe existed in the API but were never called,
so email notifications for a ticket could only be managed on the web.

Ticket.subscription is null when the user is not subscribed, so the detail
query now reads it and the menu reflects real server state rather than
guessing. The toggle updates optimistically and reverts on failure, so the
control never claims a subscription that did not take.

Decoded into the private payload rather than TicketDetail, which is Codable and
cached — adding a field there would have changed the cached shape and touched
every optimistic-update construction site.

* feat: subscribe to and unsubscribe from trackers

trackerSubscribe and trackerUnsubscribe existed in the API but were never
called. Tracker.subscription is null when not subscribed, so the state can be
read rather than guessed.

The read is a separate uncached query. The tickets query it sits beside is
paginated and cached, and a per-user subscription has no business riding along
in page payloads or being served stale from disk.

Unsubscribe passes tickets: false, so leaving a tracker does not silently drop
subscriptions to individual tickets the user opted into.

* feat: unsubscribe from mailing lists

mailingListUnsubscribe existed in the API but was never called, so the list of
subscriptions was readable and nothing more.

Scoped to unsubscribe. MailingList has no subscription field, unlike Ticket and
Tracker, so per-list state is only knowable from the subscriptions query — which
is exactly what builds this view. Subscribing would need a list the user is by
definition not subscribed to, and sr.ht exposes no discovery API to find one
(see SCOPE.md on hub.sr.ht), so there is nowhere honest to put that action yet.

The row is removed optimistically and restored if the mutation fails. The
confirmation says plainly that Hutch cannot resubscribe, since it cannot.

* feat: manage todo and lists email preferences

updatePreferences existed on both services but was never called, so these were
web-only settings.

The two services expose preferences/updatePreferences under identical names but
with different fields — notifySelf on todo, copySelf on lists — and there is no
shared preferences service, so both are read and written side by side. They load
concurrently and one service being unreachable does not hide the other's toggle.

These are server-side and apply beyond Hutch, unlike the @AppStorage toggles
above them in Settings, so the footer says so and each toggle reverts if its
mutation fails.

* refactor: drop the memory-only cache path

Two executeCached overloads existed with different return types and semantics:
one doing stale-while-revalidate against the persistent cache with TTLs, the
other only consulting the in-memory responseCache. The second was an easy thing
to reach for by mistake, since the compiler picked it purely on argument labels.

It turned out to be dead. All 38 call sites already used the TTL-aware
overload, and the memory-only one was the sole caller of executeAndCache, so
both are removed. Its doc comment promised refresh "via the onRefresh callback",
which the signature has not had for some time.

SRHTClient is now 569 lines, down from 938 before this branch.

responseCache stays as the in-memory layer behind cachedPayload and the three
view models that read it directly.

* chore: bump to 3.6.0 and record Phase 1

MARKETING_VERSION 3.5.0 -&gt; 3.6.0, build 87 -&gt; 88.

* fix: decode preferences responses on the main actor

The module sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so the response
types are implicitly main-actor isolated and their Decodable conformances are
too. Decoding straight from an `async let` used those conformances from a
nonisolated context, which warns today and is an error in the Swift 6 language
mode.

Move each fetch into its own method and `async let` over those instead, so
decoding stays on the main actor. This is what HomeViewModel.loadDashboard
already does, and the concurrency is unaffected — the network work still
overlaps, since execute suspends and frees the actor.</content>
</entry>
<entry>
<title>fix: give inbox threads identity distinct from their grouping key</title>
<updated>2026-07-16T00:31:17+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T00:31:17+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=eff81f34bb51ecc6e0aa5d831567db2cea0527a9'/>
<id>urn:sha1:eff81f34bb51ecc6e0aa5d831567db2cea0527a9</id>
<content type='text'>
InboxThreadSummary.id returned threadGroupingKey, which is listRID plus the
subject with Re:/Fwd: stripped. Two unrelated threads on one list sharing a
subject therefore shared an id — common on sourcehut, where "[PATCH] test" is
an ordinary subject — which collides under Identifiable in every list that
renders these summaries.

Key id on the root Message-ID, which is unique per thread, and leave
threadGroupingKey subject-based so replies still collapse into one
conversation.

Read state moves to threadGroupingKey at each call site. It was already
keyed on that string via id, so persisted keys are unchanged and marking a
conversation read still covers the whole subject group, matching how
HomeViewModel already builds the key for isUnread.
</content>
</entry>
<entry>
<title>fix: render code span contents literally</title>
<updated>2026-07-16T00:29:57+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T00:29:57+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=c5247f7021090358e8db70daa9ed09521e9f206a'/>
<id>urn:sha1:c5247f7021090358e8db70daa9ed09521e9f206a</id>
<content type='text'>
processInline protected allowlisted HTML tags before it handled code spans, so
a `&lt;b&gt;` written inside backticks was carried through as a live tag and applied
formatting instead of rendering as text. Every other inline pass ran against
code span contents for the same reason, so `**x**` in backticks was emitted as
bold.

Protect code spans first with their contents escaped, which takes them out of
reach of the tag, emphasis, and link passes.
</content>
</entry>
<entry>
<title>fix: stop shadowing serviceNotProvisioned classification</title>
<updated>2026-07-16T00:26:27+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T00:26:27+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=3c99c01ad1c0c855ef1793d4b74a9c77e7445811'/>
<id>urn:sha1:3c99c01ad1c0c855ef1793d4b74a9c77e7445811</id>
<content type='text'>
"No such repository or user found" matched the broad "no such" test for
.notFound, which ran first and made the .serviceNotProvisioned rule below it
unreachable. Users hitting a service they have not activated were told the
content was no longer available rather than that the account needs to enable
the service.

Order the specific check ahead of the general one.
</content>
</entry>
<entry>
<title>fix: allow clearing a repository description</title>
<updated>2026-07-16T00:25:27+00:00</updated>
<author>
<name>Christian Cleberg</name>
<email>hello@cleberg.net</email>
</author>
<published>2026-07-16T00:25:27+00:00</published>
<link rel='alternate' type='text/html' href='http://git.krz.sh/krz/hutch.git/commit/?id=7ffef07278e94bd3c268ee5cbd5c9c11cbfa3270'/>
<id>urn:sha1:7ffef07278e94bd3c268ee5cbd5c9c11cbfa3270</id>
<content type='text'>
metadataInputForSave assigned nil through the dictionary subscript to clear a
description. Swift removes the key on a nil subscript assignment, so the
mutation was sent with no `description` field and the old value survived —
clearing a description silently did nothing.

Store the nil with updateValue so the key is retained; AnyCodable already
encodes an unmatched value as a JSON null.
</content>
</entry>
</feed>
