Skip to main content

max / goingson

Declare the Sharing section Wave 14, second of four files. sharing.rs leaves the queue. The whole file was one read woven through eight assemblers. It is now one `read` answering a `Sharing` and thirteen declarations drawing it, which is where most of the change is: the members, the invitations, the confirmations, the queue and the previewed invite each became a struct saying exactly what the row shows, so the description states the row instead of deriving it. The `pane` adapter is gone rather than declared. Once the read is hoisted it was three lines calling two functions, so settings.rs calls the two. One production earned, in quasi@c8e6997: `extend` in a panel. This section is eight panels under one heading and the outer one had no way to say so. Two things the form said no to. A row's held-back acts have no member, because `menu` is a setting and one name cannot mean both, so they are a `menu [..]` setting with the act written in the hole. And `Field::min` has no builder, so the invite expiry says `at_least "1"`, which is the builder that does exist and means the same thing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 19:41 UTC
Signed with PGP, not checked
Commit: 69f0a9d5be5fabda8258003e6cd9dbff624aeb1b
Parent: 3e500d2
2 files changed, +218 insertions, -257 deletions
@@ -501,7 +501,7 @@
501 501 "email" => email::pane(&email::accounts(state)?),
502 502 "about" => about::pane(state),
503 503 "sync" => sync::pane(state),
504 - "sharing" => sharing::pane(state)?,
504 + "sharing" => sharing::sharing_pane(&sharing::read(state)?),
505 505 _ => appearance(state, &config),
506 506 };
507 507 Ok(Showing { section, body })
@@ -582,7 +582,10 @@
582 582 fn sharing_pane(state: &AppState, said: &str) -> Result<Response, RouteError> {
583 583 Ok(Response::fragment(
584 584 SECTION_REGION,
585 - Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(sharing::pane(state)?)),
585 + Node::Region(
586 + Slot::new(SECTION_REGION, RegionKind::Pane)
587 + .extend(sharing::sharing_pane(&sharing::read(state)?)),
588 + ),
586 589 )
587 590 .toast(quasi_router::layout::Tone::Success, said))
588 591 }
@@ -85,8 +85,10 @@
85 85 //! copy affordance can attach it to. Same answer, same reason, as the identity
86 86 //! key below.
87 87
88 - use quasi_router::screen::{Act, Choice, Field, Row, Tag};
89 - use quasi_router::{Action, Node, RouteError};
88 + use makeover_layout::Tone;
89 + use quasi_declare::declare;
90 + use quasi_router::screen::{Act, Choice, Tag};
91 + use quasi_router::{Action, RouteError};
90 92 use synckit_client::InvitationState;
91 93 use synckit_client::store::directory;
92 94
@@ -106,63 +108,13 @@
106 108 .map_err(|error| RouteError::internal(error.to_string()))
107 109 }
108 110
109 - /// The directory, read through the app's own pool.
110 - ///
111 - /// An empty answer is "this device knows of no groups", which is a true
112 - /// statement about what has reached it rather than a claim that the user belongs
113 - /// to none. The section says so in those words.
114 - fn known(app: &AppState) -> Result<(Vec<directory::KnownGroup>, Option<String>), RouteError> {
115 - let conn = conn(app)?;
116 - let groups =
117 - directory::groups(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
118 - let refreshed =
119 - directory::refreshed_at(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
120 - Ok((groups, refreshed))
121 - }
122 -
123 - /// One group, with the members of it this device holds.
124 - fn group_rows(app: &AppState, group: &directory::KnownGroup) -> Result<Vec<Row>, RouteError> {
125 - let mut row = Row::new(&group.name);
126 - if group.is_admin {
127 - row = row.token(Tag::badge("You administer this"));
128 - }
129 - let mut rows = vec![row];
130 -
131 - // Only for a group this user administers: the server refuses a non-admin the
132 - // member list, so for any other group there are no rows and that is correct
133 - // rather than missing. Read from `is_admin` rather than from the list being
134 - // empty, because those are different facts and only one is about permission.
135 - if group.is_admin {
136 - let conn = app
137 - .db
138 - .conn()
139 - .map_err(|error| RouteError::internal(error.to_string()))?;
140 - let members = directory::members(&conn, group.id)
141 - .map_err(|error| RouteError::internal(error.to_string()))?;
142 - if members.is_empty() {
143 - rows.push(Row::new("No members recorded yet").secondary(
144 - "The member list is fetched on a sync; this device has not had one back.",
145 - ));
146 - } else {
147 - for member in members {
148 - let mut member_row = Row::new(&member.email);
149 - if member.role == "admin" {
150 - member_row = member_row.token(Tag::badge("Admin"));
151 - }
152 - rows.push(member_row);
153 - }
154 - }
155 - rows.extend(invitation_rows(app, group)?);
156 - }
157 - Ok(rows)
158 - }
159 -
160 111 /// The word for a state, and there is no catch-all.
161 112 ///
162 113 /// `InvitationState` is `#[non_exhaustive]`, so a variant a newer server knows
163 114 /// about arrives here as something this build cannot name. Saying so beats
164 - /// picking the nearest word: every state below decides whether acts are offered,
165 - /// and guessing wrong offers an act on an invitation that cannot take it.
115 + /// picking the nearest word: every state below decides whether acts are
116 + /// offered, and guessing wrong offers an act on an invitation that cannot take
117 + /// it.
166 118 fn state_word(state: InvitationState) -> &'static str {
167 119 match state {
168 120 InvitationState::Pending => "Waiting to be used",
@@ -174,487 +126,721 @@
174 126 }
175 127 }
176 128
177 - /// One administered group's invitations, below its member list.
178 - ///
179 - /// Every read is [`effective_state`](directory::KnownInvitation::effective_state)
180 - /// rather than the stored `state`: the directory refreshes on a cycle and a
181 - /// deadline passes on its own, so between two refreshes the stored value says
182 - /// `Pending` for a code that has stopped working. Offering that as a live code
183 - /// is the one failure this section can produce without anybody doing anything.
184 - fn invitation_rows(app: &AppState, group: &directory::KnownGroup) -> Result<Vec<Row>, RouteError> {
185 - let conn = conn(app)?;
186 - let invitations = directory::invitations(&conn, group.id)
187 - .map_err(|error| RouteError::internal(error.to_string()))?;
188 - drop(conn);
189 -
190 - let mut rows = Vec::new();
191 - for invitation in invitations {
192 - let state = invitation.effective_state();
193 - // Drawn at the top of the pane instead, across every group. An accepted
194 - // invitation is the one state where a person is waiting, and finding it
195 - // by opening each group in turn is how somebody waits a week.
196 - if state == InvitationState::Accepted {
197 - continue;
198 - }
199 -
200 - // These rows share a list with the member rows above them, so the
201 - // primary says what the row *is* and the state goes in a badge, the way
202 - // `Admin` already does on a member. A row led by "Waiting to be used"
203 - // beside three email addresses reads as a fourth person.
204 - let mut row = Row::new("Invite code")
205 - .token(Tag::badge(state_word(state)))
206 - .meta(format!("Issued {}", invitation.created_at));
207 -
208 - // Everything else is terminal, so no act: the directory prunes those
209 - // once the server stops reporting them, and `Redeemed` reads as history
210 - // because by then they are in the member list above.
211 - if state == InvitationState::Pending {
212 - row = row
213 - .secondary(format!("Stops working {}.", invitation.expires_at))
214 - .act(
215 - Act::new(
216 - "Cancel it",
217 - Action::post(format!(
218 - "/settings/sharing/invites/{}/{}/revoke",
219 - group.id, invitation.id
220 - )),
221 - )
222 - .tone(makeover_layout::Tone::Danger),
223 - );
224 - }
225 -
226 - rows.push(row);
227 -
228 - // The token, and only while pending: once somebody has accepted, the
229 - // code has done its whole job and holding it is exposure with no use.
230 - // `write_invitations` drops the column at the same moment, so this is
231 - // the screen agreeing with the store rather than a second rule.
232 - if state == InvitationState::Pending
233 - && let Some(token) = invitation.token
234 - {
235 - rows.push(
236 - Row::new("The code to hand over").part(
237 - makeover_layout::RowPart::Secondary,
238 - Node::field(
239 - Field {
240 - value: Some(token),
241 - ..Field::new(
242 - makeover_layout::FieldKind::Text,
243 - format!("invite_token_{}", invitation.id),
244 - "Invite code",
245 - )
246 - }
247 - .hint("One use. Whoever redeems it still lands in your confirmations."),
248 - ),
249 - ),
250 - );
251 - }
252 - }
253 - Ok(rows)
129 + /// One member of a group this user administers.
130 + struct Member {
131 + /// Their account email, which is what the row is called.
132 + email: String,
133 + /// Whether they administer it too.
134 + is_admin: bool,
254 135 }
255 136
256 - /// Every invitation waiting on this admin, across every group they administer.
137 + /// One invitation to a group this user administers, minus the accepted ones:
138 + /// those are drawn at the top of the pane instead.
257 139 ///
258 - /// Above the group list on purpose. This is the security-bearing step of the
259 - /// whole flow: the invitee has posted a key and nothing about them reaches the
260 - /// group until somebody compares its fingerprint against what they read out over
261 - /// another channel. It is also the only place a person is actively blocked, and
262 - /// it earns the position for the same reason the queue section already sits
263 - /// above `admin_acts`.
140 + /// Every field is read off
141 + /// [`effective_state`](directory::KnownInvitation::effective_state) rather than
142 + /// the stored `state`: the directory refreshes on a cycle and a deadline passes
143 + /// on its own, so between two refreshes the stored value says `Pending` for a
144 + /// code that has stopped working. Offering that as a live code is the one
145 + /// failure this section can produce without anybody doing anything.
146 + struct Invitation {
147 + /// The group it admits somebody to, which the Cancel address names.
148 + group: String,
149 + /// The invitation, which the Cancel address names and the code field is
150 + /// keyed by.
151 + id: String,
152 + /// What the badge says about where it has got to.
153 + state: &'static str,
154 + /// Whether it can still be used, which is the only state with a code, a
155 + /// deadline and a Cancel.
156 + usable: bool,
157 + /// When it was issued.
158 + issued: String,
159 + /// When it stops working.
160 + expires: String,
161 + /// The one-use code, held only while pending and only on the device that
162 + /// issued it. `write_invitations` drops the column at the same moment the
163 + /// screen stops drawing it, so this is the screen agreeing with the store
164 + /// rather than a second rule.
165 + token: Option<String>,
166 + }
167 +
168 + /// One group this device knows the user belongs to.
169 + struct Group {
170 + /// What it is called.
171 + name: String,
172 + /// Whether this user administers it, which is what decides whether the
173 + /// server will hand over a member list at all.
174 + is_admin: bool,
175 + /// Its members. Empty means the fetch has not landed, which is a different
176 + /// fact from having none, and the pane says so in words.
177 + members: Vec<Member>,
178 + /// Its invitations, once it is administered.
179 + invitations: Vec<Invitation>,
180 + }
181 +
182 + /// One invitation waiting on this admin to compare a fingerprint.
183 + struct Confirmation {
184 + /// The group, which the two addresses name.
185 + group: String,
186 + /// The invitation, which the two addresses name.
187 + id: String,
188 + /// Who accepted, or "Somebody" when the server did not say.
189 + who: String,
190 + /// The fingerprint to read out, if the key yielded one.
191 + ///
192 + /// Absent must never be confirmable: the comparison is the whole security
193 + /// of the step, and a row that offers Confirm beside "unavailable" invites
194 + /// approving something nobody read.
195 + fingerprint: Option<String>,
196 + }
197 +
198 + /// One queued write that has not landed yet.
199 + struct Queued {
200 + /// The op, which Cancel names.
201 + id: String,
202 + /// What it will do, with the fingerprint a queued confirm authorizes
203 + /// resolved as the row is drawn rather than copied when it was queued.
204 + doing: String,
205 + /// Whether the drainer has finished with it.
206 + done: bool,
207 + /// The server's own words, when it refused. A queue that reported "failed"
208 + /// and kept the reason would be worse than the toast it replaced.
209 + failed: Option<String>,
210 + }
211 +
212 + /// This device's identity key, or why there is none.
213 + enum Identity {
214 + /// Sync is not set up, so no key has been derived.
215 + Unconfigured,
216 + /// Set up, with no master key loaded yet.
217 + Locked,
218 + /// The public key, and the fingerprint to read it out by.
219 + Held {
220 + /// The key itself, shown in a field so it is selectable and so a host
221 + /// with a copy affordance has something to attach one to.
222 + pubkey: String,
223 + /// Its fingerprint, or "unavailable" when it would not hash.
224 + fingerprint: String,
225 + },
226 + }
227 +
228 + /// The invitation this device has previewed, if it has one.
229 + struct Held {
230 + /// The group it leads to.
231 + group: String,
232 + /// Who issued it.
233 + inviter: String,
234 + /// When it stops working.
235 + expires: String,
236 + /// Whether it can still be accepted.
237 + usable: bool,
238 + /// Why not, when it cannot. `redeemable` is false for every terminal state
239 + /// alike, so which one it is has to be read off the state: "that code has
240 + /// expired" and "that code was cancelled" send a person to different
241 + /// places.
242 + refused: String,
243 + }
244 +
245 + /// Everything the Sharing pane draws, read once.
246 + pub(super) struct Sharing {
247 + /// The groups this device knows about.
248 + groups: Vec<Group>,
249 + /// How old that list is, if it has ever arrived. Staleness stated rather
250 + /// than hidden: the list is a copy of the server's answer, and how old the
251 + /// copy is decides how much to trust it.
252 + refreshed: Option<String>,
253 + /// Invitations waiting on this admin, across every group they administer.
254 + confirmations: Vec<Confirmation>,
255 + /// How old *that* list is, which is not the same number.
256 + ///
257 + /// `pending_confirmations_refreshed_at` is the freshness of exactly the
258 + /// rows `pending_confirmations` returned, and it reports the stalest group
259 + /// it drew from: `write_invitations` is scoped per group so one group's
260 + /// failed fetch does not blank the others, which is also the cycle where a
261 + /// number about the group list would overstate this one. A section whose
262 + /// whole job is a security decision should not do that.
263 + confirmations_refreshed: Option<String>,
264 + /// Writes queued and not landed.
265 + queued: Vec<Queued>,
266 + /// Whether sync is set up at all, which is what every write here needs.
267 + configured: bool,
268 + /// The groups this user administers, as the two pickers offer them.
269 + administered: Vec<Choice>,
270 + /// The invite code this device has read, if it has read one.
271 + held: Option<Held>,
272 + /// This device's identity key.
273 + identity: Identity,
274 + }
275 +
276 + /// The directory, read through the app's own pool.
264 277 ///
265 - /// The fingerprint is on the row's face rather than behind a menu, because it is
266 - /// the thing being decided rather than a detail about the decision.
267 - fn confirmations(app: &AppState) -> Result<Vec<Node>, RouteError> {
278 + /// An empty answer is "this device knows of no groups", which is a true
279 + /// statement about what has reached it rather than a claim that the user
280 + /// belongs to none. The pane says so in those words.
281 + pub(super) fn read(app: &AppState) -> Result<Sharing, RouteError> {
268 282 let conn = conn(app)?;
283 + let known =
284 + directory::groups(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
285 + let refreshed =
286 + directory::refreshed_at(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
269 287 let waiting = directory::pending_confirmations(&conn)
270 288 .map_err(|error| RouteError::internal(error.to_string()))?;
271 - let refreshed = directory::pending_confirmations_refreshed_at(&conn)
289 + let confirmations_refreshed = directory::pending_confirmations_refreshed_at(&conn)
272 290 .map_err(|error| RouteError::internal(error.to_string()))?;
273 - drop(conn);
291 + let preview =
292 + directory::preview(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
274 293
275 - if waiting.is_empty() {
276 - return Ok(Vec::new());
277 - }
278 -
279 - let rows = waiting.into_iter().map(|invitation| {
280 - let who = invitation
281 - .invitee_email
282 - .clone()
283 - .unwrap_or_else(|| "Somebody".to_owned());
284 - // A key that yielded no fingerprint must never be confirmable: the
285 - // comparison is the whole security of the step, and a row that offers
286 - // Confirm beside "unavailable" invites approving something nobody read.
287 - let Some(fingerprint) = invitation.invitee_fingerprint.clone() else {
288 - return Row::new(who)
289 - .token(Tag::badge("Cannot be checked").tone(makeover_layout::Tone::Danger))
290 - .secondary(
291 - "The key on this invitation does not read as a key, so there is no \
292 - fingerprint to compare. Cancel it from the group and issue another.",
293 - );
294 + let mut groups = Vec::with_capacity(known.len());
295 + for group in &known {
296 + // Only for a group this user administers: the server refuses a
297 + // non-admin the member list, so for any other group there are no rows
298 + // and that is correct rather than missing. Read from `is_admin` rather
299 + // than from the list being empty, because those are different facts and
300 + // only one is about permission.
301 + let (members, invitations) = if group.is_admin {
302 + let members = directory::members(&conn, group.id)
303 + .map_err(|error| RouteError::internal(error.to_string()))?;
304 + let invitations = directory::invitations(&conn, group.id)
305 + .map_err(|error| RouteError::internal(error.to_string()))?;
306 + (members, invitations)
307 + } else {
308 + (Vec::new(), Vec::new())
294 309 };
295 310
296 - Row::new(who)
297 - .secondary(format!(
298 - "Fingerprint {fingerprint}. Ask them to read it out over a channel that is \
299 - not this one, and admit them only if it matches."
300 - ))
301 - .act(Act::new(
302 - "It matches, admit them",
303 - Action::post(format!(
304 - "/settings/sharing/invites/{}/{}/confirm",
305 - invitation.group_id, invitation.id
306 - )),
307 - ))
308 - .act(
309 - Act::new(
310 - "Reject",
311 - Action::post(format!(
312 - "/settings/sharing/invites/{}/{}/revoke",
313 - invitation.group_id, invitation.id
314 - )),
315 - )
316 - .tone(makeover_layout::Tone::Danger),
317 - )
318 - });
319 -
320 - let mut nodes = vec![
321 - Node::section("Waiting on you to admit them"),
322 - Node::list(rows),
323 - ];
324 -
325 - // The age of this list, not of a neighbouring one.
326 - // `pending_confirmations_refreshed_at` is the freshness of exactly the rows
327 - // `pending_confirmations` returned, and it reports the stalest group it drew
328 - // from: `write_invitations` is scoped per group so one group's failed fetch
329 - // does not blank the others, which is also the cycle where a number about
330 - // the group list would overstate this one. A section whose whole job is a
331 - // security decision should not do that.
332 - if let Some(at) = refreshed {
333 - nodes.push(Node::text(format!(
334 - "This list was last refreshed {at}. Somebody who accepted since then is not \
335 - here yet."
336 - )));
311 + groups.push(Group {
312 + name: group.name.clone(),
313 + is_admin: group.is_admin,
314 + members: members
315 + .into_iter()
316 + .map(|member| Member {
317 + is_admin: member.role == "admin",
318 + email: member.email,
319 + })
320 + .collect(),
321 + invitations: invitations
322 + .into_iter()
323 + .filter_map(|invitation| {
324 + let state = invitation.effective_state();
325 + // Drawn at the top of the pane instead. An accepted
326 + // invitation is the one state where a person is waiting,
327 + // and finding it by opening each group in turn is how
328 + // somebody waits a week.
329 + if state == InvitationState::Accepted {
330 + return None;
331 + }
332 + let usable = state == InvitationState::Pending;
333 + Some(Invitation {
334 + group: group.id.to_string(),
335 + id: invitation.id.to_string(),
336 + state: state_word(state),
337 + usable,
338 + issued: invitation.created_at,
339 + expires: invitation.expires_at,
340 + token: usable.then_some(invitation.token).flatten(),
341 + })
342 + })
343 + .collect(),
344 + });
337 345 }
346 + drop(conn);
338 347
339 - Ok(nodes)
340 - }
341 -
342 - /// Your identity public key, and the fingerprint to read it out by.
343 - ///
344 - /// Both are local: `my_identity_public_key` derives from the master key and
345 - /// `fingerprint_of_base64` hashes it. The Tauri commands wrapping them are
346 - /// `async` because their siblings are, not because either awaits.
347 - ///
348 - /// The key is a field rather than text so a host that can offer a copy control
349 - /// has something to attach it to, and so the value is selectable everywhere else.
350 - fn identity(app: &AppState) -> Vec<Node> {
351 - let Some(client) = app.read_recovering() else {
352 - return vec![Node::empty(
353 - "Sync is not set up on this device, so there is no identity key yet. \
354 - A key is derived from your encryption master key.",
355 - )];
356 - };
357 -
358 - let Ok(pubkey) = client.my_identity_public_key() else {
359 - // Set up, but with no master key loaded yet.
360 - return vec![Node::empty(
361 - "Encryption is not set up yet, so there is no identity key to show.",
362 - )];
363 - };
364 -
365 - let fingerprint = synckit_client::identity::IdentityPublicKey::fingerprint_of_base64(&pubkey)
366 - .unwrap_or_else(|_| "unavailable".to_owned());
367 -
368 - vec![
369 - Node::text(
370 - "Give this to a group's admin and they can add you. It is a public key: \
371 - it admits you to nothing on its own.",
372 - ),
373 - Node::field(
374 - Field {
375 - value: Some(pubkey),
376 - ..Field::new(
377 - makeover_layout::FieldKind::Text,
378 - "identity_pubkey",
379 - "Your public key",
380 - )
381 - }
382 - .hint("Read the fingerprint below out loud to check it arrived intact."),
383 - ),
384 - Node::field(Field {
385 - value: Some(fingerprint),
386 - ..Field::new(
387 - makeover_layout::FieldKind::Text,
388 - "identity_fingerprint",
389 - "Fingerprint",
Lines truncated