max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
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