atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-64: enable UI buttons

Orual (Jun 29, 2026, 7:06 PM EDT) f9e95dab 788faa4a

+3713 -257
+26
.sqlx/query-09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT li.subject_uri AS \"subject_uri!\"\n FROM listitems li\n JOIN things t ON t.uri = li.subject_uri\n WHERE li.list_uri = ?\n ORDER BY li.created_at, li.did, li.rkey\n LIMIT ? OFFSET ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "subject_uri!", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "listitems", 13 + "name": "subject_uri" 14 + } 15 + } 16 + } 17 + ], 18 + "parameters": { 19 + "Right": 3 20 + }, 21 + "nullable": [ 22 + false 23 + ] 24 + }, 25 + "hash": "09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109" 26 + }
+12
.sqlx/query-188b361a738b4b83442799d2da98a5b4e454a7631d794adc254522a2b5fabdb6.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "DELETE FROM follows WHERE follower_did = ? AND rkey = ?", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 2 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "188b361a738b4b83442799d2da98a5b4e454a7631d794adc254522a2b5fabdb6" 12 + }
+12
.sqlx/query-28418e2a18f093b265bd5e9f7216e22d55337df6125d2debb8915ecada42a1d4.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "INSERT OR IGNORE INTO follows (follower_did, followed_did, rkey, cid, created_at) VALUES (?, ?, ?, ?, ?)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 5 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "28418e2a18f093b265bd5e9f7216e22d55337df6125d2debb8915ecada42a1d4" 12 + }
+21
.sqlx/query-31f4a07f5d7c76809d73e7195f3407797c4561a5d5b9540b88c1fd5d4408316b.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT EXISTS(SELECT 1 FROM identities WHERE did = ? UNION SELECT 1 FROM profiles WHERE did = ?) AS \"exists!: bool\"", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "exists!: bool", 8 + "ordinal": 0, 9 + "type_info": "Integer", 10 + "origin": "Expression" 11 + } 12 + ], 13 + "parameters": { 14 + "Right": 2 15 + }, 16 + "nullable": [ 17 + false 18 + ] 19 + }, 20 + "hash": "31f4a07f5d7c76809d73e7195f3407797c4561a5d5b9540b88c1fd5d4408316b" 21 + }
+38
.sqlx/query-3f5a57b0feda90f18e0f1bf7a4d55f540b7b00cd1baa0aaee95f21f1cbb4efc5.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT name AS \"name!\", file_json AS \"file_json!\" FROM parts WHERE did = ? AND rkey = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "name!", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "parts", 13 + "name": "name" 14 + } 15 + } 16 + }, 17 + { 18 + "name": "file_json!", 19 + "ordinal": 1, 20 + "type_info": "Text", 21 + "origin": { 22 + "Table": { 23 + "table": "parts", 24 + "name": "file_json" 25 + } 26 + } 27 + } 28 + ], 29 + "parameters": { 30 + "Right": 2 31 + }, 32 + "nullable": [ 33 + false, 34 + false 35 + ] 36 + }, 37 + "hash": "3f5a57b0feda90f18e0f1bf7a4d55f540b7b00cd1baa0aaee95f21f1cbb4efc5" 38 + }
-26
.sqlx/query-634eb748ec24612d08e8f0cae92d2713b739d9d44609086638392e9b102de747.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT subject_uri FROM listitems WHERE list_uri = ? ORDER BY created_at, did, rkey LIMIT ? OFFSET ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "subject_uri", 8 - "ordinal": 0, 9 - "type_info": "Text", 10 - "origin": { 11 - "Table": { 12 - "table": "listitems", 13 - "name": "subject_uri" 14 - } 15 - } 16 - } 17 - ], 18 - "parameters": { 19 - "Right": 3 20 - }, 21 - "nullable": [ 22 - false 23 - ] 24 - }, 25 - "hash": "634eb748ec24612d08e8f0cae92d2713b739d9d44609086638392e9b102de747" 26 - }
+12
.sqlx/query-7fb498775604e7be3ff175df985f7718e7ae32c3b07eef96edd793885a70ebf7.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "DELETE FROM lists WHERE did = ? AND rkey = ?", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 2 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "7fb498775604e7be3ff175df985f7718e7ae32c3b07eef96edd793885a70ebf7" 12 + }
+21
.sqlx/query-b22287f09b857319429c8602db45b935438f1d59c57085dd97e1eb93b0ad97c2.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT COUNT(*) AS \"count!: i64\" FROM follows WHERE follower_did = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "count!: i64", 8 + "ordinal": 0, 9 + "type_info": "Integer", 10 + "origin": "Expression" 11 + } 12 + ], 13 + "parameters": { 14 + "Right": 1 15 + }, 16 + "nullable": [ 17 + false 18 + ] 19 + }, 20 + "hash": "b22287f09b857319429c8602db45b935438f1d59c57085dd97e1eb93b0ad97c2" 21 + }
+26
.sqlx/query-b5db4be14772a0fab40500f594a40d2ae78b5489cb7e94adef5ec5c4d716bc35.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT rkey FROM follows WHERE follower_did = ? AND followed_did = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "rkey", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "follows", 13 + "name": "rkey" 14 + } 15 + } 16 + } 17 + ], 18 + "parameters": { 19 + "Right": 2 20 + }, 21 + "nullable": [ 22 + false 23 + ] 24 + }, 25 + "hash": "b5db4be14772a0fab40500f594a40d2ae78b5489cb7e94adef5ec5c4d716bc35" 26 + }
+198
.sqlx/query-bc42e631836b364189d43a5e37642cdfae03c7e37d47c5fa49ef73e292c9ef1f.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT t.did AS \"did!\", t.rkey AS \"rkey!\", t.uri AS \"uri!\", t.cid AS \"cid!\",\n t.name AS \"name!\", t.summary, t.license AS \"license!\", t.tags_json, t.cover_json,\n t.derived_from_uri, t.created_at AS \"created_at!\", t.indexed_at AS \"indexed_at!\",\n t.record_json,\n COALESCE(cs.like_count, 0) AS \"like_count!: i64\",\n COALESCE(cs.save_count, 0) AS \"save_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS \"model_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS \"part_count!: i64\"\n FROM things t\n JOIN follows f ON f.followed_did = t.did\n LEFT JOIN content_stats cs ON cs.uri = t.uri\n WHERE f.follower_did = ?\n AND (? IS NULL OR t.rkey < ? OR (t.rkey = ? AND t.did < ?))\n ORDER BY t.rkey DESC, t.did DESC LIMIT ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "did!", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "things", 13 + "name": "did" 14 + } 15 + } 16 + }, 17 + { 18 + "name": "rkey!", 19 + "ordinal": 1, 20 + "type_info": "Text", 21 + "origin": { 22 + "Table": { 23 + "table": "things", 24 + "name": "rkey" 25 + } 26 + } 27 + }, 28 + { 29 + "name": "uri!", 30 + "ordinal": 2, 31 + "type_info": "Text", 32 + "origin": { 33 + "Table": { 34 + "table": "things", 35 + "name": "uri" 36 + } 37 + } 38 + }, 39 + { 40 + "name": "cid!", 41 + "ordinal": 3, 42 + "type_info": "Text", 43 + "origin": { 44 + "Table": { 45 + "table": "things", 46 + "name": "cid" 47 + } 48 + } 49 + }, 50 + { 51 + "name": "name!", 52 + "ordinal": 4, 53 + "type_info": "Text", 54 + "origin": { 55 + "Table": { 56 + "table": "things", 57 + "name": "name" 58 + } 59 + } 60 + }, 61 + { 62 + "name": "summary", 63 + "ordinal": 5, 64 + "type_info": "Text", 65 + "origin": { 66 + "Table": { 67 + "table": "things", 68 + "name": "summary" 69 + } 70 + } 71 + }, 72 + { 73 + "name": "license!", 74 + "ordinal": 6, 75 + "type_info": "Text", 76 + "origin": { 77 + "Table": { 78 + "table": "things", 79 + "name": "license" 80 + } 81 + } 82 + }, 83 + { 84 + "name": "tags_json", 85 + "ordinal": 7, 86 + "type_info": "Text", 87 + "origin": { 88 + "Table": { 89 + "table": "things", 90 + "name": "tags_json" 91 + } 92 + } 93 + }, 94 + { 95 + "name": "cover_json", 96 + "ordinal": 8, 97 + "type_info": "Text", 98 + "origin": { 99 + "Table": { 100 + "table": "things", 101 + "name": "cover_json" 102 + } 103 + } 104 + }, 105 + { 106 + "name": "derived_from_uri", 107 + "ordinal": 9, 108 + "type_info": "Text", 109 + "origin": { 110 + "Table": { 111 + "table": "things", 112 + "name": "derived_from_uri" 113 + } 114 + } 115 + }, 116 + { 117 + "name": "created_at!", 118 + "ordinal": 10, 119 + "type_info": "Integer", 120 + "origin": { 121 + "Table": { 122 + "table": "things", 123 + "name": "created_at" 124 + } 125 + } 126 + }, 127 + { 128 + "name": "indexed_at!", 129 + "ordinal": 11, 130 + "type_info": "Integer", 131 + "origin": { 132 + "Table": { 133 + "table": "things", 134 + "name": "indexed_at" 135 + } 136 + } 137 + }, 138 + { 139 + "name": "record_json", 140 + "ordinal": 12, 141 + "type_info": "Text", 142 + "origin": { 143 + "Table": { 144 + "table": "things", 145 + "name": "record_json" 146 + } 147 + } 148 + }, 149 + { 150 + "name": "like_count!: i64", 151 + "ordinal": 13, 152 + "type_info": "Integer", 153 + "origin": "Expression" 154 + }, 155 + { 156 + "name": "save_count!: i64", 157 + "ordinal": 14, 158 + "type_info": "Integer", 159 + "origin": "Expression" 160 + }, 161 + { 162 + "name": "model_count!: i64", 163 + "ordinal": 15, 164 + "type_info": "Integer", 165 + "origin": "Expression" 166 + }, 167 + { 168 + "name": "part_count!: i64", 169 + "ordinal": 16, 170 + "type_info": "Integer", 171 + "origin": "Expression" 172 + } 173 + ], 174 + "parameters": { 175 + "Right": 6 176 + }, 177 + "nullable": [ 178 + false, 179 + false, 180 + false, 181 + false, 182 + false, 183 + true, 184 + false, 185 + true, 186 + true, 187 + true, 188 + false, 189 + false, 190 + true, 191 + false, 192 + false, 193 + false, 194 + false 195 + ] 196 + }, 197 + "hash": "bc42e631836b364189d43a5e37642cdfae03c7e37d47c5fa49ef73e292c9ef1f" 198 + }
+12
.sqlx/query-c038411fe26c2eb57d53c31bcc166fdb6bc466403516327778e4c9aa79bb31de.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "INSERT INTO lists (did, rkey, uri, cid, name, description, purpose, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(did, rkey) DO UPDATE SET uri = excluded.uri, cid = excluded.cid, name = excluded.name, description = excluded.description, purpose = excluded.purpose, created_at = excluded.created_at, indexed_at = excluded.indexed_at, record_json = excluded.record_json", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 10 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "c038411fe26c2eb57d53c31bcc166fdb6bc466403516327778e4c9aa79bb31de" 12 + }
+21
.sqlx/query-d68b5dc01d171026c69a623b7743b1ae4778913761ef7aed60d0858cb006fe33.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT COUNT(*) AS \"count!: i64\" FROM follows WHERE followed_did = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "count!: i64", 8 + "ordinal": 0, 9 + "type_info": "Integer", 10 + "origin": "Expression" 11 + } 12 + ], 13 + "parameters": { 14 + "Right": 1 15 + }, 16 + "nullable": [ 17 + false 18 + ] 19 + }, 20 + "hash": "d68b5dc01d171026c69a623b7743b1ae4778913761ef7aed60d0858cb006fe33" 21 + }
+93
.sqlx/query-f9a966b813a57d70db9c813acf49adc705c42ff6fbea402c51fe9b8451492616.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT l.uri AS \"uri!\", l.cid AS \"cid!\", l.name AS \"name!\",\n l.description, l.purpose, l.created_at AS \"created_at!\",\n COUNT(li.subject_uri) AS \"item_count!: i64\"\n FROM lists l\n LEFT JOIN listitems li ON li.list_uri = l.uri\n WHERE l.did = ?\n GROUP BY l.uri, l.cid, l.name, l.description, l.purpose, l.created_at\n ORDER BY l.created_at DESC, l.rkey DESC", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "uri!", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "lists", 13 + "name": "uri" 14 + } 15 + } 16 + }, 17 + { 18 + "name": "cid!", 19 + "ordinal": 1, 20 + "type_info": "Text", 21 + "origin": { 22 + "Table": { 23 + "table": "lists", 24 + "name": "cid" 25 + } 26 + } 27 + }, 28 + { 29 + "name": "name!", 30 + "ordinal": 2, 31 + "type_info": "Text", 32 + "origin": { 33 + "Table": { 34 + "table": "lists", 35 + "name": "name" 36 + } 37 + } 38 + }, 39 + { 40 + "name": "description", 41 + "ordinal": 3, 42 + "type_info": "Text", 43 + "origin": { 44 + "Table": { 45 + "table": "lists", 46 + "name": "description" 47 + } 48 + } 49 + }, 50 + { 51 + "name": "purpose", 52 + "ordinal": 4, 53 + "type_info": "Text", 54 + "origin": { 55 + "Table": { 56 + "table": "lists", 57 + "name": "purpose" 58 + } 59 + } 60 + }, 61 + { 62 + "name": "created_at!", 63 + "ordinal": 5, 64 + "type_info": "Integer", 65 + "origin": { 66 + "Table": { 67 + "table": "lists", 68 + "name": "created_at" 69 + } 70 + } 71 + }, 72 + { 73 + "name": "item_count!: i64", 74 + "ordinal": 6, 75 + "type_info": "Integer", 76 + "origin": "Expression" 77 + } 78 + ], 79 + "parameters": { 80 + "Right": 1 81 + }, 82 + "nullable": [ 83 + false, 84 + false, 85 + false, 86 + true, 87 + true, 88 + false, 89 + false 90 + ] 91 + }, 92 + "hash": "f9a966b813a57d70db9c813acf49adc705c42ff6fbea402c51fe9b8451492616" 93 + }
+38
.sqlx/query-fc222f5dc100602ebb991688279ca6541c4093e594e309f346000072125b922c.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT cid, rkey FROM follows WHERE follower_did = ? AND followed_did = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "cid", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "follows", 13 + "name": "cid" 14 + } 15 + } 16 + }, 17 + { 18 + "name": "rkey", 19 + "ordinal": 1, 20 + "type_info": "Text", 21 + "origin": { 22 + "Table": { 23 + "table": "follows", 24 + "name": "rkey" 25 + } 26 + } 27 + } 28 + ], 29 + "parameters": { 30 + "Right": 2 31 + }, 32 + "nullable": [ 33 + false, 34 + false 35 + ] 36 + }, 37 + "hash": "fc222f5dc100602ebb991688279ca6541c4093e594e309f346000072125b922c" 38 + }
+31 -1
assets/styling/primitives.css
··· 180 180 margin-top: auto; 181 181 } 182 182 183 + .thing-card-actions a { 184 + text-decoration: none; 185 + } 186 + 183 187 .thing-card-stats { 184 188 display: flex; 185 189 flex-wrap: wrap; ··· 224 228 } 225 229 226 230 .thing-card-action { 227 - cursor: default; 231 + padding: 0.18rem 0.28rem; 232 + cursor: pointer; 228 233 color: var(--color-muted); 234 + background: transparent; 235 + border: 1px solid transparent; 229 236 font-family: var(--font-mono); 230 237 font-size: 0.72rem; 231 238 letter-spacing: 0.04em; 232 239 text-transform: uppercase; 240 + } 241 + 242 + .thing-card-action:hover, 243 + .thing-card-action[aria-pressed="true"] { 244 + color: var(--color-tertiary); 245 + border-color: var(--color-primary); 246 + background: color-mix(in srgb, var(--color-primary) 8%, transparent); 247 + } 248 + 249 + .thing-card-action:focus-visible { 250 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 251 + outline-offset: 1px; 252 + } 253 + 254 + .thing-card-action:disabled { 255 + cursor: wait; 256 + opacity: 0.6; 257 + } 258 + 259 + .thing-card-action-error { 260 + flex-basis: 100%; 261 + color: var(--color-error); 262 + font-size: 0.78rem; 233 263 } 234 264 235 265 /* Creator links on cards and thing detail */
+81 -1
assets/styling/profile.css
··· 123 123 124 124 .profile-rail-action { 125 125 display: flex; 126 + flex-direction: column; 127 + gap: 0.45rem; 126 128 } 127 129 128 130 .profile-rail-action .profile-follow-form { ··· 133 135 .profile-rail-action .button { 134 136 width: 100%; 135 137 justify-content: center; 138 + } 139 + 140 + .profile-action-error { 141 + margin: 0; 142 + color: var(--color-error); 143 + font-size: 0.82rem; 136 144 } 137 145 138 146 .profile-bio { ··· 192 200 align-content: start; 193 201 } 194 202 195 - .profile-placeholder-panel { 203 + .profile-empty-panel { 196 204 display: flex; 197 205 flex-direction: column; 198 206 gap: 0.5rem; 199 207 padding: 2rem; 200 208 background-color: var(--color-surface); 201 209 border: 1px solid var(--color-border); 210 + color: var(--color-text); 211 + } 212 + 213 + .profile-empty-panel h2, 214 + .profile-empty-panel p { 215 + margin: 0; 216 + } 217 + 218 + .profile-empty-panel p { 219 + color: var(--color-secondary); 220 + } 221 + 222 + .profile-lists { 223 + display: flex; 224 + flex-direction: column; 225 + gap: 0.75rem; 226 + } 227 + 228 + .profile-list-card { 229 + display: flex; 230 + flex-direction: column; 231 + gap: 0.65rem; 232 + padding: clamp(1rem, 2vw, 1.25rem); 233 + } 234 + 235 + .profile-list-card-header { 236 + display: flex; 237 + flex-wrap: wrap; 238 + align-items: baseline; 239 + justify-content: space-between; 240 + gap: 0.5rem 1rem; 241 + } 242 + 243 + .profile-list-card h2, 244 + .profile-list-card p, 245 + .profile-list-meta { 246 + margin: 0; 247 + } 248 + 249 + .profile-list-card h2 { 250 + color: var(--color-emphasis); 251 + font-size: 1.05rem; 252 + } 253 + 254 + .profile-list-card p { 255 + color: var(--color-text); 256 + line-height: 1.5; 257 + } 258 + 259 + .profile-list-meta { 260 + display: flex; 261 + flex-wrap: wrap; 262 + gap: 0.5rem 1.25rem; 263 + } 264 + 265 + .profile-list-meta div { 266 + display: flex; 267 + flex-direction: column; 268 + gap: 0.1rem; 269 + min-width: 0; 270 + } 271 + 272 + .profile-list-meta dt { 202 273 color: var(--color-muted); 274 + font-size: 0.75rem; 275 + } 276 + 277 + .profile-list-meta dd { 278 + margin: 0; 279 + color: var(--color-subtle); 280 + font-family: var(--font-mono); 281 + font-size: 0.78rem; 282 + overflow-wrap: anywhere; 203 283 } 204 284 205 285 /* About tab */
+11
assets/styling/thing-detail.css
··· 149 149 align-items: center; 150 150 } 151 151 152 + .thing-detail-actions .detail-action-error { 153 + flex-basis: 100%; 154 + margin: 0; 155 + color: var(--color-error); 156 + font-size: 0.85rem; 157 + } 158 + 152 159 .thing-detail-actions .button-primary { 153 160 min-width: 9.5rem; 154 161 } ··· 235 242 .selected-model-panel, 236 243 .part-row-body { 237 244 gap: 0.65rem; 245 + } 246 + 247 + .part-download { 248 + align-self: flex-start; 238 249 } 239 250 240 251 .parts-list {
+55 -1
crates/polymodel-api/lexicons/space_polymodel_actor_defs.json
··· 2 2 "lexicon": 1, 3 3 "id": "space.polymodel.actor.defs", 4 4 "defs": { 5 + "profileListView": { 6 + "type": "object", 7 + "description": "A list authored by the profile actor, with enough metadata for profile tabs and links into space.polymodel.graph.getList.", 8 + "required": [ 9 + "uri", 10 + "cid", 11 + "name", 12 + "itemCount", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "cid": { 17 + "type": "string", 18 + "format": "cid" 19 + }, 20 + "createdAt": { 21 + "type": "string", 22 + "format": "datetime" 23 + }, 24 + "description": { 25 + "type": "string" 26 + }, 27 + "itemCount": { 28 + "type": "integer" 29 + }, 30 + "name": { 31 + "type": "string" 32 + }, 33 + "purpose": { 34 + "type": "string" 35 + }, 36 + "uri": { 37 + "type": "string", 38 + "format": "at-uri" 39 + } 40 + } 41 + }, 5 42 "profileView": { 6 43 "type": "object", 7 - "description": "Polymodel actor profile view (union variant). Hydrated from the space.polymodel.actor.profile record plus SQLite-derived stats (thingCount, totalLikes). Returned when the actor authored a polymodel profile record; otherwise getProfile returns the app.bsky.actor.defs#profileViewDetailed variant.", 44 + "description": "Polymodel actor profile view (union variant). Hydrated from the space.polymodel.actor.profile record plus SQLite-derived stats and viewer relationship state. Returned when the actor authored a polymodel profile record; otherwise getProfile returns the app.bsky.actor.defs#profileViewDetailed variant.", 8 45 "required": [ 9 46 "did", 10 47 "handle", ··· 29 66 "displayName": { 30 67 "type": "string" 31 68 }, 69 + "followerCount": { 70 + "type": "integer" 71 + }, 72 + "followingCount": { 73 + "type": "integer" 74 + }, 32 75 "handle": { 33 76 "type": "string" 34 77 }, ··· 43 86 "format": "uri" 44 87 } 45 88 }, 89 + "lists": { 90 + "type": "array", 91 + "items": { 92 + "type": "ref", 93 + "ref": "space.polymodel.actor.defs#profileListView" 94 + } 95 + }, 46 96 "printers": { 47 97 "type": "array", 48 98 "items": { ··· 61 111 }, 62 112 "totalLikes": { 63 113 "type": "integer" 114 + }, 115 + "viewer": { 116 + "type": "ref", 117 + "ref": "space.polymodel.library.defs#actorViewerState" 64 118 } 65 119 } 66 120 },
+48
crates/polymodel-api/lexicons/space_polymodel_graph_createFollow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "space.polymodel.graph.createFollow", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a Polymodel-native follow record for the authenticated actor.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "subject" 14 + ], 15 + "properties": { 16 + "subject": { 17 + "type": "string", 18 + "format": "did" 19 + } 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": [ 28 + "uri", 29 + "cid" 30 + ], 31 + "properties": { 32 + "cid": { 33 + "type": "string", 34 + "format": "cid" 35 + }, 36 + "created": { 37 + "type": "boolean" 38 + }, 39 + "uri": { 40 + "type": "string", 41 + "format": "at-uri" 42 + } 43 + } 44 + } 45 + } 46 + } 47 + } 48 + }
+44
crates/polymodel-api/lexicons/space_polymodel_graph_deleteFollow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "space.polymodel.graph.deleteFollow", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete the authenticated actor's Polymodel-native follow by record URI or subject DID.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "properties": { 13 + "subject": { 14 + "type": "string", 15 + "format": "did" 16 + }, 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + } 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": [ 29 + "deleted" 30 + ], 31 + "properties": { 32 + "deleted": { 33 + "type": "boolean" 34 + }, 35 + "uri": { 36 + "type": "string", 37 + "format": "at-uri" 38 + } 39 + } 40 + } 41 + } 42 + } 43 + } 44 + }
+28
crates/polymodel-api/lexicons/space_polymodel_graph_follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "space.polymodel.graph.follow", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Polymodel-native follow relationship from the record author to another actor DID.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "subject", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + }, 20 + "subject": { 21 + "type": "string", 22 + "format": "did" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + }
+3 -2
crates/polymodel-api/lexicons/space_polymodel_library_getFeed.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Recent/hot feed of things.", 7 + "description": "Recent, hot, or viewer-following feed of things.", 8 8 "parameters": { 9 9 "type": "params", 10 10 "properties": { ··· 12 12 "type": "string", 13 13 "knownValues": [ 14 14 "recent", 15 - "hot" 15 + "hot", 16 + "following" 16 17 ] 17 18 }, 18 19 "cursor": {
+684 -164
crates/polymodel-api/src/space_polymodel/actor.rs
··· 22 22 #[allow(unused_imports)] 23 23 use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; 24 24 use jacquard_common::deps::smol_str::SmolStr; 25 - use jacquard_common::types::string::{Did, Datetime, UriValue}; 25 + use jacquard_common::types::string::{Did, AtUri, Cid, Datetime, UriValue}; 26 26 use jacquard_common::types::value::Data; 27 27 use jacquard_derive::IntoStatic; 28 28 use jacquard_lexicon::lexicon::LexiconDoc; ··· 32 32 use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; 33 33 use serde::{Serialize, Deserialize}; 34 34 use crate::app_bsky::actor::ProfileViewDetailed; 35 + use crate::space_polymodel::library::ActorViewerState; 35 36 use crate::space_polymodel::actor; 36 - /// Polymodel actor profile view (union variant). Hydrated from the space.polymodel.actor.profile record plus SQLite-derived stats (thingCount, totalLikes). Returned when the actor authored a polymodel profile record; otherwise getProfile returns the app.bsky.actor.defs#profileViewDetailed variant. 37 + /// A list authored by the profile actor, with enough metadata for profile tabs and links into space.polymodel.graph.getList. 38 + 39 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 40 + #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 41 + pub struct ProfileListView<S: BosStr = DefaultStr> { 42 + pub cid: Cid<S>, 43 + pub created_at: Datetime, 44 + #[serde(skip_serializing_if = "Option::is_none")] 45 + pub description: Option<S>, 46 + pub item_count: i64, 47 + pub name: S, 48 + #[serde(skip_serializing_if = "Option::is_none")] 49 + pub purpose: Option<S>, 50 + pub uri: AtUri<S>, 51 + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 52 + pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 53 + } 54 + 55 + /// Polymodel actor profile view (union variant). Hydrated from the space.polymodel.actor.profile record plus SQLite-derived stats and viewer relationship state. Returned when the actor authored a polymodel profile record; otherwise getProfile returns the app.bsky.actor.defs#profileViewDetailed variant. 37 56 38 57 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 39 58 #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] ··· 48 67 pub did: Did<S>, 49 68 #[serde(skip_serializing_if = "Option::is_none")] 50 69 pub display_name: Option<S>, 70 + #[serde(skip_serializing_if = "Option::is_none")] 71 + pub follower_count: Option<i64>, 72 + #[serde(skip_serializing_if = "Option::is_none")] 73 + pub following_count: Option<i64>, 51 74 pub handle: S, 52 75 #[serde(skip_serializing_if = "Option::is_none")] 53 76 pub indexed_at: Option<Datetime>, 54 77 #[serde(skip_serializing_if = "Option::is_none")] 55 78 pub links: Option<Vec<UriValue<S>>>, 56 79 #[serde(skip_serializing_if = "Option::is_none")] 80 + pub lists: Option<Vec<actor::ProfileListView<S>>>, 81 + #[serde(skip_serializing_if = "Option::is_none")] 57 82 pub printers: Option<Vec<S>>, 58 83 #[serde(skip_serializing_if = "Option::is_none")] 59 84 pub pronouns: Option<S>, ··· 63 88 pub thing_count: Option<i64>, 64 89 #[serde(skip_serializing_if = "Option::is_none")] 65 90 pub total_likes: Option<i64>, 91 + #[serde(skip_serializing_if = "Option::is_none")] 92 + pub viewer: Option<ActorViewerState<S>>, 66 93 #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 67 94 pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 68 95 } ··· 78 105 ProfileViewDetailed(Box<ProfileViewDetailed<S>>), 79 106 } 80 107 108 + impl<S: BosStr> LexiconSchema for ProfileListView<S> { 109 + fn nsid() -> &'static str { 110 + "space.polymodel.actor.defs" 111 + } 112 + fn def_name() -> &'static str { 113 + "profileListView" 114 + } 115 + fn lexicon_doc() -> LexiconDoc<'static> { 116 + lexicon_doc_space_polymodel_actor_defs() 117 + } 118 + fn validate(&self) -> Result<(), ConstraintError> { 119 + Ok(()) 120 + } 121 + } 122 + 81 123 impl<S: BosStr> LexiconSchema for ProfileView<S> { 82 124 fn nsid() -> &'static str { 83 125 "space.polymodel.actor.defs" ··· 93 135 } 94 136 } 95 137 138 + pub mod profile_list_view_state { 139 + 140 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 141 + #[allow(unused)] 142 + use ::core::marker::PhantomData; 143 + mod sealed { 144 + pub trait Sealed {} 145 + } 146 + /// State trait tracking which required fields have been set 147 + pub trait State: sealed::Sealed { 148 + type Cid; 149 + type CreatedAt; 150 + type ItemCount; 151 + type Name; 152 + type Uri; 153 + } 154 + /// Empty state - all required fields are unset 155 + pub struct Empty(()); 156 + impl sealed::Sealed for Empty {} 157 + impl State for Empty { 158 + type Cid = Unset; 159 + type CreatedAt = Unset; 160 + type ItemCount = Unset; 161 + type Name = Unset; 162 + type Uri = Unset; 163 + } 164 + ///State transition - sets the `cid` field to Set 165 + pub struct SetCid<St: State = Empty>(PhantomData<fn() -> St>); 166 + impl<St: State> sealed::Sealed for SetCid<St> {} 167 + impl<St: State> State for SetCid<St> { 168 + type Cid = Set<members::cid>; 169 + type CreatedAt = St::CreatedAt; 170 + type ItemCount = St::ItemCount; 171 + type Name = St::Name; 172 + type Uri = St::Uri; 173 + } 174 + ///State transition - sets the `created_at` field to Set 175 + pub struct SetCreatedAt<St: State = Empty>(PhantomData<fn() -> St>); 176 + impl<St: State> sealed::Sealed for SetCreatedAt<St> {} 177 + impl<St: State> State for SetCreatedAt<St> { 178 + type Cid = St::Cid; 179 + type CreatedAt = Set<members::created_at>; 180 + type ItemCount = St::ItemCount; 181 + type Name = St::Name; 182 + type Uri = St::Uri; 183 + } 184 + ///State transition - sets the `item_count` field to Set 185 + pub struct SetItemCount<St: State = Empty>(PhantomData<fn() -> St>); 186 + impl<St: State> sealed::Sealed for SetItemCount<St> {} 187 + impl<St: State> State for SetItemCount<St> { 188 + type Cid = St::Cid; 189 + type CreatedAt = St::CreatedAt; 190 + type ItemCount = Set<members::item_count>; 191 + type Name = St::Name; 192 + type Uri = St::Uri; 193 + } 194 + ///State transition - sets the `name` field to Set 195 + pub struct SetName<St: State = Empty>(PhantomData<fn() -> St>); 196 + impl<St: State> sealed::Sealed for SetName<St> {} 197 + impl<St: State> State for SetName<St> { 198 + type Cid = St::Cid; 199 + type CreatedAt = St::CreatedAt; 200 + type ItemCount = St::ItemCount; 201 + type Name = Set<members::name>; 202 + type Uri = St::Uri; 203 + } 204 + ///State transition - sets the `uri` field to Set 205 + pub struct SetUri<St: State = Empty>(PhantomData<fn() -> St>); 206 + impl<St: State> sealed::Sealed for SetUri<St> {} 207 + impl<St: State> State for SetUri<St> { 208 + type Cid = St::Cid; 209 + type CreatedAt = St::CreatedAt; 210 + type ItemCount = St::ItemCount; 211 + type Name = St::Name; 212 + type Uri = Set<members::uri>; 213 + } 214 + /// Marker types for field names 215 + #[allow(non_camel_case_types)] 216 + pub mod members { 217 + ///Marker type for the `cid` field 218 + pub struct cid(()); 219 + ///Marker type for the `created_at` field 220 + pub struct created_at(()); 221 + ///Marker type for the `item_count` field 222 + pub struct item_count(()); 223 + ///Marker type for the `name` field 224 + pub struct name(()); 225 + ///Marker type for the `uri` field 226 + pub struct uri(()); 227 + } 228 + } 229 + 230 + /// Builder for constructing an instance of this type. 231 + pub struct ProfileListViewBuilder< 232 + St: profile_list_view_state::State, 233 + S: BosStr = DefaultStr, 234 + > { 235 + _state: PhantomData<fn() -> St>, 236 + _fields: ( 237 + Option<Cid<S>>, 238 + Option<Datetime>, 239 + Option<S>, 240 + Option<i64>, 241 + Option<S>, 242 + Option<S>, 243 + Option<AtUri<S>>, 244 + ), 245 + _type: PhantomData<fn() -> S>, 246 + } 247 + 248 + impl ProfileListView<DefaultStr> { 249 + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed 250 + pub fn new() -> ProfileListViewBuilder<profile_list_view_state::Empty, DefaultStr> { 251 + ProfileListViewBuilder::new() 252 + } 253 + } 254 + 255 + impl<S: BosStr> ProfileListView<S> { 256 + /// Create a new builder for this type 257 + pub fn builder() -> ProfileListViewBuilder<profile_list_view_state::Empty, S> { 258 + ProfileListViewBuilder::builder() 259 + } 260 + } 261 + 262 + impl ProfileListViewBuilder<profile_list_view_state::Empty, DefaultStr> { 263 + /// Create a new builder with all fields unset, using the default string type, if needed 264 + pub fn new() -> Self { 265 + ProfileListViewBuilder { 266 + _state: PhantomData, 267 + _fields: (None, None, None, None, None, None, None), 268 + _type: PhantomData, 269 + } 270 + } 271 + } 272 + 273 + impl<S: BosStr> ProfileListViewBuilder<profile_list_view_state::Empty, S> { 274 + /// Create a new builder with all fields unset 275 + pub fn builder() -> Self { 276 + ProfileListViewBuilder { 277 + _state: PhantomData, 278 + _fields: (None, None, None, None, None, None, None), 279 + _type: PhantomData, 280 + } 281 + } 282 + } 283 + 284 + impl<St, S: BosStr> ProfileListViewBuilder<St, S> 285 + where 286 + St: profile_list_view_state::State, 287 + St::Cid: profile_list_view_state::IsUnset, 288 + { 289 + /// Set the `cid` field (required) 290 + pub fn cid( 291 + mut self, 292 + value: impl Into<Cid<S>>, 293 + ) -> ProfileListViewBuilder<profile_list_view_state::SetCid<St>, S> { 294 + self._fields.0 = Option::Some(value.into()); 295 + ProfileListViewBuilder { 296 + _state: PhantomData, 297 + _fields: self._fields, 298 + _type: PhantomData, 299 + } 300 + } 301 + } 302 + 303 + impl<St, S: BosStr> ProfileListViewBuilder<St, S> 304 + where 305 + St: profile_list_view_state::State, 306 + St::CreatedAt: profile_list_view_state::IsUnset, 307 + { 308 + /// Set the `createdAt` field (required) 309 + pub fn created_at( 310 + mut self, 311 + value: impl Into<Datetime>, 312 + ) -> ProfileListViewBuilder<profile_list_view_state::SetCreatedAt<St>, S> { 313 + self._fields.1 = Option::Some(value.into()); 314 + ProfileListViewBuilder { 315 + _state: PhantomData, 316 + _fields: self._fields, 317 + _type: PhantomData, 318 + } 319 + } 320 + } 321 + 322 + impl<St: profile_list_view_state::State, S: BosStr> ProfileListViewBuilder<St, S> { 323 + /// Set the `description` field (optional) 324 + pub fn description(mut self, value: impl Into<Option<S>>) -> Self { 325 + self._fields.2 = value.into(); 326 + self 327 + } 328 + /// Set the `description` field to an Option value (optional) 329 + pub fn maybe_description(mut self, value: Option<S>) -> Self { 330 + self._fields.2 = value; 331 + self 332 + } 333 + } 334 + 335 + impl<St, S: BosStr> ProfileListViewBuilder<St, S> 336 + where 337 + St: profile_list_view_state::State, 338 + St::ItemCount: profile_list_view_state::IsUnset, 339 + { 340 + /// Set the `itemCount` field (required) 341 + pub fn item_count( 342 + mut self, 343 + value: impl Into<i64>, 344 + ) -> ProfileListViewBuilder<profile_list_view_state::SetItemCount<St>, S> { 345 + self._fields.3 = Option::Some(value.into()); 346 + ProfileListViewBuilder { 347 + _state: PhantomData, 348 + _fields: self._fields, 349 + _type: PhantomData, 350 + } 351 + } 352 + } 353 + 354 + impl<St, S: BosStr> ProfileListViewBuilder<St, S> 355 + where 356 + St: profile_list_view_state::State, 357 + St::Name: profile_list_view_state::IsUnset, 358 + { 359 + /// Set the `name` field (required) 360 + pub fn name( 361 + mut self, 362 + value: impl Into<S>, 363 + ) -> ProfileListViewBuilder<profile_list_view_state::SetName<St>, S> { 364 + self._fields.4 = Option::Some(value.into()); 365 + ProfileListViewBuilder { 366 + _state: PhantomData, 367 + _fields: self._fields, 368 + _type: PhantomData, 369 + } 370 + } 371 + } 372 + 373 + impl<St: profile_list_view_state::State, S: BosStr> ProfileListViewBuilder<St, S> { 374 + /// Set the `purpose` field (optional) 375 + pub fn purpose(mut self, value: impl Into<Option<S>>) -> Self { 376 + self._fields.5 = value.into(); 377 + self 378 + } 379 + /// Set the `purpose` field to an Option value (optional) 380 + pub fn maybe_purpose(mut self, value: Option<S>) -> Self { 381 + self._fields.5 = value; 382 + self 383 + } 384 + } 385 + 386 + impl<St, S: BosStr> ProfileListViewBuilder<St, S> 387 + where 388 + St: profile_list_view_state::State, 389 + St::Uri: profile_list_view_state::IsUnset, 390 + { 391 + /// Set the `uri` field (required) 392 + pub fn uri( 393 + mut self, 394 + value: impl Into<AtUri<S>>, 395 + ) -> ProfileListViewBuilder<profile_list_view_state::SetUri<St>, S> { 396 + self._fields.6 = Option::Some(value.into()); 397 + ProfileListViewBuilder { 398 + _state: PhantomData, 399 + _fields: self._fields, 400 + _type: PhantomData, 401 + } 402 + } 403 + } 404 + 405 + impl<St, S: BosStr> ProfileListViewBuilder<St, S> 406 + where 407 + St: profile_list_view_state::State, 408 + St::Cid: profile_list_view_state::IsSet, 409 + St::CreatedAt: profile_list_view_state::IsSet, 410 + St::ItemCount: profile_list_view_state::IsSet, 411 + St::Name: profile_list_view_state::IsSet, 412 + St::Uri: profile_list_view_state::IsSet, 413 + { 414 + /// Build the final struct. 415 + pub fn build(self) -> ProfileListView<S> { 416 + ProfileListView { 417 + cid: self._fields.0.unwrap(), 418 + created_at: self._fields.1.unwrap(), 419 + description: self._fields.2, 420 + item_count: self._fields.3.unwrap(), 421 + name: self._fields.4.unwrap(), 422 + purpose: self._fields.5, 423 + uri: self._fields.6.unwrap(), 424 + extra_data: Default::default(), 425 + } 426 + } 427 + /// Build the final struct with custom extra_data. 428 + pub fn build_with_data( 429 + self, 430 + extra_data: BTreeMap<SmolStr, Data<S>>, 431 + ) -> ProfileListView<S> { 432 + ProfileListView { 433 + cid: self._fields.0.unwrap(), 434 + created_at: self._fields.1.unwrap(), 435 + description: self._fields.2, 436 + item_count: self._fields.3.unwrap(), 437 + name: self._fields.4.unwrap(), 438 + purpose: self._fields.5, 439 + uri: self._fields.6.unwrap(), 440 + extra_data: Some(extra_data), 441 + } 442 + } 443 + } 444 + 445 + fn lexicon_doc_space_polymodel_actor_defs() -> LexiconDoc<'static> { 446 + #[allow(unused_imports)] 447 + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; 448 + use jacquard_lexicon::lexicon::*; 449 + use alloc::collections::BTreeMap; 450 + LexiconDoc { 451 + lexicon: Lexicon::Lexicon1, 452 + id: CowStr::new_static("space.polymodel.actor.defs"), 453 + defs: { 454 + let mut map = BTreeMap::new(); 455 + map.insert( 456 + SmolStr::new_static("profileListView"), 457 + LexUserType::Object(LexObject { 458 + description: Some( 459 + CowStr::new_static( 460 + "A list authored by the profile actor, with enough metadata for profile tabs and links into space.polymodel.graph.getList.", 461 + ), 462 + ), 463 + required: Some( 464 + vec![ 465 + SmolStr::new_static("uri"), SmolStr::new_static("cid"), 466 + SmolStr::new_static("name"), 467 + SmolStr::new_static("itemCount"), 468 + SmolStr::new_static("createdAt") 469 + ], 470 + ), 471 + properties: { 472 + #[allow(unused_mut)] 473 + let mut map = BTreeMap::new(); 474 + map.insert( 475 + SmolStr::new_static("cid"), 476 + LexObjectProperty::String(LexString { 477 + format: Some(LexStringFormat::Cid), 478 + ..Default::default() 479 + }), 480 + ); 481 + map.insert( 482 + SmolStr::new_static("createdAt"), 483 + LexObjectProperty::String(LexString { 484 + format: Some(LexStringFormat::Datetime), 485 + ..Default::default() 486 + }), 487 + ); 488 + map.insert( 489 + SmolStr::new_static("description"), 490 + LexObjectProperty::String(LexString { ..Default::default() }), 491 + ); 492 + map.insert( 493 + SmolStr::new_static("itemCount"), 494 + LexObjectProperty::Integer(LexInteger { 495 + ..Default::default() 496 + }), 497 + ); 498 + map.insert( 499 + SmolStr::new_static("name"), 500 + LexObjectProperty::String(LexString { ..Default::default() }), 501 + ); 502 + map.insert( 503 + SmolStr::new_static("purpose"), 504 + LexObjectProperty::String(LexString { ..Default::default() }), 505 + ); 506 + map.insert( 507 + SmolStr::new_static("uri"), 508 + LexObjectProperty::String(LexString { 509 + format: Some(LexStringFormat::AtUri), 510 + ..Default::default() 511 + }), 512 + ); 513 + map 514 + }, 515 + ..Default::default() 516 + }), 517 + ); 518 + map.insert( 519 + SmolStr::new_static("profileView"), 520 + LexUserType::Object(LexObject { 521 + description: Some( 522 + CowStr::new_static( 523 + "Polymodel actor profile view (union variant). Hydrated from the space.polymodel.actor.profile record plus SQLite-derived stats and viewer relationship state. Returned when the actor authored a polymodel profile record; otherwise getProfile returns the app.bsky.actor.defs#profileViewDetailed variant.", 524 + ), 525 + ), 526 + required: Some( 527 + vec![ 528 + SmolStr::new_static("did"), SmolStr::new_static("handle"), 529 + SmolStr::new_static("record") 530 + ], 531 + ), 532 + properties: { 533 + #[allow(unused_mut)] 534 + let mut map = BTreeMap::new(); 535 + map.insert( 536 + SmolStr::new_static("avatar"), 537 + LexObjectProperty::String(LexString { 538 + description: Some( 539 + CowStr::new_static( 540 + "Resolved avatar URL (CDN). Null until the app view resolves the polymodel profile avatar blob.", 541 + ), 542 + ), 543 + format: Some(LexStringFormat::Uri), 544 + ..Default::default() 545 + }), 546 + ); 547 + map.insert( 548 + SmolStr::new_static("defaultLicense"), 549 + LexObjectProperty::String(LexString { ..Default::default() }), 550 + ); 551 + map.insert( 552 + SmolStr::new_static("description"), 553 + LexObjectProperty::String(LexString { ..Default::default() }), 554 + ); 555 + map.insert( 556 + SmolStr::new_static("did"), 557 + LexObjectProperty::String(LexString { 558 + format: Some(LexStringFormat::Did), 559 + ..Default::default() 560 + }), 561 + ); 562 + map.insert( 563 + SmolStr::new_static("displayName"), 564 + LexObjectProperty::String(LexString { ..Default::default() }), 565 + ); 566 + map.insert( 567 + SmolStr::new_static("followerCount"), 568 + LexObjectProperty::Integer(LexInteger { 569 + ..Default::default() 570 + }), 571 + ); 572 + map.insert( 573 + SmolStr::new_static("followingCount"), 574 + LexObjectProperty::Integer(LexInteger { 575 + ..Default::default() 576 + }), 577 + ); 578 + map.insert( 579 + SmolStr::new_static("handle"), 580 + LexObjectProperty::String(LexString { ..Default::default() }), 581 + ); 582 + map.insert( 583 + SmolStr::new_static("indexedAt"), 584 + LexObjectProperty::String(LexString { 585 + format: Some(LexStringFormat::Datetime), 586 + ..Default::default() 587 + }), 588 + ); 589 + map.insert( 590 + SmolStr::new_static("links"), 591 + LexObjectProperty::Array(LexArray { 592 + items: LexArrayItem::String(LexString { 593 + format: Some(LexStringFormat::Uri), 594 + ..Default::default() 595 + }), 596 + ..Default::default() 597 + }), 598 + ); 599 + map.insert( 600 + SmolStr::new_static("lists"), 601 + LexObjectProperty::Array(LexArray { 602 + items: LexArrayItem::Ref(LexRef { 603 + r#ref: CowStr::new_static( 604 + "space.polymodel.actor.defs#profileListView", 605 + ), 606 + ..Default::default() 607 + }), 608 + ..Default::default() 609 + }), 610 + ); 611 + map.insert( 612 + SmolStr::new_static("printers"), 613 + LexObjectProperty::Array(LexArray { 614 + items: LexArrayItem::String(LexString { 615 + ..Default::default() 616 + }), 617 + ..Default::default() 618 + }), 619 + ); 620 + map.insert( 621 + SmolStr::new_static("pronouns"), 622 + LexObjectProperty::String(LexString { ..Default::default() }), 623 + ); 624 + map.insert( 625 + SmolStr::new_static("record"), 626 + LexObjectProperty::Unknown(LexUnknown { 627 + ..Default::default() 628 + }), 629 + ); 630 + map.insert( 631 + SmolStr::new_static("thingCount"), 632 + LexObjectProperty::Integer(LexInteger { 633 + ..Default::default() 634 + }), 635 + ); 636 + map.insert( 637 + SmolStr::new_static("totalLikes"), 638 + LexObjectProperty::Integer(LexInteger { 639 + ..Default::default() 640 + }), 641 + ); 642 + map.insert( 643 + SmolStr::new_static("viewer"), 644 + LexObjectProperty::Ref(LexRef { 645 + r#ref: CowStr::new_static( 646 + "space.polymodel.library.defs#actorViewerState", 647 + ), 648 + ..Default::default() 649 + }), 650 + ); 651 + map 652 + }, 653 + ..Default::default() 654 + }), 655 + ); 656 + map.insert( 657 + SmolStr::new_static("profileViewUnion"), 658 + LexUserType::Union(LexRefUnion { 659 + refs: vec![ 660 + CowStr::new_static("space.polymodel.actor.defs#profileView"), 661 + CowStr::new_static("app.bsky.actor.defs#profileViewDetailed") 662 + ], 663 + closed: Some(true), 664 + ..Default::default() 665 + }), 666 + ); 667 + map 668 + }, 669 + ..Default::default() 670 + } 671 + } 672 + 96 673 pub mod profile_view_state { 97 674 98 675 pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; ··· 160 737 Option<S>, 161 738 Option<Did<S>>, 162 739 Option<S>, 740 + Option<i64>, 741 + Option<i64>, 163 742 Option<S>, 164 743 Option<Datetime>, 165 744 Option<Vec<UriValue<S>>>, 745 + Option<Vec<actor::ProfileListView<S>>>, 166 746 Option<Vec<S>>, 167 747 Option<S>, 168 748 Option<Data<S>>, 169 749 Option<i64>, 170 750 Option<i64>, 751 + Option<ActorViewerState<S>>, 171 752 ), 172 753 _type: PhantomData<fn() -> S>, 173 754 } ··· 205 786 None, 206 787 None, 207 788 None, 789 + None, 790 + None, 791 + None, 792 + None, 208 793 ), 209 794 _type: PhantomData, 210 795 } ··· 217 802 ProfileViewBuilder { 218 803 _state: PhantomData, 219 804 _fields: ( 805 + None, 806 + None, 807 + None, 808 + None, 220 809 None, 221 810 None, 222 811 None, ··· 307 896 } 308 897 } 309 898 899 + impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 900 + /// Set the `followerCount` field (optional) 901 + pub fn follower_count(mut self, value: impl Into<Option<i64>>) -> Self { 902 + self._fields.5 = value.into(); 903 + self 904 + } 905 + /// Set the `followerCount` field to an Option value (optional) 906 + pub fn maybe_follower_count(mut self, value: Option<i64>) -> Self { 907 + self._fields.5 = value; 908 + self 909 + } 910 + } 911 + 912 + impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 913 + /// Set the `followingCount` field (optional) 914 + pub fn following_count(mut self, value: impl Into<Option<i64>>) -> Self { 915 + self._fields.6 = value.into(); 916 + self 917 + } 918 + /// Set the `followingCount` field to an Option value (optional) 919 + pub fn maybe_following_count(mut self, value: Option<i64>) -> Self { 920 + self._fields.6 = value; 921 + self 922 + } 923 + } 924 + 310 925 impl<St, S: BosStr> ProfileViewBuilder<St, S> 311 926 where 312 927 St: profile_view_state::State, ··· 317 932 mut self, 318 933 value: impl Into<S>, 319 934 ) -> ProfileViewBuilder<profile_view_state::SetHandle<St>, S> { 320 - self._fields.5 = Option::Some(value.into()); 935 + self._fields.7 = Option::Some(value.into()); 321 936 ProfileViewBuilder { 322 937 _state: PhantomData, 323 938 _fields: self._fields, ··· 329 944 impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 330 945 /// Set the `indexedAt` field (optional) 331 946 pub fn indexed_at(mut self, value: impl Into<Option<Datetime>>) -> Self { 332 - self._fields.6 = value.into(); 947 + self._fields.8 = value.into(); 333 948 self 334 949 } 335 950 /// Set the `indexedAt` field to an Option value (optional) 336 951 pub fn maybe_indexed_at(mut self, value: Option<Datetime>) -> Self { 337 - self._fields.6 = value; 952 + self._fields.8 = value; 338 953 self 339 954 } 340 955 } ··· 342 957 impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 343 958 /// Set the `links` field (optional) 344 959 pub fn links(mut self, value: impl Into<Option<Vec<UriValue<S>>>>) -> Self { 345 - self._fields.7 = value.into(); 960 + self._fields.9 = value.into(); 346 961 self 347 962 } 348 963 /// Set the `links` field to an Option value (optional) 349 964 pub fn maybe_links(mut self, value: Option<Vec<UriValue<S>>>) -> Self { 350 - self._fields.7 = value; 965 + self._fields.9 = value; 966 + self 967 + } 968 + } 969 + 970 + impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 971 + /// Set the `lists` field (optional) 972 + pub fn lists( 973 + mut self, 974 + value: impl Into<Option<Vec<actor::ProfileListView<S>>>>, 975 + ) -> Self { 976 + self._fields.10 = value.into(); 977 + self 978 + } 979 + /// Set the `lists` field to an Option value (optional) 980 + pub fn maybe_lists(mut self, value: Option<Vec<actor::ProfileListView<S>>>) -> Self { 981 + self._fields.10 = value; 351 982 self 352 983 } 353 984 } ··· 355 986 impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 356 987 /// Set the `printers` field (optional) 357 988 pub fn printers(mut self, value: impl Into<Option<Vec<S>>>) -> Self { 358 - self._fields.8 = value.into(); 989 + self._fields.11 = value.into(); 359 990 self 360 991 } 361 992 /// Set the `printers` field to an Option value (optional) 362 993 pub fn maybe_printers(mut self, value: Option<Vec<S>>) -> Self { 363 - self._fields.8 = value; 994 + self._fields.11 = value; 364 995 self 365 996 } 366 997 } ··· 368 999 impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 369 1000 /// Set the `pronouns` field (optional) 370 1001 pub fn pronouns(mut self, value: impl Into<Option<S>>) -> Self { 371 - self._fields.9 = value.into(); 1002 + self._fields.12 = value.into(); 372 1003 self 373 1004 } 374 1005 /// Set the `pronouns` field to an Option value (optional) 375 1006 pub fn maybe_pronouns(mut self, value: Option<S>) -> Self { 376 - self._fields.9 = value; 1007 + self._fields.12 = value; 377 1008 self 378 1009 } 379 1010 } ··· 388 1019 mut self, 389 1020 value: impl Into<Data<S>>, 390 1021 ) -> ProfileViewBuilder<profile_view_state::SetRecord<St>, S> { 391 - self._fields.10 = Option::Some(value.into()); 1022 + self._fields.13 = Option::Some(value.into()); 392 1023 ProfileViewBuilder { 393 1024 _state: PhantomData, 394 1025 _fields: self._fields, ··· 400 1031 impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 401 1032 /// Set the `thingCount` field (optional) 402 1033 pub fn thing_count(mut self, value: impl Into<Option<i64>>) -> Self { 403 - self._fields.11 = value.into(); 1034 + self._fields.14 = value.into(); 404 1035 self 405 1036 } 406 1037 /// Set the `thingCount` field to an Option value (optional) 407 1038 pub fn maybe_thing_count(mut self, value: Option<i64>) -> Self { 408 - self._fields.11 = value; 1039 + self._fields.14 = value; 409 1040 self 410 1041 } 411 1042 } ··· 413 1044 impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 414 1045 /// Set the `totalLikes` field (optional) 415 1046 pub fn total_likes(mut self, value: impl Into<Option<i64>>) -> Self { 416 - self._fields.12 = value.into(); 1047 + self._fields.15 = value.into(); 417 1048 self 418 1049 } 419 1050 /// Set the `totalLikes` field to an Option value (optional) 420 1051 pub fn maybe_total_likes(mut self, value: Option<i64>) -> Self { 421 - self._fields.12 = value; 1052 + self._fields.15 = value; 1053 + self 1054 + } 1055 + } 1056 + 1057 + impl<St: profile_view_state::State, S: BosStr> ProfileViewBuilder<St, S> { 1058 + /// Set the `viewer` field (optional) 1059 + pub fn viewer(mut self, value: impl Into<Option<ActorViewerState<S>>>) -> Self { 1060 + self._fields.16 = value.into(); 1061 + self 1062 + } 1063 + /// Set the `viewer` field to an Option value (optional) 1064 + pub fn maybe_viewer(mut self, value: Option<ActorViewerState<S>>) -> Self { 1065 + self._fields.16 = value; 422 1066 self 423 1067 } 424 1068 } ··· 438 1082 description: self._fields.2, 439 1083 did: self._fields.3.unwrap(), 440 1084 display_name: self._fields.4, 441 - handle: self._fields.5.unwrap(), 442 - indexed_at: self._fields.6, 443 - links: self._fields.7, 444 - printers: self._fields.8, 445 - pronouns: self._fields.9, 446 - record: self._fields.10.unwrap(), 447 - thing_count: self._fields.11, 448 - total_likes: self._fields.12, 1085 + follower_count: self._fields.5, 1086 + following_count: self._fields.6, 1087 + handle: self._fields.7.unwrap(), 1088 + indexed_at: self._fields.8, 1089 + links: self._fields.9, 1090 + lists: self._fields.10, 1091 + printers: self._fields.11, 1092 + pronouns: self._fields.12, 1093 + record: self._fields.13.unwrap(), 1094 + thing_count: self._fields.14, 1095 + total_likes: self._fields.15, 1096 + viewer: self._fields.16, 449 1097 extra_data: Default::default(), 450 1098 } 451 1099 } ··· 460 1108 description: self._fields.2, 461 1109 did: self._fields.3.unwrap(), 462 1110 display_name: self._fields.4, 463 - handle: self._fields.5.unwrap(), 464 - indexed_at: self._fields.6, 465 - links: self._fields.7, 466 - printers: self._fields.8, 467 - pronouns: self._fields.9, 468 - record: self._fields.10.unwrap(), 469 - thing_count: self._fields.11, 470 - total_likes: self._fields.12, 1111 + follower_count: self._fields.5, 1112 + following_count: self._fields.6, 1113 + handle: self._fields.7.unwrap(), 1114 + indexed_at: self._fields.8, 1115 + links: self._fields.9, 1116 + lists: self._fields.10, 1117 + printers: self._fields.11, 1118 + pronouns: self._fields.12, 1119 + record: self._fields.13.unwrap(), 1120 + thing_count: self._fields.14, 1121 + total_likes: self._fields.15, 1122 + viewer: self._fields.16, 471 1123 extra_data: Some(extra_data), 472 1124 } 473 - } 474 - } 475 - 476 - fn lexicon_doc_space_polymodel_actor_defs() -> LexiconDoc<'static> { 477 - #[allow(unused_imports)] 478 - use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; 479 - use jacquard_lexicon::lexicon::*; 480 - use alloc::collections::BTreeMap; 481 - LexiconDoc { 482 - lexicon: Lexicon::Lexicon1, 483 - id: CowStr::new_static("space.polymodel.actor.defs"), 484 - defs: { 485 - let mut map = BTreeMap::new(); 486 - map.insert( 487 - SmolStr::new_static("profileView"), 488 - LexUserType::Object(LexObject { 489 - description: Some( 490 - CowStr::new_static( 491 - "Polymodel actor profile view (union variant). Hydrated from the space.polymodel.actor.profile record plus SQLite-derived stats (thingCount, totalLikes). Returned when the actor authored a polymodel profile record; otherwise getProfile returns the app.bsky.actor.defs#profileViewDetailed variant.", 492 - ), 493 - ), 494 - required: Some( 495 - vec![ 496 - SmolStr::new_static("did"), SmolStr::new_static("handle"), 497 - SmolStr::new_static("record") 498 - ], 499 - ), 500 - properties: { 501 - #[allow(unused_mut)] 502 - let mut map = BTreeMap::new(); 503 - map.insert( 504 - SmolStr::new_static("avatar"), 505 - LexObjectProperty::String(LexString { 506 - description: Some( 507 - CowStr::new_static( 508 - "Resolved avatar URL (CDN). Null until the app view resolves the polymodel profile avatar blob.", 509 - ), 510 - ), 511 - format: Some(LexStringFormat::Uri), 512 - ..Default::default() 513 - }), 514 - ); 515 - map.insert( 516 - SmolStr::new_static("defaultLicense"), 517 - LexObjectProperty::String(LexString { ..Default::default() }), 518 - ); 519 - map.insert( 520 - SmolStr::new_static("description"), 521 - LexObjectProperty::String(LexString { ..Default::default() }), 522 - ); 523 - map.insert( 524 - SmolStr::new_static("did"), 525 - LexObjectProperty::String(LexString { 526 - format: Some(LexStringFormat::Did), 527 - ..Default::default() 528 - }), 529 - ); 530 - map.insert( 531 - SmolStr::new_static("displayName"), 532 - LexObjectProperty::String(LexString { ..Default::default() }), 533 - ); 534 - map.insert( 535 - SmolStr::new_static("handle"), 536 - LexObjectProperty::String(LexString { ..Default::default() }), 537 - ); 538 - map.insert( 539 - SmolStr::new_static("indexedAt"), 540 - LexObjectProperty::String(LexString { 541 - format: Some(LexStringFormat::Datetime), 542 - ..Default::default() 543 - }), 544 - ); 545 - map.insert( 546 - SmolStr::new_static("links"), 547 - LexObjectProperty::Array(LexArray { 548 - items: LexArrayItem::String(LexString { 549 - format: Some(LexStringFormat::Uri), 550 - ..Default::default() 551 - }), 552 - ..Default::default() 553 - }), 554 - ); 555 - map.insert( 556 - SmolStr::new_static("printers"), 557 - LexObjectProperty::Array(LexArray { 558 - items: LexArrayItem::String(LexString { 559 - ..Default::default() 560 - }), 561 - ..Default::default() 562 - }), 563 - ); 564 - map.insert( 565 - SmolStr::new_static("pronouns"), 566 - LexObjectProperty::String(LexString { ..Default::default() }), 567 - ); 568 - map.insert( 569 - SmolStr::new_static("record"), 570 - LexObjectProperty::Unknown(LexUnknown { 571 - ..Default::default() 572 - }), 573 - ); 574 - map.insert( 575 - SmolStr::new_static("thingCount"), 576 - LexObjectProperty::Integer(LexInteger { 577 - ..Default::default() 578 - }), 579 - ); 580 - map.insert( 581 - SmolStr::new_static("totalLikes"), 582 - LexObjectProperty::Integer(LexInteger { 583 - ..Default::default() 584 - }), 585 - ); 586 - map 587 - }, 588 - ..Default::default() 589 - }), 590 - ); 591 - map.insert( 592 - SmolStr::new_static("profileViewUnion"), 593 - LexUserType::Union(LexRefUnion { 594 - refs: vec![ 595 - CowStr::new_static("space.polymodel.actor.defs#profileView"), 596 - CowStr::new_static("app.bsky.actor.defs#profileViewDetailed") 597 - ], 598 - closed: Some(true), 599 - ..Default::default() 600 - }), 601 - ); 602 - map 603 - }, 604 - ..Default::default() 605 1125 } 606 1126 }
+3
crates/polymodel-api/src/space_polymodel/graph.rs
··· 4 4 // Any manual changes will be overwritten on the next regeneration. 5 5 6 6 //! Generated bindings for the `space.polymodel.graph` Lexicon namespace/module. 7 + pub mod create_follow; 7 8 pub mod create_like; 8 9 pub mod create_save; 9 10 pub mod create_tag; 11 + pub mod delete_follow; 10 12 pub mod delete_like; 11 13 pub mod delete_save; 12 14 pub mod delete_tag; 15 + pub mod follow; 13 16 pub mod get_list; 14 17 pub mod like; 15 18 pub mod list;
+188
crates/polymodel-api/src/space_polymodel/graph/create_follow.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // Lexicon: space.polymodel.graph.createFollow 4 + // 5 + // This file was automatically generated from Lexicon schemas. 6 + // Any manual changes will be overwritten on the next regeneration. 7 + 8 + #[allow(unused_imports)] 9 + use alloc::collections::BTreeMap; 10 + 11 + #[allow(unused_imports)] 12 + use core::marker::PhantomData; 13 + use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; 14 + use jacquard_common::deps::smol_str::SmolStr; 15 + use jacquard_common::types::string::{Did, AtUri, Cid}; 16 + use jacquard_common::types::value::Data; 17 + use jacquard_derive::IntoStatic; 18 + use serde::{Serialize, Deserialize}; 19 + 20 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 21 + #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 22 + pub struct CreateFollow<S: BosStr = DefaultStr> { 23 + pub subject: Did<S>, 24 + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 25 + pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 26 + } 27 + 28 + 29 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 30 + #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 31 + pub struct CreateFollowOutput<S: BosStr = DefaultStr> { 32 + pub cid: Cid<S>, 33 + #[serde(skip_serializing_if = "Option::is_none")] 34 + pub created: Option<bool>, 35 + pub uri: AtUri<S>, 36 + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 37 + pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 38 + } 39 + 40 + /** Response marker for the `space.polymodel.graph.createFollow` procedure. 41 + 42 + Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output<S>`, which is `CreateFollowOutput<S>` for this endpoint.*/ 43 + pub struct CreateFollowResponse; 44 + impl jacquard_common::xrpc::XrpcResp for CreateFollowResponse { 45 + const NSID: &'static str = "space.polymodel.graph.createFollow"; 46 + const ENCODING: &'static str = "application/json"; 47 + type Output<S: BosStr> = CreateFollowOutput<S>; 48 + type Err = jacquard_common::xrpc::GenericError; 49 + } 50 + 51 + impl<S: BosStr> jacquard_common::xrpc::XrpcRequest for CreateFollow<S> { 52 + const NSID: &'static str = "space.polymodel.graph.createFollow"; 53 + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( 54 + "application/json", 55 + ); 56 + type Response = CreateFollowResponse; 57 + } 58 + 59 + /** Endpoint marker for the `space.polymodel.graph.createFollow` procedure. 60 + 61 + Path: `/xrpc/space.polymodel.graph.createFollow`. The request payload type is `CreateFollow<S>`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ 62 + pub struct CreateFollowRequest; 63 + impl jacquard_common::xrpc::XrpcEndpoint for CreateFollowRequest { 64 + const PATH: &'static str = "/xrpc/space.polymodel.graph.createFollow"; 65 + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( 66 + "application/json", 67 + ); 68 + type Request<S: BosStr> = CreateFollow<S>; 69 + type Response = CreateFollowResponse; 70 + } 71 + 72 + pub mod create_follow_state { 73 + 74 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 75 + #[allow(unused)] 76 + use ::core::marker::PhantomData; 77 + mod sealed { 78 + pub trait Sealed {} 79 + } 80 + /// State trait tracking which required fields have been set 81 + pub trait State: sealed::Sealed { 82 + type Subject; 83 + } 84 + /// Empty state - all required fields are unset 85 + pub struct Empty(()); 86 + impl sealed::Sealed for Empty {} 87 + impl State for Empty { 88 + type Subject = Unset; 89 + } 90 + ///State transition - sets the `subject` field to Set 91 + pub struct SetSubject<St: State = Empty>(PhantomData<fn() -> St>); 92 + impl<St: State> sealed::Sealed for SetSubject<St> {} 93 + impl<St: State> State for SetSubject<St> { 94 + type Subject = Set<members::subject>; 95 + } 96 + /// Marker types for field names 97 + #[allow(non_camel_case_types)] 98 + pub mod members { 99 + ///Marker type for the `subject` field 100 + pub struct subject(()); 101 + } 102 + } 103 + 104 + /// Builder for constructing an instance of this type. 105 + pub struct CreateFollowBuilder<St: create_follow_state::State, S: BosStr = DefaultStr> { 106 + _state: PhantomData<fn() -> St>, 107 + _fields: (Option<Did<S>>,), 108 + _type: PhantomData<fn() -> S>, 109 + } 110 + 111 + impl CreateFollow<DefaultStr> { 112 + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed 113 + pub fn new() -> CreateFollowBuilder<create_follow_state::Empty, DefaultStr> { 114 + CreateFollowBuilder::new() 115 + } 116 + } 117 + 118 + impl<S: BosStr> CreateFollow<S> { 119 + /// Create a new builder for this type 120 + pub fn builder() -> CreateFollowBuilder<create_follow_state::Empty, S> { 121 + CreateFollowBuilder::builder() 122 + } 123 + } 124 + 125 + impl CreateFollowBuilder<create_follow_state::Empty, DefaultStr> { 126 + /// Create a new builder with all fields unset, using the default string type, if needed 127 + pub fn new() -> Self { 128 + CreateFollowBuilder { 129 + _state: PhantomData, 130 + _fields: (None,), 131 + _type: PhantomData, 132 + } 133 + } 134 + } 135 + 136 + impl<S: BosStr> CreateFollowBuilder<create_follow_state::Empty, S> { 137 + /// Create a new builder with all fields unset 138 + pub fn builder() -> Self { 139 + CreateFollowBuilder { 140 + _state: PhantomData, 141 + _fields: (None,), 142 + _type: PhantomData, 143 + } 144 + } 145 + } 146 + 147 + impl<St, S: BosStr> CreateFollowBuilder<St, S> 148 + where 149 + St: create_follow_state::State, 150 + St::Subject: create_follow_state::IsUnset, 151 + { 152 + /// Set the `subject` field (required) 153 + pub fn subject( 154 + mut self, 155 + value: impl Into<Did<S>>, 156 + ) -> CreateFollowBuilder<create_follow_state::SetSubject<St>, S> { 157 + self._fields.0 = Option::Some(value.into()); 158 + CreateFollowBuilder { 159 + _state: PhantomData, 160 + _fields: self._fields, 161 + _type: PhantomData, 162 + } 163 + } 164 + } 165 + 166 + impl<St, S: BosStr> CreateFollowBuilder<St, S> 167 + where 168 + St: create_follow_state::State, 169 + St::Subject: create_follow_state::IsSet, 170 + { 171 + /// Build the final struct. 172 + pub fn build(self) -> CreateFollow<S> { 173 + CreateFollow { 174 + subject: self._fields.0.unwrap(), 175 + extra_data: Default::default(), 176 + } 177 + } 178 + /// Build the final struct with custom extra_data. 179 + pub fn build_with_data( 180 + self, 181 + extra_data: BTreeMap<SmolStr, Data<S>>, 182 + ) -> CreateFollow<S> { 183 + CreateFollow { 184 + subject: self._fields.0.unwrap(), 185 + extra_data: Some(extra_data), 186 + } 187 + } 188 + }
+72
crates/polymodel-api/src/space_polymodel/graph/delete_follow.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // Lexicon: space.polymodel.graph.deleteFollow 4 + // 5 + // This file was automatically generated from Lexicon schemas. 6 + // Any manual changes will be overwritten on the next regeneration. 7 + 8 + #[allow(unused_imports)] 9 + use alloc::collections::BTreeMap; 10 + 11 + #[allow(unused_imports)] 12 + use core::marker::PhantomData; 13 + use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; 14 + use jacquard_common::deps::smol_str::SmolStr; 15 + use jacquard_common::types::string::{Did, AtUri}; 16 + use jacquard_common::types::value::Data; 17 + use jacquard_derive::IntoStatic; 18 + use serde::{Serialize, Deserialize}; 19 + 20 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] 21 + #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 22 + pub struct DeleteFollow<S: BosStr = DefaultStr> { 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub subject: Option<Did<S>>, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub uri: Option<AtUri<S>>, 27 + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 28 + pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 29 + } 30 + 31 + 32 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 33 + #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 34 + pub struct DeleteFollowOutput<S: BosStr = DefaultStr> { 35 + pub deleted: bool, 36 + #[serde(skip_serializing_if = "Option::is_none")] 37 + pub uri: Option<AtUri<S>>, 38 + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 39 + pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 40 + } 41 + 42 + /** Response marker for the `space.polymodel.graph.deleteFollow` procedure. 43 + 44 + Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output<S>`, which is `DeleteFollowOutput<S>` for this endpoint.*/ 45 + pub struct DeleteFollowResponse; 46 + impl jacquard_common::xrpc::XrpcResp for DeleteFollowResponse { 47 + const NSID: &'static str = "space.polymodel.graph.deleteFollow"; 48 + const ENCODING: &'static str = "application/json"; 49 + type Output<S: BosStr> = DeleteFollowOutput<S>; 50 + type Err = jacquard_common::xrpc::GenericError; 51 + } 52 + 53 + impl<S: BosStr> jacquard_common::xrpc::XrpcRequest for DeleteFollow<S> { 54 + const NSID: &'static str = "space.polymodel.graph.deleteFollow"; 55 + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( 56 + "application/json", 57 + ); 58 + type Response = DeleteFollowResponse; 59 + } 60 + 61 + /** Endpoint marker for the `space.polymodel.graph.deleteFollow` procedure. 62 + 63 + Path: `/xrpc/space.polymodel.graph.deleteFollow`. The request payload type is `DeleteFollow<S>`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ 64 + pub struct DeleteFollowRequest; 65 + impl jacquard_common::xrpc::XrpcEndpoint for DeleteFollowRequest { 66 + const PATH: &'static str = "/xrpc/space.polymodel.graph.deleteFollow"; 67 + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( 68 + "application/json", 69 + ); 70 + type Request<S: BosStr> = DeleteFollow<S>; 71 + type Response = DeleteFollowResponse; 72 + }
+308
crates/polymodel-api/src/space_polymodel/graph/follow.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // Lexicon: space.polymodel.graph.follow 4 + // 5 + // This file was automatically generated from Lexicon schemas. 6 + // Any manual changes will be overwritten on the next regeneration. 7 + 8 + #[allow(unused_imports)] 9 + use alloc::collections::BTreeMap; 10 + 11 + #[allow(unused_imports)] 12 + use core::marker::PhantomData; 13 + use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; 14 + 15 + #[allow(unused_imports)] 16 + use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; 17 + use jacquard_common::deps::smol_str::SmolStr; 18 + use jacquard_common::types::collection::{Collection, RecordError}; 19 + use jacquard_common::types::string::{Did, AtUri, Cid, Datetime}; 20 + use jacquard_common::types::uri::{RecordUri, UriError}; 21 + use jacquard_common::types::value::Data; 22 + use jacquard_common::xrpc::XrpcResp; 23 + use jacquard_derive::{IntoStatic, lexicon}; 24 + use jacquard_lexicon::lexicon::LexiconDoc; 25 + use jacquard_lexicon::schema::LexiconSchema; 26 + 27 + #[allow(unused_imports)] 28 + use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; 29 + use serde::{Serialize, Deserialize}; 30 + /// Polymodel-native follow relationship from the record author to another actor DID. 31 + 32 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 33 + #[serde( 34 + rename_all = "camelCase", 35 + rename = "space.polymodel.graph.follow", 36 + tag = "$type", 37 + bound(deserialize = "S: Deserialize<'de> + BosStr") 38 + )] 39 + pub struct Follow<S: BosStr = DefaultStr> { 40 + pub created_at: Datetime, 41 + pub subject: Did<S>, 42 + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 43 + pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 44 + } 45 + 46 + /// Typed wrapper for GetRecord response with this collection's record type. 47 + 48 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 49 + #[serde(rename_all = "camelCase")] 50 + pub struct FollowGetRecordOutput<S: BosStr = DefaultStr> { 51 + #[serde(skip_serializing_if = "Option::is_none")] 52 + pub cid: Option<Cid<S>>, 53 + pub uri: AtUri<S>, 54 + pub value: Follow<S>, 55 + } 56 + 57 + impl<S: BosStr> Follow<S> { 58 + pub fn uri(uri: S) -> Result<RecordUri<S, FollowRecord>, UriError> { 59 + RecordUri::try_from_uri(AtUri::new(uri)?) 60 + } 61 + } 62 + 63 + /// Marker type for deserializing records from this collection. 64 + 65 + #[derive(Debug, Serialize, Deserialize)] 66 + pub struct FollowRecord; 67 + impl XrpcResp for FollowRecord { 68 + const NSID: &'static str = "space.polymodel.graph.follow"; 69 + const ENCODING: &'static str = "application/json"; 70 + type Output<S: BosStr> = FollowGetRecordOutput<S>; 71 + type Err = RecordError; 72 + } 73 + 74 + impl<S: BosStr> From<FollowGetRecordOutput<S>> for Follow<S> { 75 + fn from(output: FollowGetRecordOutput<S>) -> Self { 76 + output.value 77 + } 78 + } 79 + 80 + impl<S: BosStr> Collection for Follow<S> { 81 + const NSID: &'static str = "space.polymodel.graph.follow"; 82 + type Record = FollowRecord; 83 + } 84 + 85 + impl Collection for FollowRecord { 86 + const NSID: &'static str = "space.polymodel.graph.follow"; 87 + type Record = FollowRecord; 88 + } 89 + 90 + impl<S: BosStr> LexiconSchema for Follow<S> { 91 + fn nsid() -> &'static str { 92 + "space.polymodel.graph.follow" 93 + } 94 + fn def_name() -> &'static str { 95 + "main" 96 + } 97 + fn lexicon_doc() -> LexiconDoc<'static> { 98 + lexicon_doc_space_polymodel_graph_follow() 99 + } 100 + fn validate(&self) -> Result<(), ConstraintError> { 101 + Ok(()) 102 + } 103 + } 104 + 105 + pub mod follow_state { 106 + 107 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 108 + #[allow(unused)] 109 + use ::core::marker::PhantomData; 110 + mod sealed { 111 + pub trait Sealed {} 112 + } 113 + /// State trait tracking which required fields have been set 114 + pub trait State: sealed::Sealed { 115 + type CreatedAt; 116 + type Subject; 117 + } 118 + /// Empty state - all required fields are unset 119 + pub struct Empty(()); 120 + impl sealed::Sealed for Empty {} 121 + impl State for Empty { 122 + type CreatedAt = Unset; 123 + type Subject = Unset; 124 + } 125 + ///State transition - sets the `created_at` field to Set 126 + pub struct SetCreatedAt<St: State = Empty>(PhantomData<fn() -> St>); 127 + impl<St: State> sealed::Sealed for SetCreatedAt<St> {} 128 + impl<St: State> State for SetCreatedAt<St> { 129 + type CreatedAt = Set<members::created_at>; 130 + type Subject = St::Subject; 131 + } 132 + ///State transition - sets the `subject` field to Set 133 + pub struct SetSubject<St: State = Empty>(PhantomData<fn() -> St>); 134 + impl<St: State> sealed::Sealed for SetSubject<St> {} 135 + impl<St: State> State for SetSubject<St> { 136 + type CreatedAt = St::CreatedAt; 137 + type Subject = Set<members::subject>; 138 + } 139 + /// Marker types for field names 140 + #[allow(non_camel_case_types)] 141 + pub mod members { 142 + ///Marker type for the `created_at` field 143 + pub struct created_at(()); 144 + ///Marker type for the `subject` field 145 + pub struct subject(()); 146 + } 147 + } 148 + 149 + /// Builder for constructing an instance of this type. 150 + pub struct FollowBuilder<St: follow_state::State, S: BosStr = DefaultStr> { 151 + _state: PhantomData<fn() -> St>, 152 + _fields: (Option<Datetime>, Option<Did<S>>), 153 + _type: PhantomData<fn() -> S>, 154 + } 155 + 156 + impl Follow<DefaultStr> { 157 + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed 158 + pub fn new() -> FollowBuilder<follow_state::Empty, DefaultStr> { 159 + FollowBuilder::new() 160 + } 161 + } 162 + 163 + impl<S: BosStr> Follow<S> { 164 + /// Create a new builder for this type 165 + pub fn builder() -> FollowBuilder<follow_state::Empty, S> { 166 + FollowBuilder::builder() 167 + } 168 + } 169 + 170 + impl FollowBuilder<follow_state::Empty, DefaultStr> { 171 + /// Create a new builder with all fields unset, using the default string type, if needed 172 + pub fn new() -> Self { 173 + FollowBuilder { 174 + _state: PhantomData, 175 + _fields: (None, None), 176 + _type: PhantomData, 177 + } 178 + } 179 + } 180 + 181 + impl<S: BosStr> FollowBuilder<follow_state::Empty, S> { 182 + /// Create a new builder with all fields unset 183 + pub fn builder() -> Self { 184 + FollowBuilder { 185 + _state: PhantomData, 186 + _fields: (None, None), 187 + _type: PhantomData, 188 + } 189 + } 190 + } 191 + 192 + impl<St, S: BosStr> FollowBuilder<St, S> 193 + where 194 + St: follow_state::State, 195 + St::CreatedAt: follow_state::IsUnset, 196 + { 197 + /// Set the `createdAt` field (required) 198 + pub fn created_at( 199 + mut self, 200 + value: impl Into<Datetime>, 201 + ) -> FollowBuilder<follow_state::SetCreatedAt<St>, S> { 202 + self._fields.0 = Option::Some(value.into()); 203 + FollowBuilder { 204 + _state: PhantomData, 205 + _fields: self._fields, 206 + _type: PhantomData, 207 + } 208 + } 209 + } 210 + 211 + impl<St, S: BosStr> FollowBuilder<St, S> 212 + where 213 + St: follow_state::State, 214 + St::Subject: follow_state::IsUnset, 215 + { 216 + /// Set the `subject` field (required) 217 + pub fn subject( 218 + mut self, 219 + value: impl Into<Did<S>>, 220 + ) -> FollowBuilder<follow_state::SetSubject<St>, S> { 221 + self._fields.1 = Option::Some(value.into()); 222 + FollowBuilder { 223 + _state: PhantomData, 224 + _fields: self._fields, 225 + _type: PhantomData, 226 + } 227 + } 228 + } 229 + 230 + impl<St, S: BosStr> FollowBuilder<St, S> 231 + where 232 + St: follow_state::State, 233 + St::CreatedAt: follow_state::IsSet, 234 + St::Subject: follow_state::IsSet, 235 + { 236 + /// Build the final struct. 237 + pub fn build(self) -> Follow<S> { 238 + Follow { 239 + created_at: self._fields.0.unwrap(), 240 + subject: self._fields.1.unwrap(), 241 + extra_data: Default::default(), 242 + } 243 + } 244 + /// Build the final struct with custom extra_data. 245 + pub fn build_with_data(self, extra_data: BTreeMap<SmolStr, Data<S>>) -> Follow<S> { 246 + Follow { 247 + created_at: self._fields.0.unwrap(), 248 + subject: self._fields.1.unwrap(), 249 + extra_data: Some(extra_data), 250 + } 251 + } 252 + } 253 + 254 + fn lexicon_doc_space_polymodel_graph_follow() -> LexiconDoc<'static> { 255 + #[allow(unused_imports)] 256 + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; 257 + use jacquard_lexicon::lexicon::*; 258 + use alloc::collections::BTreeMap; 259 + LexiconDoc { 260 + lexicon: Lexicon::Lexicon1, 261 + id: CowStr::new_static("space.polymodel.graph.follow"), 262 + defs: { 263 + let mut map = BTreeMap::new(); 264 + map.insert( 265 + SmolStr::new_static("main"), 266 + LexUserType::Record(LexRecord { 267 + description: Some( 268 + CowStr::new_static( 269 + "Polymodel-native follow relationship from the record author to another actor DID.", 270 + ), 271 + ), 272 + key: Some(CowStr::new_static("tid")), 273 + record: LexRecordRecord::Object(LexObject { 274 + required: Some( 275 + vec![ 276 + SmolStr::new_static("subject"), 277 + SmolStr::new_static("createdAt") 278 + ], 279 + ), 280 + properties: { 281 + #[allow(unused_mut)] 282 + let mut map = BTreeMap::new(); 283 + map.insert( 284 + SmolStr::new_static("createdAt"), 285 + LexObjectProperty::String(LexString { 286 + format: Some(LexStringFormat::Datetime), 287 + ..Default::default() 288 + }), 289 + ); 290 + map.insert( 291 + SmolStr::new_static("subject"), 292 + LexObjectProperty::String(LexString { 293 + format: Some(LexStringFormat::Did), 294 + ..Default::default() 295 + }), 296 + ); 297 + map 298 + }, 299 + ..Default::default() 300 + }), 301 + ..Default::default() 302 + }), 303 + ); 304 + map 305 + }, 306 + ..Default::default() 307 + } 308 + }
+19 -1
lexicons/actor/defs.json
··· 4 4 "defs": { 5 5 "profileView": { 6 6 "type": "object", 7 - "description": "Polymodel actor profile view (union variant). Hydrated from the space.polymodel.actor.profile record plus SQLite-derived stats (thingCount, totalLikes). Returned when the actor authored a polymodel profile record; otherwise getProfile returns the app.bsky.actor.defs#profileViewDetailed variant.", 7 + "description": "Polymodel actor profile view (union variant). Hydrated from the space.polymodel.actor.profile record plus SQLite-derived stats and viewer relationship state. Returned when the actor authored a polymodel profile record; otherwise getProfile returns the app.bsky.actor.defs#profileViewDetailed variant.", 8 8 "required": ["did", "handle", "record"], 9 9 "properties": { 10 10 "did": { "type": "string", "format": "did" }, ··· 19 19 "links": { "type": "array", "items": { "type": "string", "format": "uri" } }, 20 20 "thingCount": { "type": "integer" }, 21 21 "totalLikes": { "type": "integer" }, 22 + "followerCount": { "type": "integer" }, 23 + "followingCount": { "type": "integer" }, 24 + "viewer": { "type": "ref", "ref": "space.polymodel.library.defs#actorViewerState" }, 25 + "lists": { "type": "array", "items": { "type": "ref", "ref": "space.polymodel.actor.defs#profileListView" } }, 22 26 "indexedAt": { "type": "string", "format": "datetime" } 27 + } 28 + }, 29 + "profileListView": { 30 + "type": "object", 31 + "description": "A list authored by the profile actor, with enough metadata for profile tabs and links into space.polymodel.graph.getList.", 32 + "required": ["uri", "cid", "name", "itemCount", "createdAt"], 33 + "properties": { 34 + "uri": { "type": "string", "format": "at-uri" }, 35 + "cid": { "type": "string", "format": "cid" }, 36 + "name": { "type": "string" }, 37 + "description": { "type": "string" }, 38 + "purpose": { "type": "string" }, 39 + "itemCount": { "type": "integer" }, 40 + "createdAt": { "type": "string", "format": "datetime" } 23 41 } 24 42 }, 25 43 "profileViewUnion": {
+32
lexicons/graph/createFollow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "space.polymodel.graph.createFollow", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a Polymodel-native follow record for the authenticated actor.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["subject"], 13 + "properties": { 14 + "subject": { "type": "string", "format": "did" } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["uri", "cid"], 23 + "properties": { 24 + "uri": { "type": "string", "format": "at-uri" }, 25 + "cid": { "type": "string", "format": "cid" }, 26 + "created": { "type": "boolean" } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+31
lexicons/graph/deleteFollow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "space.polymodel.graph.deleteFollow", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete the authenticated actor's Polymodel-native follow by record URI or subject DID.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "properties": { 13 + "subject": { "type": "string", "format": "did" }, 14 + "uri": { "type": "string", "format": "at-uri" } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["deleted"], 23 + "properties": { 24 + "deleted": { "type": "boolean" }, 25 + "uri": { "type": "string", "format": "at-uri" } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+19
lexicons/graph/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "space.polymodel.graph.follow", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Polymodel-native follow relationship from the record author to another actor DID.", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "subject": { "type": "string", "format": "did" }, 13 + "createdAt": { "type": "string", "format": "datetime" } 14 + }, 15 + "required": ["subject", "createdAt"] 16 + } 17 + } 18 + } 19 + }
+2 -2
lexicons/library/getFeed.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Recent/hot feed of things.", 7 + "description": "Recent, hot, or viewer-following feed of things.", 8 8 "parameters": { 9 9 "type": "params", 10 10 "properties": { 11 - "algorithm": { "type": "string", "knownValues": ["recent", "hot"] }, 11 + "algorithm": { "type": "string", "knownValues": ["recent", "hot", "following"] }, 12 12 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, 13 13 "cursor": { "type": "string" } 14 14 }
+12
migrations/007_follows.sql
··· 1 + CREATE TABLE follows ( 2 + follower_did TEXT NOT NULL, 3 + followed_did TEXT NOT NULL, 4 + rkey TEXT NOT NULL, 5 + cid TEXT NOT NULL, 6 + created_at INTEGER NOT NULL, 7 + PRIMARY KEY (follower_did, rkey), 8 + UNIQUE (follower_did, followed_did) 9 + ); 10 + 11 + CREATE INDEX idx_follows_followed ON follows(followed_did, created_at DESC); 12 + CREATE INDEX idx_follows_follower ON follows(follower_did, created_at DESC);
+15
migrations/008_lists.sql
··· 1 + CREATE TABLE lists ( 2 + did TEXT NOT NULL, 3 + rkey TEXT NOT NULL, 4 + uri TEXT NOT NULL UNIQUE, 5 + cid TEXT NOT NULL, 6 + name TEXT NOT NULL, 7 + description TEXT, 8 + purpose TEXT, 9 + created_at INTEGER NOT NULL, 10 + indexed_at INTEGER NOT NULL, 11 + record_json TEXT NOT NULL, 12 + PRIMARY KEY (did, rkey) 13 + ); 14 + 15 + CREATE INDEX idx_lists_did ON lists(did, created_at DESC);
+8
src/appview/actor.rs
··· 181 181 printers: None, 182 182 pronouns: None, 183 183 record: Data::Null, 184 + follower_count: None, 185 + following_count: None, 186 + lists: None, 184 187 thing_count: None, 185 188 total_likes: None, 189 + viewer: None, 186 190 extra_data: None, 187 191 }) 188 192 } ··· 203 207 printers: None, 204 208 pronouns: detailed.pronouns, 205 209 record: Data::Null, 210 + follower_count: None, 211 + following_count: None, 212 + lists: None, 206 213 thing_count: None, 207 214 total_likes: None, 215 + viewer: None, 208 216 extra_data: None, 209 217 } 210 218 }
+202
src/appview/downloads.rs
··· 1 + use axum::body::Body; 2 + use axum::extract::{Path, State}; 3 + use axum::http::{StatusCode, header}; 4 + use axum::response::Response; 5 + use jacquard_common::types::string::{Cid, Did}; 6 + use jacquard_common::xrpc::XrpcClient; 7 + use polymodel_api::com_atproto::sync::get_blob::GetBlob; 8 + use polymodel_api::space_polymodel::library::File; 9 + use sha2::{Digest, Sha256}; 10 + 11 + use super::error::{AppResult, db, internal, invalid_request, not_found}; 12 + use super::state::AppState; 13 + 14 + const MAX_DOWNLOAD_BYTES: i64 = 100 * 1024 * 1024; 15 + 16 + pub(super) async fn download_part( 17 + State(state): State<AppState>, 18 + Path((did, rkey)): Path<(String, String)>, 19 + ) -> AppResult<Response> { 20 + let did: Did = Did::new_owned(did).map_err(|e| invalid_request(format!("invalid DID: {e}")))?; 21 + let rkey = rkey.trim(); 22 + if rkey.is_empty() || rkey.contains('/') { 23 + return Err(invalid_request("invalid part rkey")); 24 + } 25 + 26 + #[derive(sqlx::FromRow)] 27 + struct PartDownloadRow { 28 + name: String, 29 + file_json: String, 30 + } 31 + 32 + let row = db(sqlx::query_as!( 33 + PartDownloadRow, 34 + r#"SELECT name AS "name!", file_json AS "file_json!" FROM parts WHERE did = ? AND rkey = ?"#, 35 + did.as_ref(), 36 + rkey, 37 + ) 38 + .fetch_optional(&state.pool) 39 + .await)? 40 + .ok_or_else(not_found)?; 41 + 42 + let file: File = serde_json::from_str(&row.file_json) 43 + .map_err(|e| internal(format!("part file manifest parse failed: {e}")))?; 44 + if file.chunks.is_empty() { 45 + return Err(not_found()); 46 + } 47 + validate_file_manifest(&file)?; 48 + 49 + let mut chunks = file.chunks; 50 + chunks.sort_by_key(|chunk| chunk.offset); 51 + let mut bytes = Vec::with_capacity(file.size as usize); 52 + for chunk in chunks { 53 + let cid: Cid = Cid::new_owned(chunk.blob.blob().r#ref.as_str().as_bytes()) 54 + .map_err(|e| internal(format!("part blob CID is invalid: {e}")))?; 55 + let response = state 56 + .bsky 57 + .send(GetBlob { 58 + did: did.clone(), 59 + cid, 60 + }) 61 + .await 62 + .map_err(|e| internal(format!("blob fetch failed: {e}")))?; 63 + let output = response 64 + .into_output() 65 + .map_err(|e| internal(format!("blob response decode failed: {e}")))?; 66 + if output.body.len() != chunk.size as usize { 67 + return Err(internal("blob chunk length did not match manifest")); 68 + } 69 + bytes.extend_from_slice(&output.body); 70 + } 71 + if bytes.len() != file.size as usize { 72 + return Err(internal("downloaded file length did not match manifest")); 73 + } 74 + if let Some(digest) = file.digest.as_ref() { 75 + let actual = hex_sha256(&bytes); 76 + let digest: &str = digest.as_ref(); 77 + let expected = digest.strip_prefix("sha256:").unwrap_or(digest); 78 + if actual != expected { 79 + return Err(internal("downloaded file digest did not match manifest")); 80 + } 81 + } 82 + 83 + let filename = download_filename(&row.name); 84 + let content_type = file.mime_type.to_string(); 85 + Response::builder() 86 + .status(StatusCode::OK) 87 + .header(header::CONTENT_TYPE, content_type) 88 + .header( 89 + header::CONTENT_DISPOSITION, 90 + format!("attachment; filename=\"{filename}\""), 91 + ) 92 + .body(Body::from(bytes)) 93 + .map_err(|e| internal(format!("download response build failed: {e}"))) 94 + } 95 + 96 + fn validate_file_manifest(file: &File) -> AppResult<()> { 97 + if file.size < 0 || file.size > MAX_DOWNLOAD_BYTES { 98 + return Err(invalid_request( 99 + "part file size is outside the download limit", 100 + )); 101 + } 102 + if file.chunks.len() > 4096 { 103 + return Err(invalid_request("part file has too many chunks")); 104 + } 105 + let mut chunks = file.chunks.iter().collect::<Vec<_>>(); 106 + chunks.sort_by_key(|chunk| chunk.offset); 107 + let mut expected_offset = 0_i64; 108 + for chunk in chunks { 109 + if chunk.offset != expected_offset || chunk.size < 0 { 110 + return Err(invalid_request("part file chunks are not contiguous")); 111 + } 112 + expected_offset = expected_offset 113 + .checked_add(chunk.size) 114 + .ok_or_else(|| invalid_request("part file chunk sizes overflow"))?; 115 + if expected_offset > MAX_DOWNLOAD_BYTES { 116 + return Err(invalid_request( 117 + "part file size is outside the download limit", 118 + )); 119 + } 120 + } 121 + if expected_offset != file.size { 122 + return Err(invalid_request("part file chunks do not match file size")); 123 + } 124 + Ok(()) 125 + } 126 + 127 + fn hex_sha256(bytes: &[u8]) -> String { 128 + Sha256::digest(bytes) 129 + .iter() 130 + .map(|byte| format!("{byte:02x}")) 131 + .collect() 132 + } 133 + 134 + fn download_filename(name: &str) -> String { 135 + let cleaned = name 136 + .chars() 137 + .map(|ch| match ch { 138 + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => ch, 139 + _ => '_', 140 + }) 141 + .collect::<String>(); 142 + if cleaned.trim_matches('_').is_empty() { 143 + "polymodel-part.bin".to_string() 144 + } else { 145 + cleaned 146 + } 147 + } 148 + 149 + #[cfg(test)] 150 + mod tests { 151 + use super::{download_filename, hex_sha256, validate_file_manifest}; 152 + use jacquard_common::deps::smol_str::SmolStr; 153 + use jacquard_common::types::blob::{Blob, BlobRef, MimeType}; 154 + use jacquard_common::types::cid::CidLink; 155 + use polymodel_api::space_polymodel::library::{Chunk, File}; 156 + 157 + fn blob_ref() -> BlobRef { 158 + BlobRef::from(Blob { 159 + r#ref: CidLink::new_owned( 160 + b"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", 161 + ) 162 + .unwrap(), 163 + mime_type: MimeType::new_owned("model/stl"), 164 + size: 4, 165 + }) 166 + } 167 + 168 + fn file(chunks: Vec<Chunk>, size: i64) -> File { 169 + File { 170 + chunks, 171 + digest: None, 172 + mime_type: SmolStr::from("model/stl"), 173 + size, 174 + extra_data: None, 175 + } 176 + } 177 + 178 + #[test] 179 + fn download_filename_removes_header_unsafe_characters() { 180 + assert_eq!(download_filename("gear bracket.stl"), "gear_bracket.stl"); 181 + assert_eq!(download_filename("../../x\r\n"), ".._.._x__"); 182 + assert_eq!(download_filename("!!!"), "polymodel-part.bin"); 183 + } 184 + 185 + #[test] 186 + fn file_manifest_validation_requires_contiguous_bounded_chunks() { 187 + let chunk = Chunk::builder().blob(blob_ref()).offset(0).size(4).build(); 188 + assert!(validate_file_manifest(&file(vec![chunk.clone()], 4)).is_ok()); 189 + 190 + let gapped = Chunk::builder().blob(blob_ref()).offset(2).size(4).build(); 191 + assert!(validate_file_manifest(&file(vec![gapped], 6)).is_err()); 192 + assert!(validate_file_manifest(&file(vec![chunk], 101 * 1024 * 1024)).is_err()); 193 + } 194 + 195 + #[test] 196 + fn hex_sha256_matches_known_digest() { 197 + assert_eq!( 198 + hex_sha256(b"solid cube"), 199 + "d3a15aa3cd30cc79123d6a50d2809ed794a452e67fa857bbc7ac343cbfca9971" 200 + ); 201 + } 202 + }
+4
src/appview/library.rs
··· 100 100 let feed = match algorithm { 101 101 "recent" => views::feed_recent(&state, limit, cursor, viewer).await?, 102 102 "hot" => views::feed_hot(&state, limit, cursor, viewer).await?, 103 + "following" => match viewer { 104 + Some(viewer) => views::feed_following(&state, viewer, limit, cursor).await?, 105 + None => views::empty_feed(), 106 + }, 103 107 other => return Err(invalid_request(format!("unknown algorithm: {other}"))), 104 108 }; 105 109 Ok(XrpcResponse(GetFeedOutput {
+10 -2
src/appview/mod.rs
··· 20 20 pub mod views; 21 21 22 22 mod actor; 23 + mod downloads; 23 24 mod drafts; 24 25 mod graph; 25 26 mod library; ··· 38 39 get_session::GetSessionRequest, 39 40 }, 40 41 graph::{ 41 - create_like::CreateLikeRequest, create_save::CreateSaveRequest, 42 - create_tag::CreateTagRequest, delete_like::DeleteLikeRequest, 42 + create_follow::CreateFollowRequest, create_like::CreateLikeRequest, 43 + create_save::CreateSaveRequest, create_tag::CreateTagRequest, 44 + delete_follow::DeleteFollowRequest, delete_like::DeleteLikeRequest, 43 45 delete_save::DeleteSaveRequest, delete_tag::DeleteTagRequest, get_list::GetListRequest, 44 46 }, 45 47 library::{ ··· 84 86 .merge(PublishThingRequest::into_router(writes::publish_thing)) 85 87 .merge(UpdateThingRequest::into_router(writes::update_thing)) 86 88 .merge(DeleteThingRequest::into_router(writes::delete_thing)) 89 + .merge(CreateFollowRequest::into_router(writes::create_follow)) 90 + .merge(DeleteFollowRequest::into_router(writes::delete_follow)) 87 91 .merge(CreateLikeRequest::into_router(writes::create_like)) 88 92 .merge(DeleteLikeRequest::into_router(writes::delete_like)) 89 93 .merge(CreateSaveRequest::into_router(writes::create_save)) ··· 115 119 .route( 116 120 GetSessionRequest::PATH, 117 121 axum::routing::get(actor::get_session), 122 + ) 123 + .route( 124 + "/app/parts/{did}/{rkey}/download", 125 + axum::routing::get(downloads::download_part), 118 126 ) 119 127 // PM-43 app-internal draft store + image upload. Not federated lexicons, 120 128 // so these use a plain `/app/*` namespace (cookie-authenticated,
+154
src/appview/tests.rs
··· 314 314 .unwrap(); 315 315 } 316 316 317 + async fn seed_list(pool: &SqlitePool, did: &str, rkey: &str, name: &str) -> String { 318 + let uri = format!("at://{did}/space.polymodel.graph.list/{rkey}"); 319 + let now = chrono::Utc::now() 320 + .timestamp_nanos_opt() 321 + .expect("current timestamp must fit in i64 nanoseconds"); 322 + sqlx::query( 323 + r#"INSERT INTO lists (did, rkey, uri, cid, name, description, purpose, created_at, indexed_at, record_json) 324 + VALUES (?, ?, ?, ?, ?, 'Print farm staples', 'curation', ?, ?, ?)"#, 325 + ) 326 + .bind(did) 327 + .bind(rkey) 328 + .bind(&uri) 329 + .bind("bafyreilistcid") 330 + .bind(name) 331 + .bind(now) 332 + .bind(now) 333 + .bind(json!({ 334 + "name": name, 335 + "description": "Print farm staples", 336 + "purpose": "curation", 337 + "createdAt": "2024-01-01T00:00:00.000Z" 338 + }).to_string()) 339 + .execute(pool) 340 + .await 341 + .unwrap(); 342 + uri 343 + } 344 + 317 345 #[tokio::test] 318 346 async fn get_thing_hydrates_thing_with_ordered_models_and_record() { 319 347 let state = state().await; ··· 672 700 } 673 701 674 702 #[tokio::test] 703 + async fn list_pagination_filters_non_things_before_windowing() { 704 + let state = state().await; 705 + seed_identity(&state.pool, DID_A, "alice.com").await; 706 + let t1 = seed_thing(&state.pool, DID_A, "t1", "Thing one", &[], 0).await; 707 + let t2 = seed_thing(&state.pool, DID_A, "t2", "Thing two", &[], 0).await; 708 + let list = "at://did:plc:l/space.polymodel.graph.list/mixed"; 709 + let non_thing = "at://did:plc:x/space.polymodel.graph.like/k1"; 710 + for (rkey, subject, created_at) in [ 711 + ("i1", non_thing, 1_i64), 712 + ("i2", t1.as_str(), 2_i64), 713 + ("i3", non_thing, 3_i64), 714 + ("i4", t2.as_str(), 4_i64), 715 + ] { 716 + sqlx::query("INSERT INTO listitems (did, rkey, cid, list_uri, subject_uri, created_at) VALUES (?, ?, ?, ?, ?, ?)") 717 + .bind(DID_A) 718 + .bind(rkey) 719 + .bind(format!("cid-{rkey}")) 720 + .bind(list) 721 + .bind(subject) 722 + .bind(created_at) 723 + .execute(&state.pool) 724 + .await 725 + .unwrap(); 726 + } 727 + 728 + let (page1, cursor) = views::list_things(&state, list, 1, None, None) 729 + .await 730 + .unwrap(); 731 + assert_eq!(page1.len(), 1); 732 + assert_eq!(page1[0].uri.as_ref(), t1); 733 + let cursor = cursor.expect("second thing should remain"); 734 + 735 + let (page2, cursor) = views::list_things(&state, list, 1, Some(cursor.as_ref()), None) 736 + .await 737 + .unwrap(); 738 + assert_eq!(page2.len(), 1); 739 + assert_eq!(page2[0].uri.as_ref(), t2); 740 + assert!(cursor.is_none()); 741 + } 742 + 743 + #[tokio::test] 675 744 async fn polymodel_profile_variant_carries_stats() { 676 745 let state = state().await; 677 746 seed_identity(&state.pool, DID_A, "alice.com").await; ··· 698 767 } 699 768 700 769 #[tokio::test] 770 + async fn polymodel_profile_variant_carries_lists() { 771 + let state = state().await; 772 + seed_identity(&state.pool, DID_A, "alice.com").await; 773 + seed_profile(&state.pool, DID_A, "Alice").await; 774 + let list = seed_list(&state.pool, DID_A, "fixtures", "Fixture jigs").await; 775 + let thing = seed_thing(&state.pool, DID_A, "t1", "Thing One", &[], 0).await; 776 + sqlx::query("INSERT INTO listitems (did, rkey, cid, list_uri, subject_uri, created_at) VALUES (?, ?, ?, ?, ?, ?)") 777 + .bind(DID_A) 778 + .bind("li1") 779 + .bind("bafyreilistitem") 780 + .bind(&list) 781 + .bind(&thing) 782 + .bind(1_i64) 783 + .execute(&state.pool) 784 + .await 785 + .unwrap(); 786 + 787 + let view = views::polymodel_profile_view(&state, &did(DID_A)) 788 + .await 789 + .unwrap() 790 + .expect("polymodel profile exists"); 791 + let lists = view.lists.expect("profile lists should be populated"); 792 + assert_eq!(lists.len(), 1); 793 + assert_eq!(lists[0].uri.as_ref(), list); 794 + assert_eq!(lists[0].name.as_str(), "Fixture jigs"); 795 + assert_eq!( 796 + lists[0].description.as_ref().map(|s| s.as_str()), 797 + Some("Print farm staples") 798 + ); 799 + assert_eq!( 800 + lists[0].purpose.as_ref().map(|s| s.as_str()), 801 + Some("curation") 802 + ); 803 + assert_eq!(lists[0].item_count, 1); 804 + } 805 + 806 + #[tokio::test] 701 807 async fn polymodel_profile_absent_returns_none() { 702 808 let state = state().await; 703 809 let view = views::polymodel_profile_view(&state, &did(DID_B)) ··· 946 1052 assert_eq!(page2.items.len(), 1); 947 1053 assert_eq!(page2.items[0].thing.name.as_str(), "t1"); 948 1054 assert!(page2.cursor.is_none()); 1055 + } 1056 + 1057 + #[tokio::test] 1058 + async fn feed_following_cursor_handles_same_rkey_across_followed_repos() { 1059 + let state = state().await; 1060 + seed_identity(&state.pool, DID_A, "alice.com").await; 1061 + seed_identity(&state.pool, DID_B, "bob.com").await; 1062 + seed_identity(&state.pool, "did:plc:viewer", "viewer.com").await; 1063 + seed_thing(&state.pool, DID_A, "3sameeeeeeeee", "Alice thing", &[], 0).await; 1064 + seed_thing(&state.pool, DID_B, "3sameeeeeeeee", "Bob thing", &[], 0).await; 1065 + sqlx::query("INSERT INTO follows (follower_did, followed_did, rkey, cid, created_at) VALUES (?, ?, ?, ?, ?)") 1066 + .bind("did:plc:viewer") 1067 + .bind(DID_A) 1068 + .bind("fa") 1069 + .bind("cid-a") 1070 + .bind(1_i64) 1071 + .execute(&state.pool) 1072 + .await 1073 + .unwrap(); 1074 + sqlx::query("INSERT INTO follows (follower_did, followed_did, rkey, cid, created_at) VALUES (?, ?, ?, ?, ?)") 1075 + .bind("did:plc:viewer") 1076 + .bind(DID_B) 1077 + .bind("fb") 1078 + .bind("cid-b") 1079 + .bind(2_i64) 1080 + .execute(&state.pool) 1081 + .await 1082 + .unwrap(); 1083 + 1084 + let viewer = did("did:plc:viewer"); 1085 + let page1 = views::feed_following(&state, viewer.borrow(), 1, None) 1086 + .await 1087 + .unwrap(); 1088 + let cursor = page1.cursor.as_deref().expect("first row has another page"); 1089 + assert!(cursor.starts_with("following:v1:")); 1090 + let page2 = views::feed_following(&state, viewer.borrow(), 1, Some(cursor)) 1091 + .await 1092 + .unwrap(); 1093 + 1094 + let names = page1 1095 + .items 1096 + .into_iter() 1097 + .chain(page2.items.into_iter()) 1098 + .map(|item| item.thing.name.to_string()) 1099 + .collect::<std::collections::HashSet<_>>(); 1100 + assert_eq!(names.len(), 2); 1101 + assert!(names.contains("Alice thing")); 1102 + assert!(names.contains("Bob thing")); 949 1103 } 950 1104 951 1105 // ---------------------------------------------------------------------------
+247 -21
src/appview/views.rs
··· 26 26 use jacquard_common::deps::smol_str::SmolStr; 27 27 use jacquard_common::types::string::{AtUri, Datetime, Did, Handle, UriValue}; 28 28 use jacquard_common::types::{blob::BlobRef, value::Data}; 29 + use polymodel_api::space_polymodel::actor::ProfileListView; 29 30 use polymodel_api::space_polymodel::library::{ 30 - Actor, FeedItem, FeedView, File, Image, ImageView, ModelView, PartView, ThingView, 31 - ThingViewBasic, ViewerState, model::Model, part::Part, thing::Thing, 31 + Actor, ActorViewerState, FeedItem, FeedView, File, Image, ImageView, ModelView, PartView, 32 + ThingView, ThingViewBasic, ViewerState, model::Model, part::Part, thing::Thing, 32 33 }; 33 34 use sqlx::SqlitePool; 34 35 ··· 249 250 Some(h) => h, 250 251 None => resolve_handle(state, &did_typed).await?, 251 252 }; 253 + let stats = actor_follow_stats(&state.pool, did, None).await?; 252 254 Ok(Actor { 253 255 did: did_typed, 254 256 handle: handle_of(handle)?, 255 257 display_name: display_name.map(s), 256 258 avatar: avatar_url(did, avatar_json.as_deref())?, 257 259 description: None, 258 - follower_count: None, 259 - following_count: None, 260 + follower_count: Some(stats.follower_count), 261 + following_count: Some(stats.following_count), 260 262 pronouns: None, 261 263 thing_count: None, 262 264 viewer: None, ··· 264 266 }) 265 267 } 266 268 269 + struct ActorFollowStats { 270 + follower_count: i64, 271 + following_count: i64, 272 + viewer: ActorViewerState, 273 + } 274 + 275 + async fn actor_follow_stats( 276 + pool: &SqlitePool, 277 + actor_did: &str, 278 + viewer_did: Option<Did<&str>>, 279 + ) -> AppResult<ActorFollowStats> { 280 + #[derive(sqlx::FromRow)] 281 + struct CountRow { 282 + count: i64, 283 + } 284 + let follower_count = db(sqlx::query_as!( 285 + CountRow, 286 + r#"SELECT COUNT(*) AS "count!: i64" FROM follows WHERE followed_did = ?"#, 287 + actor_did, 288 + ) 289 + .fetch_one(pool) 290 + .await)? 291 + .count; 292 + let following_count = db(sqlx::query_as!( 293 + CountRow, 294 + r#"SELECT COUNT(*) AS "count!: i64" FROM follows WHERE follower_did = ?"#, 295 + actor_did, 296 + ) 297 + .fetch_one(pool) 298 + .await)? 299 + .count; 300 + 301 + let Some(viewer_did) = viewer_did else { 302 + return Ok(ActorFollowStats { 303 + follower_count, 304 + following_count, 305 + viewer: ActorViewerState::default(), 306 + }); 307 + }; 308 + let viewer_did = viewer_did.as_str(); 309 + #[derive(sqlx::FromRow)] 310 + struct RkeyRow { 311 + rkey: String, 312 + } 313 + let following = db(sqlx::query_as!( 314 + RkeyRow, 315 + "SELECT rkey FROM follows WHERE follower_did = ? AND followed_did = ?", 316 + viewer_did, 317 + actor_did, 318 + ) 319 + .fetch_optional(pool) 320 + .await)?; 321 + let followed_by = db(sqlx::query_as!( 322 + RkeyRow, 323 + "SELECT rkey FROM follows WHERE follower_did = ? AND followed_did = ?", 324 + actor_did, 325 + viewer_did, 326 + ) 327 + .fetch_optional(pool) 328 + .await)?; 329 + let following = following 330 + .map(|row| { 331 + at_uri(&format!( 332 + "at://{viewer_did}/space.polymodel.graph.follow/{}", 333 + row.rkey 334 + )) 335 + }) 336 + .transpose()?; 337 + let followed_by = followed_by 338 + .map(|row| { 339 + at_uri(&format!( 340 + "at://{actor_did}/space.polymodel.graph.follow/{}", 341 + row.rkey 342 + )) 343 + }) 344 + .transpose()?; 345 + 346 + Ok(ActorFollowStats { 347 + follower_count, 348 + following_count, 349 + viewer: ActorViewerState { 350 + following, 351 + followed_by, 352 + extra_data: None, 353 + ..ActorViewerState::default() 354 + }, 355 + }) 356 + } 357 + 267 358 async fn viewer_state( 268 359 pool: &SqlitePool, 269 360 viewer_did: Option<Did<&str>>, ··· 586 677 }) 587 678 } 588 679 680 + pub(super) fn empty_feed() -> FeedView { 681 + FeedView { 682 + cursor: None, 683 + items: Vec::new(), 684 + extra_data: None, 685 + } 686 + } 687 + 589 688 pub(super) async fn author_things( 590 689 state: &AppState, 591 690 did: &str, ··· 648 747 rkey_paged_feed(state, rows, limit, viewer_did).await 649 748 } 650 749 750 + pub(crate) async fn feed_following( 751 + state: &AppState, 752 + viewer_did: Did<&str>, 753 + limit: i64, 754 + cursor: Option<&str>, 755 + ) -> AppResult<FeedView> { 756 + let cursor = parse_following_cursor(cursor)?; 757 + let fetch = limit + 1; 758 + let rows = db(sqlx::query_as!( 759 + ThingRow, 760 + r#"SELECT t.did AS "did!", t.rkey AS "rkey!", t.uri AS "uri!", t.cid AS "cid!", 761 + t.name AS "name!", t.summary, t.license AS "license!", t.tags_json, t.cover_json, 762 + t.derived_from_uri, t.created_at AS "created_at!", t.indexed_at AS "indexed_at!", 763 + t.record_json, 764 + COALESCE(cs.like_count, 0) AS "like_count!: i64", 765 + COALESCE(cs.save_count, 0) AS "save_count!: i64", 766 + (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS "model_count!: i64", 767 + (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS "part_count!: i64" 768 + FROM things t 769 + JOIN follows f ON f.followed_did = t.did 770 + LEFT JOIN content_stats cs ON cs.uri = t.uri 771 + WHERE f.follower_did = ? 772 + AND (? IS NULL OR t.rkey < ? OR (t.rkey = ? AND t.did < ?)) 773 + ORDER BY t.rkey DESC, t.did DESC LIMIT ?"#, 774 + viewer_did.as_str(), 775 + cursor.as_ref().map(|c| c.rkey.as_str()), 776 + cursor.as_ref().map(|c| c.rkey.as_str()), 777 + cursor.as_ref().map(|c| c.rkey.as_str()), 778 + cursor.as_ref().map(|c| c.did.as_str()), 779 + fetch, 780 + ) 781 + .fetch_all(&state.pool) 782 + .await)?; 783 + compound_paged_feed(state, rows, limit, Some(viewer_did)).await 784 + } 785 + 786 + #[derive(Clone, Debug, PartialEq, Eq)] 787 + struct FollowingCursor { 788 + rkey: String, 789 + did: String, 790 + } 791 + 792 + fn parse_following_cursor(cursor: Option<&str>) -> AppResult<Option<FollowingCursor>> { 793 + let Some(cursor) = cursor else { 794 + return Ok(None); 795 + }; 796 + let Some(rest) = cursor.strip_prefix("following:v1:") else { 797 + return Err(invalid_request("invalid following cursor")); 798 + }; 799 + let Some((rkey, did)) = rest.split_once(':') else { 800 + return Err(invalid_request("invalid following cursor")); 801 + }; 802 + if rkey.is_empty() || did.is_empty() { 803 + return Err(invalid_request("invalid following cursor")); 804 + } 805 + Ok(Some(FollowingCursor { 806 + rkey: rkey.to_string(), 807 + did: did.to_string(), 808 + })) 809 + } 810 + 811 + fn following_cursor(row: &ThingRow) -> String { 812 + format!("following:v1:{}:{}", row.rkey, row.did) 813 + } 814 + 651 815 /// Reduce an over-fetched (`limit+1`) rkey-ordered page into a feed, returning 652 816 /// the last *returned* item's `rkey` as the cursor when another page exists. 653 817 /// Using the last returned item (not the over-fetched boundary) keeps ··· 660 824 ) -> AppResult<FeedView> { 661 825 let cursor = if rows.len() as i64 > limit { 662 826 rows.get((limit - 1) as usize).map(|r| r.rkey.clone()) 827 + } else { 828 + None 829 + }; 830 + rows.truncate(limit as usize); 831 + feed_view(state, rows, cursor, viewer_did).await 832 + } 833 + 834 + async fn compound_paged_feed( 835 + state: &AppState, 836 + mut rows: Vec<ThingRow>, 837 + limit: i64, 838 + viewer_did: Option<Did<&str>>, 839 + ) -> AppResult<FeedView> { 840 + let cursor = if rows.len() as i64 > limit { 841 + rows.get((limit - 1) as usize).map(following_cursor) 663 842 } else { 664 843 None 665 844 }; ··· 1006 1185 viewer_did: Option<Did<&str>>, 1007 1186 ) -> AppResult<(Vec<ThingViewBasic>, Option<String>)> { 1008 1187 let offset = parse_offset(cursor)?; 1009 - // Over-fetch subjects, then hydrate only those resolving to a thing; non-thing 1010 - // subjects are filtered out (listitems.subject_uri may reference any record). 1011 1188 let fetch = limit + 1; 1012 1189 #[derive(sqlx::FromRow)] 1013 1190 struct ListItemRow { 1014 1191 subject_uri: String, 1015 1192 } 1016 - let rows = db( 1017 - sqlx::query_as!( 1018 - ListItemRow, 1019 - r#"SELECT subject_uri FROM listitems WHERE list_uri = ? ORDER BY created_at, did, rkey LIMIT ? OFFSET ?"#, 1020 - list_uri, 1021 - fetch, 1022 - offset, 1023 - ) 1024 - .fetch_all(&state.pool) 1025 - .await, 1026 - )?; 1027 - // Another page exists when the raw subject over-fetch exceeded the limit, 1028 - // independent of how many subjects hydrated to things. 1193 + let rows = db(sqlx::query_as!( 1194 + ListItemRow, 1195 + r#"SELECT li.subject_uri AS "subject_uri!" 1196 + FROM listitems li 1197 + JOIN things t ON t.uri = li.subject_uri 1198 + WHERE li.list_uri = ? 1199 + ORDER BY li.created_at, li.did, li.rkey 1200 + LIMIT ? OFFSET ?"#, 1201 + list_uri, 1202 + fetch, 1203 + offset, 1204 + ) 1205 + .fetch_all(&state.pool) 1206 + .await)?; 1029 1207 let cursor = if rows.len() as i64 > limit { 1030 1208 Some((offset + limit).to_string()) 1031 1209 } else { ··· 1035 1213 for li in rows.into_iter().take(limit as usize) { 1036 1214 if let Some(thing) = thing_basic_by_uri(state, &li.subject_uri, viewer_did.clone()).await? { 1037 1215 items.push(thing); 1038 - } else { 1039 - tracing::debug!(subject_uri = %li.subject_uri, "list item is not a thing; skipped"); 1040 1216 } 1041 1217 } 1042 1218 Ok((items, cursor)) ··· 1062 1238 pub total_likes: i64, 1063 1239 } 1064 1240 1241 + #[derive(sqlx::FromRow)] 1242 + struct ProfileListRow { 1243 + uri: String, 1244 + cid: String, 1245 + name: String, 1246 + description: Option<String>, 1247 + purpose: Option<String>, 1248 + created_at: i64, 1249 + item_count: i64, 1250 + } 1251 + 1065 1252 async fn profile_for_did(state: &AppState, did: &str) -> AppResult<Option<ProfileRow>> { 1066 1253 let row = db(sqlx::query_as!( 1067 1254 ProfileRow, ··· 1101 1288 }) 1102 1289 } 1103 1290 1291 + async fn profile_lists(state: &AppState, did: &str) -> AppResult<Vec<ProfileListView>> { 1292 + let rows = db(sqlx::query_as!( 1293 + ProfileListRow, 1294 + r#"SELECT l.uri AS "uri!", l.cid AS "cid!", l.name AS "name!", 1295 + l.description, l.purpose, l.created_at AS "created_at!", 1296 + COUNT(li.subject_uri) AS "item_count!: i64" 1297 + FROM lists l 1298 + LEFT JOIN listitems li ON li.list_uri = l.uri 1299 + WHERE l.did = ? 1300 + GROUP BY l.uri, l.cid, l.name, l.description, l.purpose, l.created_at 1301 + ORDER BY l.created_at DESC, l.rkey DESC"#, 1302 + did, 1303 + ) 1304 + .fetch_all(&state.pool) 1305 + .await)?; 1306 + 1307 + rows.into_iter() 1308 + .map(|row| { 1309 + Ok(ProfileListView { 1310 + uri: at_uri(&row.uri)?, 1311 + cid: jacquard_common::types::string::Cid::new_owned(row.cid.as_bytes()) 1312 + .map_err(|e| internal(format!("invalid list cid: {e}")))?, 1313 + name: s(row.name), 1314 + description: row.description.map(s), 1315 + purpose: row.purpose.map(s), 1316 + item_count: row.item_count, 1317 + created_at: datetime_nanos(row.created_at), 1318 + extra_data: None, 1319 + }) 1320 + }) 1321 + .collect() 1322 + } 1323 + 1104 1324 pub(super) async fn handle_for_did(state: &AppState, did: &Did) -> AppResult<Option<String>> { 1105 1325 let did = did.as_str(); 1106 1326 #[derive(sqlx::FromRow)] ··· 1148 1368 return Ok(None); 1149 1369 }; 1150 1370 let stats = profile_stats(state, did.as_ref()).await?; 1371 + let follows = actor_follow_stats(&state.pool, did.as_ref(), None).await?; 1372 + let lists = profile_lists(state, did.as_ref()).await?; 1151 1373 let handle_str = resolve_handle(state, did).await?; 1152 1374 let record: Data = serde_json::from_str(&row.record_json) 1153 1375 .map_err(|e| internal(format!("profile record_json parse: {e}")))?; ··· 1164 1386 links: uri_vec(row.links_json.as_deref()), 1165 1387 thing_count: Some(stats.thing_count), 1166 1388 total_likes: Some(stats.total_likes), 1389 + follower_count: Some(follows.follower_count), 1390 + following_count: Some(follows.following_count), 1391 + viewer: Some(follows.viewer), 1392 + lists: if lists.is_empty() { None } else { Some(lists) }, 1167 1393 indexed_at: Some(datetime_nanos(row.indexed_at)), 1168 1394 extra_data: None, 1169 1395 }))
+125
src/appview/writes.rs
··· 30 30 BootstrapProfileOutput, BootstrapProfileOutputStatus, BootstrapProfileRequest, 31 31 }; 32 32 use polymodel_api::space_polymodel::actor::profile::Profile as PolymodelProfile; 33 + use polymodel_api::space_polymodel::graph::create_follow::{ 34 + CreateFollowOutput, CreateFollowRequest, 35 + }; 33 36 use polymodel_api::space_polymodel::graph::create_like::{CreateLikeOutput, CreateLikeRequest}; 34 37 use polymodel_api::space_polymodel::graph::create_save::{CreateSaveOutput, CreateSaveRequest}; 35 38 use polymodel_api::space_polymodel::graph::create_tag::{CreateTagOutput, CreateTagRequest}; 39 + use polymodel_api::space_polymodel::graph::delete_follow::{ 40 + DeleteFollowOutput, DeleteFollowRequest, 41 + }; 36 42 use polymodel_api::space_polymodel::graph::delete_like::{DeleteLikeOutput, DeleteLikeRequest}; 37 43 use polymodel_api::space_polymodel::graph::delete_save::{DeleteSaveOutput, DeleteSaveRequest}; 38 44 use polymodel_api::space_polymodel::graph::delete_tag::{DeleteTagOutput, DeleteTagRequest}; 45 + use polymodel_api::space_polymodel::graph::follow::Follow; 39 46 use polymodel_api::space_polymodel::graph::like::Like; 40 47 use polymodel_api::space_polymodel::graph::save::Save; 41 48 use polymodel_api::space_polymodel::graph::tag::Tag; ··· 66 73 const THING_COLLECTION: &str = "space.polymodel.library.thing"; 67 74 const MODEL_COLLECTION: &str = "space.polymodel.library.model"; 68 75 const PART_COLLECTION: &str = "space.polymodel.library.part"; 76 + const FOLLOW_COLLECTION: &str = "space.polymodel.graph.follow"; 69 77 const LIKE_COLLECTION: &str = "space.polymodel.graph.like"; 70 78 const SAVE_COLLECTION: &str = "space.polymodel.graph.save"; 71 79 const TAG_COLLECTION: &str = "space.polymodel.graph.tag"; ··· 241 249 cid: out.cid, 242 250 created: Some(true), 243 251 uri: out.uri, 252 + extra_data: None, 253 + })) 254 + } 255 + 256 + pub(super) async fn create_follow( 257 + State(state): State<AppState>, 258 + ExtractOAuthSession(session): ExtractOAuthSession<PublicResolver, SqliteAuthStore>, 259 + ExtractXrpc(req): ExtractXrpc<CreateFollowRequest>, 260 + ) -> AppResult<XrpcResponse<CreateFollowRequest>> { 261 + let agent = Agent::from(session); 262 + let actor = authenticated_did(&agent).await?; 263 + let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?; 264 + if actor == req.subject { 265 + return Err(invalid_request("cannot follow yourself")); 266 + } 267 + ensure_follow_subject(&state, &req.subject).await?; 268 + if let Some(existing) = lookup_existing_follow(&state, &actor, &req.subject).await? { 269 + return Ok(XrpcResponse(CreateFollowOutput { 270 + cid: existing.cid, 271 + created: Some(false), 272 + uri: existing.uri, 273 + extra_data: None, 274 + })); 275 + } 276 + let record = Follow { 277 + created_at: Datetime::now(), 278 + subject: req.subject, 279 + extra_data: None, 280 + }; 281 + let out = agent 282 + .create_record(record.clone(), None) 283 + .await 284 + .map_err(|e| agent_error("create follow", e))?; 285 + let rkey = rkey_from_uri(&out.uri)?; 286 + eager_project_record( 287 + &state, 288 + &actor, 289 + FOLLOW_COLLECTION, 290 + rkey.as_ref(), 291 + Some(out.cid.clone()), 292 + &record, 293 + "create", 294 + ) 295 + .await?; 296 + Ok(XrpcResponse(CreateFollowOutput { 297 + cid: out.cid, 298 + created: Some(true), 299 + uri: out.uri, 300 + extra_data: None, 301 + })) 302 + } 303 + 304 + pub(super) async fn delete_follow( 305 + State(state): State<AppState>, 306 + ExtractOAuthSession(session): ExtractOAuthSession<PublicResolver, SqliteAuthStore>, 307 + ExtractXrpc(req): ExtractXrpc<DeleteFollowRequest>, 308 + ) -> AppResult<XrpcResponse<DeleteFollowRequest>> { 309 + let agent = Agent::from(session); 310 + let actor = authenticated_did(&agent).await?; 311 + let uri = match (req.uri, req.subject) { 312 + (Some(uri), _) => uri, 313 + (None, Some(subject)) => lookup_follow_uri(&state, &actor, &subject).await?, 314 + (None, None) => return Err(invalid_request("deleteFollow requires uri or subject")), 315 + }; 316 + delete_social::<Follow>(&state, &agent, &actor, FOLLOW_COLLECTION, &uri).await?; 317 + Ok(XrpcResponse(DeleteFollowOutput { 318 + deleted: true, 319 + uri: Some(uri), 244 320 extra_data: None, 245 321 })) 246 322 } ··· 1467 1543 .ok_or_else(|| invalid_request("social record is not indexed locally")) 1468 1544 } 1469 1545 1546 + async fn ensure_follow_subject(state: &AppState, subject: &Did) -> AppResult<()> { 1547 + let subject_ref = subject.as_ref(); 1548 + let exists = db(sqlx::query_scalar!( 1549 + "SELECT EXISTS(SELECT 1 FROM identities WHERE did = ? UNION SELECT 1 FROM profiles WHERE did = ?) AS \"exists!: bool\"", 1550 + subject_ref, 1551 + subject_ref, 1552 + ) 1553 + .fetch_one(&state.pool) 1554 + .await)?; 1555 + if !exists { 1556 + let _ = views::resolve_handle(state, subject).await?; 1557 + } 1558 + Ok(()) 1559 + } 1560 + 1561 + async fn lookup_follow_uri(state: &AppState, actor: &Did, subject: &Did) -> AppResult<AtUri> { 1562 + lookup_existing_follow(state, actor, subject) 1563 + .await? 1564 + .map(|record| record.uri) 1565 + .ok_or_else(|| invalid_request("follow record is not indexed locally")) 1566 + } 1567 + 1470 1568 struct ExistingSocial { 1471 1569 cid: Cid, 1472 1570 uri: AtUri, ··· 1511 1609 }) 1512 1610 .transpose()?; 1513 1611 Ok(existing) 1612 + } 1613 + 1614 + async fn lookup_existing_follow( 1615 + state: &AppState, 1616 + actor: &Did, 1617 + subject: &Did, 1618 + ) -> AppResult<Option<ExistingSocial>> { 1619 + #[derive(sqlx::FromRow)] 1620 + struct Row { 1621 + cid: String, 1622 + rkey: String, 1623 + } 1624 + let row = db(sqlx::query_as!( 1625 + Row, 1626 + "SELECT cid, rkey FROM follows WHERE follower_did = ? AND followed_did = ?", 1627 + actor.as_ref(), 1628 + subject.as_ref(), 1629 + ) 1630 + .fetch_optional(&state.pool) 1631 + .await)?; 1632 + row.map(|row| { 1633 + let uri = at_uri_owned(actor, FOLLOW_COLLECTION, &row.rkey)?; 1634 + let cid = Cid::new_owned(row.cid.as_bytes()) 1635 + .map_err(|e| internal(format!("stored follow cid is invalid: {e}")))?; 1636 + Ok(ExistingSocial { cid, uri }) 1637 + }) 1638 + .transpose() 1514 1639 } 1515 1640 1516 1641 pub(super) fn content_type(headers: &HeaderMap) -> String {
+18 -3
src/browse.rs
··· 17 17 enum FeedAlgorithm { 18 18 Recent, 19 19 Hot, 20 + Following, 20 21 } 21 22 22 23 impl FeedAlgorithm { ··· 24 25 match self { 25 26 Self::Recent => "recent", 26 27 Self::Hot => "hot", 28 + Self::Following => "following", 27 29 } 28 30 } 29 31 ··· 31 33 match self { 32 34 Self::Recent => "Recent", 33 35 Self::Hot => "Hot", 36 + Self::Following => "Following", 34 37 } 35 38 } 36 39 } ··· 153 156 div { class: "tab-row", role: "tablist", aria_label: "Feeds", 154 157 FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } 155 158 FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } 156 - button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } 159 + FeedTab { algorithm: FeedAlgorithm::Following, selected_algorithm } 157 160 } 158 161 159 162 section { class: "browse-results-grid", aria_label: "Browse results", ··· 177 180 BrowseFeedState::Empty => rsx! { 178 181 div { class: "state-card empty-state browse-state-card", 179 182 span { class: "status-pill status-muted", "Empty" } 180 - h2 { "No projects yet" } 181 - p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } 183 + if *selected_algorithm.read() == FeedAlgorithm::Following && matches!(&*session.read(), SessionIdentity::Anonymous) { 184 + h2 { "Sign in to build a following feed" } 185 + p { "Follow makers to collect their latest Polymodel projects here." } 186 + form { class: "hero-band-signin", action: "/oauth/start", method: "get", 187 + input { r#type: "hidden", name: "return_to", value: "/" } 188 + button { class: "button button-primary", r#type: "submit", "Sign in" } 189 + } 190 + } else if *selected_algorithm.read() == FeedAlgorithm::Following { 191 + h2 { "No followed projects yet" } 192 + p { "Follow makers from their profiles and their projects will appear here." } 193 + } else { 194 + h2 { "No projects yet" } 195 + p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } 196 + } 182 197 } 183 198 }, 184 199 BrowseFeedState::Populated(things) => rsx! {
+215 -2
src/indexing/projection.rs
··· 13 13 use jacquard_common::types::value::{Data, DataDeserializerError, from_data, to_data}; 14 14 use polymodel_api::space_polymodel::{ 15 15 actor::profile::Profile, 16 - graph::{like::Like, listitem::Listitem, save::Save, tag::Tag}, 16 + graph::{follow::Follow, like::Like, list::List, listitem::Listitem, save::Save, tag::Tag}, 17 17 library::{model::Model, part::Part, thing::Thing}, 18 18 }; 19 19 use sqlx::{Sqlite, SqlitePool, Transaction}; ··· 259 259 ("delete", "space.polymodel.actor.profile") => { 260 260 project_profile_delete(tx, event).await?; 261 261 } 262 + ("create", "space.polymodel.graph.follow") => { 263 + project_follow_create(tx, event).await?; 264 + } 265 + ("delete", "space.polymodel.graph.follow") => { 266 + project_follow_delete(tx, event).await?; 267 + } 262 268 ("create", "space.polymodel.graph.like") => { 263 269 project_like_create(tx, event).await?; 264 270 } ··· 277 283 ("delete", "space.polymodel.graph.tag") => { 278 284 project_tag_delete(tx, event).await?; 279 285 } 286 + ("create" | "update", "space.polymodel.graph.list") => { 287 + project_list_upsert(tx, event).await?; 288 + } 289 + ("delete", "space.polymodel.graph.list") => { 290 + project_list_delete(tx, event).await?; 291 + } 280 292 ("create", "space.polymodel.graph.listitem") => { 281 293 project_listitem_create(tx, event).await?; 282 294 } 283 295 ("delete", "space.polymodel.graph.listitem") => { 284 296 project_listitem_delete(tx, event).await?; 285 297 } 286 - // graph.list lives in Hydrant's fjall store; no SQLite projection. 287 298 // account events are dropped by ProjectionInput::from_hydrant. 288 299 _ => { 289 300 tracing::debug!(action, collection, "no projection rule for event"); ··· 789 800 Ok(()) 790 801 } 791 802 803 + async fn project_follow_create( 804 + tx: &mut Transaction<'_, Sqlite>, 805 + e: &ProjectionEvent, 806 + ) -> anyhow::Result<()> { 807 + let Some(data) = &e.record else { 808 + return Ok(()); 809 + }; 810 + let rec = match decode::<Follow>(data, &e.collection) { 811 + Ok(r) => r, 812 + Err(err) => { 813 + tracing::warn!(error = %err, "follow record decode failed"); 814 + return Ok(()); 815 + } 816 + }; 817 + let Some(cid) = e.cid.clone() else { 818 + return Ok(()); 819 + }; 820 + let followed = rec.subject.as_str().to_string(); 821 + let created_at = rec 822 + .created_at 823 + .as_ref() 824 + .timestamp_nanos_opt() 825 + .unwrap_or_else(now_nanos); 826 + 827 + sqlx::query!( 828 + "INSERT OR IGNORE INTO follows (follower_did, followed_did, rkey, cid, created_at) \ 829 + VALUES (?, ?, ?, ?, ?)", 830 + e.did, 831 + followed, 832 + e.rkey, 833 + cid, 834 + created_at, 835 + ) 836 + .execute(&mut **tx) 837 + .await?; 838 + Ok(()) 839 + } 840 + 841 + async fn project_follow_delete( 842 + tx: &mut Transaction<'_, Sqlite>, 843 + e: &ProjectionEvent, 844 + ) -> anyhow::Result<()> { 845 + sqlx::query!( 846 + "DELETE FROM follows WHERE follower_did = ? AND rkey = ?", 847 + e.did, 848 + e.rkey 849 + ) 850 + .execute(&mut **tx) 851 + .await?; 852 + Ok(()) 853 + } 854 + 792 855 async fn project_like_delete( 793 856 tx: &mut Transaction<'_, Sqlite>, 794 857 e: &ProjectionEvent, ··· 1028 1091 Ok(()) 1029 1092 } 1030 1093 1094 + async fn project_list_upsert( 1095 + tx: &mut Transaction<'_, Sqlite>, 1096 + e: &ProjectionEvent, 1097 + ) -> anyhow::Result<()> { 1098 + let Some(data) = &e.record else { 1099 + return Ok(()); 1100 + }; 1101 + let rec = match decode::<List>(data, &e.collection) { 1102 + Ok(r) => r, 1103 + Err(err) => { 1104 + tracing::warn!(error = %err, "list record decode failed"); 1105 + return Ok(()); 1106 + } 1107 + }; 1108 + let Some(cid) = e.cid.clone() else { 1109 + return Ok(()); 1110 + }; 1111 + let uri = format!("at://{}/{}/{}", e.did, e.collection, e.rkey); 1112 + let name = rec.name.to_string(); 1113 + let description = rec.description.as_ref().map(|s| s.to_string()); 1114 + let purpose = rec.purpose.as_ref().map(|s| s.to_string()); 1115 + let created_at = rec 1116 + .created_at 1117 + .as_ref() 1118 + .timestamp_nanos_opt() 1119 + .unwrap_or_else(now_nanos); 1120 + let indexed_at = now_nanos(); 1121 + let record_json = serde_json::to_string(data)?; 1122 + 1123 + sqlx::query!( 1124 + "INSERT INTO lists (did, rkey, uri, cid, name, description, purpose, created_at, indexed_at, record_json) \ 1125 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ 1126 + ON CONFLICT(did, rkey) DO UPDATE SET \ 1127 + uri = excluded.uri, cid = excluded.cid, name = excluded.name, \ 1128 + description = excluded.description, purpose = excluded.purpose, \ 1129 + created_at = excluded.created_at, indexed_at = excluded.indexed_at, \ 1130 + record_json = excluded.record_json", 1131 + e.did, 1132 + e.rkey, 1133 + uri, 1134 + cid, 1135 + name, 1136 + description, 1137 + purpose, 1138 + created_at, 1139 + indexed_at, 1140 + record_json, 1141 + ) 1142 + .execute(&mut **tx) 1143 + .await?; 1144 + Ok(()) 1145 + } 1146 + 1147 + async fn project_list_delete( 1148 + tx: &mut Transaction<'_, Sqlite>, 1149 + e: &ProjectionEvent, 1150 + ) -> anyhow::Result<()> { 1151 + sqlx::query!( 1152 + "DELETE FROM lists WHERE did = ? AND rkey = ?", 1153 + e.did, 1154 + e.rkey 1155 + ) 1156 + .execute(&mut **tx) 1157 + .await?; 1158 + Ok(()) 1159 + } 1160 + 1031 1161 async fn project_listitem_delete( 1032 1162 tx: &mut Transaction<'_, Sqlite>, 1033 1163 e: &ProjectionEvent, ··· 1580 1710 let (stored_list, stored_subject) = row.expect("listitem must be projected"); 1581 1711 assert_eq!(stored_list, list_uri); 1582 1712 assert_eq!(stored_subject, subject_uri); 1713 + } 1714 + 1715 + #[tokio::test] 1716 + async fn list_create_update_delete_projects_metadata() { 1717 + let db = test_db().await; 1718 + let did = "did:plc:listmaker"; 1719 + let create = ProjectionEvent { 1720 + seq: 20, 1721 + did: did.to_string(), 1722 + collection: "space.polymodel.graph.list".to_string(), 1723 + rkey: "l1".to_string(), 1724 + action: "create".to_string(), 1725 + record: Some( 1726 + to_data(&json!({ 1727 + "name": "Fixture jigs", 1728 + "description": "Useful shop fixtures", 1729 + "purpose": "curation", 1730 + "createdAt": "2024-01-01T00:00:00.000Z" 1731 + })) 1732 + .unwrap(), 1733 + ), 1734 + cid: Some("bafyreilistone".to_string()), 1735 + }; 1736 + 1737 + project_event(&db, &ProjectionInput::Record(create)) 1738 + .await 1739 + .unwrap(); 1740 + let uri = format!("at://{did}/space.polymodel.graph.list/l1"); 1741 + let row: (String, String, Option<String>) = 1742 + sqlx::query_as("SELECT uri, name, purpose FROM lists WHERE did = ? AND rkey = ?") 1743 + .bind(did) 1744 + .bind("l1") 1745 + .fetch_one(&db) 1746 + .await 1747 + .unwrap(); 1748 + assert_eq!(row.0, uri); 1749 + assert_eq!(row.1, "Fixture jigs"); 1750 + assert_eq!(row.2.as_deref(), Some("curation")); 1751 + 1752 + let update = ProjectionEvent { 1753 + seq: 21, 1754 + did: did.to_string(), 1755 + collection: "space.polymodel.graph.list".to_string(), 1756 + rkey: "l1".to_string(), 1757 + action: "update".to_string(), 1758 + record: Some( 1759 + to_data(&json!({ 1760 + "name": "Workbench jigs", 1761 + "createdAt": "2024-01-02T00:00:00.000Z" 1762 + })) 1763 + .unwrap(), 1764 + ), 1765 + cid: Some("bafyreilisttwo".to_string()), 1766 + }; 1767 + project_event(&db, &ProjectionInput::Record(update)) 1768 + .await 1769 + .unwrap(); 1770 + let name: String = sqlx::query_scalar("SELECT name FROM lists WHERE did = ? AND rkey = ?") 1771 + .bind(did) 1772 + .bind("l1") 1773 + .fetch_one(&db) 1774 + .await 1775 + .unwrap(); 1776 + assert_eq!(name, "Workbench jigs"); 1777 + 1778 + let delete = ProjectionEvent { 1779 + seq: 22, 1780 + did: did.to_string(), 1781 + collection: "space.polymodel.graph.list".to_string(), 1782 + rkey: "l1".to_string(), 1783 + action: "delete".to_string(), 1784 + record: None, 1785 + cid: None, 1786 + }; 1787 + project_event(&db, &ProjectionInput::Record(delete)) 1788 + .await 1789 + .unwrap(); 1790 + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lists WHERE did = ?") 1791 + .bind(did) 1792 + .fetch_one(&db) 1793 + .await 1794 + .unwrap(); 1795 + assert_eq!(count, 0); 1583 1796 } 1584 1797 }
+1
src/indexing/setup.rs
··· 62 62 } 63 63 let expected_records = [ 64 64 "space.polymodel.actor.profile", 65 + "space.polymodel.graph.follow", 65 66 "space.polymodel.graph.like", 66 67 "space.polymodel.graph.list", 67 68 "space.polymodel.graph.listitem",
+234 -19
src/profile.rs
··· 1 - use dioxus::prelude::*; 2 - use jacquard_common::deps::smol_str::SmolStr; 3 - use jacquard_common::types::string::{Did, Handle, UriValue}; 4 - use polymodel_api::space_polymodel::actor::ProfileViewUnion; 5 - use polymodel_api::space_polymodel::library; 6 - 1 + use crate::PolymodelClient; 7 2 use crate::Route; 8 3 use crate::session::SessionIdentity; 9 4 use crate::thing_card::ThingCard; 10 5 use crate::thing_detail::thing_detail_href; 6 + use dioxus::prelude::*; 7 + use jacquard_common::deps::smol_str::SmolStr; 8 + use jacquard_common::types::string::{AtIdentifier, Did, Handle, UriValue}; 9 + use jacquard_common::xrpc::XrpcClient; 10 + use polymodel_api::space_polymodel::actor::{ProfileViewUnion, get_profile::GetProfile}; 11 + use polymodel_api::space_polymodel::graph::{ 12 + create_follow::CreateFollow, delete_follow::DeleteFollow, 13 + }; 14 + use polymodel_api::space_polymodel::library; 15 + use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings; 11 16 12 17 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 13 18 enum ProfileTab { ··· 40 45 struct ProfileData { 41 46 header: ProfileHeader, 42 47 things: Vec<library::ThingViewBasic>, 48 + lists: Vec<polymodel_api::space_polymodel::actor::ProfileListView>, 43 49 } 44 50 45 51 #[derive(Clone, Debug, PartialEq, Eq)] ··· 60 66 handle: Handle, 61 67 links: Option<Vec<UriValue>>, 62 68 pronouns: Option<SmolStr>, 69 + follower_count: Option<i64>, 70 + following_count: Option<i64>, 71 + following: Option<jacquard_common::types::string::AtUri>, 63 72 thing_count: Option<i64>, 64 73 total_likes: Option<i64>, 65 74 is_bsky_fallback: bool, ··· 118 127 .expect("polymodel profile handle is server-validated"), 119 128 links: view.links.clone(), 120 129 pronouns: optional_smolstr(&view.pronouns), 130 + follower_count: view.follower_count, 131 + following_count: view.following_count, 132 + following: view 133 + .viewer 134 + .as_ref() 135 + .and_then(|viewer| viewer.following.clone()), 121 136 thing_count: view.thing_count, 122 137 total_likes: view.total_likes, 123 138 is_bsky_fallback: false, ··· 131 146 handle: view.handle.clone(), 132 147 links: None, 133 148 pronouns: optional_smolstr(&view.pronouns), 149 + follower_count: None, 150 + following_count: None, 151 + following: None, 134 152 thing_count: None, 135 153 total_likes: None, 136 154 is_bsky_fallback: true, ··· 148 166 .map(SmolStr::from) 149 167 } 150 168 169 + fn profile_lists( 170 + value: &ProfileViewUnion, 171 + ) -> Vec<polymodel_api::space_polymodel::actor::ProfileListView> { 172 + match value { 173 + ProfileViewUnion::ProfileView(view) => view.lists.clone().unwrap_or_default(), 174 + ProfileViewUnion::ProfileViewDetailed(_) => Vec::new(), 175 + } 176 + } 177 + 151 178 /// Canonical profile route for an actor. Prefers the handle (always present 152 179 /// and validated on `library::Actor`); falls back to the DID defensively. 153 180 pub(crate) fn profile_href(actor: &library::Actor) -> Option<String> { ··· 199 226 let feed = 200 227 crate::appview::ssr::get_author_things_from_fullstack_context(&actor_id, 24, None).await?; 201 228 let things = feed.items.into_iter().map(|item| item.thing).collect(); 229 + let lists = profile_lists(&union); 202 230 203 - Ok(ProfileData { header, things }) 231 + Ok(ProfileData { 232 + header, 233 + things, 234 + lists, 235 + }) 236 + } 237 + 238 + fn adjust_count(current: i64, was_active: bool, is_active: bool) -> i64 { 239 + match (was_active, is_active) { 240 + (false, true) => current + 1, 241 + (true, false) => current.saturating_sub(1), 242 + _ => current, 243 + } 244 + } 245 + 246 + async fn load_profile(client: PolymodelClient, actor_str: &str) -> Result<ProfileData, String> { 247 + let actor = AtIdentifier::new_owned(actor_str) 248 + .map_err(|error| format!("Invalid actor: unsupported actor identifier: {error}"))?; 249 + 250 + let profile_response = client 251 + .send(GetProfile::new().actor(actor.clone()).build()) 252 + .await 253 + .map_err(|error| format!("Profile request failed: {error}"))?; 254 + let profile_output = profile_response 255 + .into_output() 256 + .map_err(|error| format!("Profile decode failed: {error}"))?; 257 + let header = profile_header(&profile_output.value); 258 + let lists = profile_lists(&profile_output.value); 259 + 260 + let things_response = client 261 + .send(GetAuthorThings::new().actor(actor).limit(24).build()) 262 + .await 263 + .map_err(|error| format!("Author things request failed: {error}"))?; 264 + let things_output = things_response 265 + .into_output() 266 + .map_err(|error| format!("Author things decode failed: {error}"))?; 267 + let things = things_output 268 + .value 269 + .items 270 + .into_iter() 271 + .map(|item| item.thing) 272 + .collect(); 273 + 274 + Ok(ProfileData { 275 + header, 276 + things, 277 + lists, 278 + }) 204 279 } 205 280 206 281 #[allow(clippy::useless_format)] ··· 398 473 is_anonymous: bool, 399 474 active_tab: Signal<ProfileTab>, 400 475 ) -> Element { 476 + let client = use_context::<PolymodelClient>(); 401 477 let header = data.header; 402 478 let return_to = use_route::<Route>().to_string(); 403 479 let things = data.things; ··· 417 493 .map(ToString::to_string); 418 494 let thing_count_label = count_label(header.thing_count, "thing"); 419 495 let total_likes_label = count_label(header.total_likes, "like"); 496 + let mut follower_count = use_signal(|| header.follower_count.unwrap_or(0)); 497 + let follower_count_label = if header.follower_count.is_some() { 498 + count_label(Some(*follower_count.read()), "follower") 499 + } else { 500 + None 501 + }; 502 + let following_count_label = count_label(header.following_count, "following"); 503 + let mut following = use_signal(|| header.following.clone()); 504 + let mut follow_pending = use_signal(|| false); 505 + let mut follow_error = use_signal(|| None::<String>); 506 + let follow_label = if following.read().is_some() { 507 + "Following" 508 + } else { 509 + "Follow" 510 + }; 420 511 let is_bsky_fallback = header.is_bsky_fallback; 421 512 let links_rail = header.links.clone(); 422 513 let bio_segments = description.as_deref().map(linkify_bio); 423 514 let tab = *active_tab.read(); 515 + let follow_subject = header.did.clone(); 424 516 425 517 rsx! { 426 518 div { class: "profile-layout", ··· 446 538 } 447 539 } 448 540 449 - if thing_count_label.is_some() || total_likes_label.is_some() { 541 + if thing_count_label.is_some() || total_likes_label.is_some() || header.follower_count.is_some() || following_count_label.is_some() { 450 542 div { class: "profile-stats", aria_label: "Maker stats", 451 543 if let Some(label) = &thing_count_label { 452 544 span { "{label}" } ··· 454 546 if let Some(label) = &total_likes_label { 455 547 span { "{label}" } 456 548 } 549 + if let Some(label) = &follower_count_label { 550 + span { "{label}" } 551 + } 552 + if let Some(label) = &following_count_label { 553 + span { "{label}" } 554 + } 457 555 } 458 556 } 459 557 ··· 466 564 button { class: "button button-primary", r#type: "submit", "Sign in to follow" } 467 565 } 468 566 } else { 469 - button { class: "button button-primary", disabled: true, "Follow" } 567 + button { 568 + class: if following.read().is_some() { "button button-secondary" } else { "button button-primary" }, 569 + disabled: *follow_pending.read(), 570 + aria_pressed: "{following.read().is_some()}", 571 + onclick: move |_| { 572 + let client = client.clone(); 573 + let subject = follow_subject.clone(); 574 + let current = following.read().clone(); 575 + follow_pending.set(true); 576 + follow_error.set(None); 577 + spawn(async move { 578 + let result = if let Some(uri) = current { 579 + match client.send(DeleteFollow { uri: Some(uri), subject: None, extra_data: None }).await { 580 + Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 581 + Err(error) => Err(error.to_string()), 582 + } 583 + } else { 584 + match client.send(CreateFollow { subject, extra_data: None }).await { 585 + Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 586 + Err(error) => Err(error.to_string()), 587 + } 588 + }; 589 + match result { 590 + Ok(next) => { 591 + let was_following = following.read().is_some(); 592 + following.set(next); 593 + let current_count = *follower_count.read(); 594 + let is_following = following.read().is_some(); 595 + follower_count.set(adjust_count(current_count, was_following, is_following)); 596 + } 597 + Err(error) => follow_error.set(Some(format!("Follow failed: {error}"))), 598 + } 599 + follow_pending.set(false); 600 + }); 601 + }, 602 + "{follow_label}" 603 + } 604 + if let Some(error) = follow_error.read().as_ref() { 605 + p { class: "profile-action-error", role: "status", "{error}" } 606 + } 470 607 } 471 608 } 472 609 ··· 505 642 div { class: "profile-tab-content", 506 643 match tab { 507 644 ProfileTab::Things => rsx! { ProfileThingsGrid { things } }, 508 - ProfileTab::Lists => rsx! { 509 - div { class: "profile-placeholder-panel", 510 - span { class: "status-pill status-muted", "Coming soon" } 511 - p { "Lists and collections are not available yet." } 512 - } 513 - }, 645 + ProfileTab::Lists => rsx! { ProfileLists { lists: data.lists.clone() } }, 514 646 ProfileTab::Makes => rsx! { 515 - div { class: "profile-placeholder-panel", 516 - span { class: "status-pill status-muted", "Coming soon" } 517 - p { "Makes and activity are not available yet." } 647 + div { class: "profile-empty-panel", 648 + span { class: "status-pill status-muted", "Not modelled yet" } 649 + h2 { "No make activity model yet" } 650 + p { "Polymodel does not yet have a dedicated make, remix, or activity record to show here without faking it." } 518 651 } 519 652 }, 520 653 ProfileTab::About => rsx! { ··· 593 726 rsx! { ThingCard { thing, detail_href, creator_href } } 594 727 } 595 728 729 + fn list_item_count_label(count: i64) -> String { 730 + let suffix = if count == 1 { "" } else { "s" }; 731 + format!("{count} item{suffix}") 732 + } 733 + 734 + #[allow(clippy::useless_format)] 735 + #[component] 736 + fn ProfileLists(lists: Vec<polymodel_api::space_polymodel::actor::ProfileListView>) -> Element { 737 + rsx! { 738 + section { class: "profile-lists", aria_label: "Profile lists", 739 + if lists.is_empty() { 740 + div { class: "profile-empty-panel", 741 + span { class: "status-pill status-muted", "Empty" } 742 + h2 { "No lists yet" } 743 + p { "Lists are real Polymodel records; this maker just has not published one yet." } 744 + } 745 + } else { 746 + for list in lists { 747 + article { key: "{list.uri}", class: "profile-list-card blueprint-panel", 748 + div { class: "profile-list-card-header", 749 + h2 { "{list.name}" } 750 + span { class: "status-pill status-muted", "{list_item_count_label(list.item_count)}" } 751 + } 752 + if let Some(description) = &list.description { 753 + p { "{description}" } 754 + } 755 + dl { class: "profile-list-meta", 756 + if let Some(purpose) = &list.purpose { 757 + div { dt { "Purpose" } dd { "{purpose}" } } 758 + } 759 + div { dt { "List URI" } dd { "{list.uri.as_ref()}" } } 760 + } 761 + } 762 + } 763 + } 764 + } 765 + } 766 + } 767 + 596 768 #[allow(clippy::useless_format)] 597 769 #[component] 598 770 fn ProfileAbout(header: ProfileHeader) -> Element { ··· 664 836 use crate::thing_card::thing_card_fixtures; 665 837 use jacquard_common::types::string::{Did, Handle}; 666 838 use polymodel_api::app_bsky::actor::ProfileViewDetailed; 667 - use polymodel_api::space_polymodel::actor::ProfileView; 668 839 use polymodel_api::space_polymodel::actor::get_profile::GetProfileOutput; 840 + use polymodel_api::space_polymodel::actor::{ProfileListView, ProfileView}; 669 841 use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThingsOutput; 670 842 671 843 fn polymodel_view() -> ProfileView { ··· 684 856 printers: None, 685 857 record: jacquard::Data::Null, 686 858 total_likes: Some(42), 859 + follower_count: None, 860 + following_count: None, 861 + lists: None, 862 + viewer: None, 687 863 } 688 864 } 689 865 ··· 714 890 } 715 891 } 716 892 893 + fn profile_list() -> ProfileListView { 894 + ProfileListView { 895 + cid: jacquard_common::types::string::Cid::new_owned(b"bafyreilistcid").unwrap(), 896 + created_at: jacquard_common::types::datetime::Datetime::raw_str( 897 + "2024-01-01T00:00:00.000Z", 898 + ), 899 + description: Some("Shop fixtures".into()), 900 + item_count: 2, 901 + name: "Fixture jigs".into(), 902 + purpose: Some("curation".into()), 903 + uri: jacquard_common::types::string::AtUri::new_owned( 904 + "at://did:plc:alice/space.polymodel.graph.list/fixtures", 905 + ) 906 + .unwrap(), 907 + extra_data: None, 908 + } 909 + } 910 + 717 911 fn fixture_actor(did: &str, handle: &str) -> library::Actor { 718 912 library::Actor { 719 913 avatar: None, ··· 744 938 let data = ProfileData { 745 939 header: profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))), 746 940 things: vec![], 941 + lists: vec![], 747 942 }; 748 943 let populated = profile_state(false, Some(&Ok(data))); 749 944 assert!(matches!(populated, ProfileState::Populated(_))); ··· 817 1012 assert_eq!(header.total_likes, None); 818 1013 assert_eq!(header.default_license, None); 819 1014 assert_eq!(header.links, None); 1015 + } 1016 + 1017 + #[test] 1018 + fn profile_lists_extracts_polymodel_lists_only() { 1019 + let mut view = polymodel_view(); 1020 + view.lists = Some(vec![profile_list()]); 1021 + let lists = profile_lists(&ProfileViewUnion::ProfileView(Box::new(view))); 1022 + assert_eq!(lists.len(), 1); 1023 + assert_eq!(lists[0].name.as_str(), "Fixture jigs"); 1024 + 1025 + let fallback = profile_lists(&ProfileViewUnion::ProfileViewDetailed( 1026 + Box::new(bsky_view()), 1027 + )); 1028 + assert!(fallback.is_empty()); 1029 + } 1030 + 1031 + #[test] 1032 + fn list_item_count_label_pluralizes() { 1033 + assert_eq!(list_item_count_label(1), "1 item"); 1034 + assert_eq!(list_item_count_label(2), "2 items"); 820 1035 } 821 1036 822 1037 #[test]
+8
src/session.rs
··· 96 96 thing_count: None, 97 97 extra_data: None, 98 98 links: None, 99 + lists: None, 99 100 printers: None, 100 101 record: jacquard::Data::Null, 102 + follower_count: None, 103 + following_count: None, 101 104 total_likes: None, 105 + viewer: None, 102 106 }, 103 107 extra_data: None, 104 108 authenticated: true, ··· 130 134 thing_count: None, 131 135 extra_data: None, 132 136 links: None, 137 + lists: None, 133 138 printers: None, 134 139 record: jacquard::Data::Null, 140 + follower_count: None, 141 + following_count: None, 135 142 total_likes: None, 143 + viewer: None, 136 144 }, 137 145 extra_data: None, 138 146 authenticated: true,
+136 -5
src/thing_card.rs
··· 1 1 use dioxus::prelude::*; 2 + use jacquard_common::types::string::Cid; 2 3 #[cfg(test)] 3 4 use jacquard_common::types::{ 4 5 string::{AtUri, Datetime, Did, Handle}, 5 6 uri::UriValue, 6 7 }; 8 + use jacquard_common::xrpc::XrpcClient; 9 + use polymodel_api::com_atproto::repo::strong_ref::StrongRef; 10 + use polymodel_api::space_polymodel::graph::{ 11 + create_like::CreateLike, create_save::CreateSave, delete_like::DeleteLike, 12 + delete_save::DeleteSave, 13 + }; 7 14 use polymodel_api::space_polymodel::library; 15 + 16 + use crate::client::PolymodelClient; 17 + use crate::session::SessionIdentity; 8 18 9 19 #[cfg(test)] 10 20 const COMPLETE_PREVIEW_SVG: &str = "data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 480\"%3E%3Crect width=\"640\" height=\"480\" fill=\"%23f4efe4\"/%3E%3Cg stroke=\"%232a6f97\" stroke-width=\"2\" fill=\"none\"%3E%3Cpath d=\"M64 96h512v288H64zM160 144h320v192H160zM256 192h128v96H256z\"/%3E%3Cpath d=\"M64 384 160 336M576 384 480 336M160 144 64 96M480 144 576 96\"/%3E%3C/g%3E%3Cg fill=\"%2366767f\" font-family=\"monospace\" font-size=\"24\"%3E%3Ctext x=\"72\" y=\"438\"%3Eparametric enclosure preview%3C/text%3E%3C/g%3E%3C/svg%3E"; ··· 121 131 detail_href: Option<String>, 122 132 #[props(default)] creator_href: Option<String>, 123 133 ) -> Element { 134 + let client = use_context::<PolymodelClient>(); 135 + let session = use_context::<Signal<SessionIdentity>>(); 124 136 let title = thing_title(&thing); 125 137 let creator = creator_label(&thing.author); 126 138 let media = select_card_media(thing.cover.as_deref(), thing.previews.as_deref(), title); 127 139 let stats = stats(&thing); 128 - let like_label = if thing.viewer.like.is_some() { 140 + let mut like_uri = use_signal(|| thing.viewer.like.clone()); 141 + let mut save_uri = use_signal(|| thing.viewer.save.clone()); 142 + let mut like_count = use_signal(|| thing.like_count); 143 + let mut save_count = use_signal(|| thing.save_count); 144 + let mut action_pending = use_signal(|| false); 145 + let mut action_error = use_signal(|| None::<String>); 146 + let like_label = if like_uri.read().is_some() { 129 147 "Liked" 130 148 } else { 131 149 "Like" 132 150 }; 133 - let save_label = if thing.viewer.save.is_some() { 151 + let save_label = if save_uri.read().is_some() { 134 152 "Saved" 135 153 } else { 136 154 "Save" 137 155 }; 156 + let can_write = matches!(&*session.read(), SessionIdentity::Authenticated(_)); 157 + let subject = StrongRef { 158 + uri: thing.uri.clone(), 159 + cid: Cid::new_owned(thing.cid.as_str().as_bytes()).expect("thing cid is server-validated"), 160 + extra_data: None, 161 + }; 162 + let like_client = client.clone(); 163 + let like_subject = subject.clone(); 164 + let save_client = client.clone(); 165 + let save_subject = subject.clone(); 138 166 139 167 rsx! { 140 168 article { class: "thing-card card-surface", aria_label: "Thing card for {title}", ··· 174 202 } 175 203 } 176 204 dl { class: "thing-card-stats", aria_label: "Thing stats", 177 - for (label, value) in stats { 205 + for (label, value) in stats_with_social_counts(&stats, *like_count.read(), *save_count.read()) { 178 206 div { 179 207 dt { "{label}" } 180 208 dd { "{value}" } ··· 182 210 } 183 211 } 184 212 div { class: "thing-card-actions", aria_label: "Thing card saved and liked state", 185 - span { class: "thing-card-action", aria_label: like_label, "♡ {like_label}" } 186 - span { class: "thing-card-action", aria_label: save_label, "☆ {save_label}" } 213 + if can_write { 214 + button { 215 + class: "thing-card-action", 216 + disabled: *action_pending.read(), 217 + aria_pressed: "{like_uri.read().is_some()}", 218 + aria_label: "{like_label} {title}", 219 + onclick: move |_| { 220 + let client = like_client.clone(); 221 + let subject = like_subject.clone(); 222 + let current = like_uri.read().clone(); 223 + action_pending.set(true); 224 + action_error.set(None); 225 + spawn(async move { 226 + let result = if let Some(uri) = current { 227 + match client.send(DeleteLike { uri: Some(uri), subject: None, extra_data: None }).await { 228 + Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 229 + Err(error) => Err(error.to_string()), 230 + } 231 + } else { 232 + match client.send(CreateLike { subject, extra_data: None }).await { 233 + Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 234 + Err(error) => Err(error.to_string()), 235 + } 236 + }; 237 + match result { 238 + Ok(next) => { 239 + let was_liked = like_uri.read().is_some(); 240 + like_uri.set(next); 241 + let current_count = *like_count.read(); 242 + let is_liked = like_uri.read().is_some(); 243 + like_count.set(adjust_count(current_count, was_liked, is_liked)); 244 + } 245 + Err(error) => action_error.set(Some(format!("Like failed: {error}"))), 246 + } 247 + action_pending.set(false); 248 + }); 249 + }, 250 + "♡ {like_label}" 251 + } 252 + button { 253 + class: "thing-card-action", 254 + disabled: *action_pending.read(), 255 + aria_pressed: "{save_uri.read().is_some()}", 256 + aria_label: "{save_label} {title}", 257 + onclick: move |_| { 258 + let client = save_client.clone(); 259 + let subject = save_subject.clone(); 260 + let current = save_uri.read().clone(); 261 + action_pending.set(true); 262 + action_error.set(None); 263 + spawn(async move { 264 + let result = if let Some(uri) = current { 265 + match client.send(DeleteSave { uri: Some(uri), subject: None, extra_data: None }).await { 266 + Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 267 + Err(error) => Err(error.to_string()), 268 + } 269 + } else { 270 + match client.send(CreateSave { subject, note: None, extra_data: None }).await { 271 + Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 272 + Err(error) => Err(error.to_string()), 273 + } 274 + }; 275 + match result { 276 + Ok(next) => { 277 + let was_saved = save_uri.read().is_some(); 278 + save_uri.set(next); 279 + let current_count = *save_count.read(); 280 + let is_saved = save_uri.read().is_some(); 281 + save_count.set(adjust_count(current_count, was_saved, is_saved)); 282 + } 283 + Err(error) => action_error.set(Some(format!("Save failed: {error}"))), 284 + } 285 + action_pending.set(false); 286 + }); 287 + }, 288 + "☆ {save_label}" 289 + } 290 + } else { 291 + a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to like {title}", "♡ Like" } 292 + a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to save {title}", "☆ Save" } 293 + } 294 + if let Some(error) = action_error.read().as_ref() { 295 + span { class: "thing-card-action-error", role: "status", "{error}" } 296 + } 187 297 } 188 298 } 189 299 } ··· 217 327 ("Likes", count_label(thing.like_count, "like")), 218 328 ("Saves", count_label(thing.save_count, "save")), 219 329 ] 330 + } 331 + 332 + fn stats_with_social_counts( 333 + stats: &[(&'static str, String); 4], 334 + like_count: i64, 335 + save_count: i64, 336 + ) -> [(&'static str, String); 4] { 337 + [ 338 + stats[0].clone(), 339 + stats[1].clone(), 340 + ("Likes", count_label(like_count, "like")), 341 + ("Saves", count_label(save_count, "save")), 342 + ] 343 + } 344 + 345 + fn adjust_count(current: i64, was_active: bool, is_active: bool) -> i64 { 346 + match (was_active, is_active) { 347 + (false, true) => current + 1, 348 + (true, false) => current.saturating_sub(1), 349 + _ => current, 350 + } 220 351 } 221 352 222 353 fn count_label(value: i64, noun: &str) -> String {
+135 -7
src/thing_detail.rs
··· 1 1 use dioxus::prelude::*; 2 2 use jacquard::common::types::ident::AtIdentifier; 3 3 use jacquard::common::types::recordkey::Rkey; 4 - use jacquard::common::types::string::AtUri; 4 + use jacquard::common::types::string::{AtUri, Cid}; 5 5 use jacquard::common::types::value::from_data; 6 + use jacquard_common::xrpc::XrpcClient; 7 + use polymodel_api::com_atproto::repo::strong_ref::StrongRef; 8 + use polymodel_api::space_polymodel::graph::{ 9 + create_like::CreateLike, create_save::CreateSave, delete_like::DeleteLike, 10 + delete_save::DeleteSave, 11 + }; 6 12 use polymodel_api::space_polymodel::library; 7 13 8 14 use crate::Route; 15 + use crate::client::PolymodelClient; 9 16 use crate::profile::profile_href; 17 + use crate::session::SessionIdentity; 10 18 use crate::viewer::{AssetViewer, ViewerFallbackImage, demo_models}; 11 19 12 20 #[derive(Clone, Debug, PartialEq, Eq)] ··· 70 78 } 71 79 72 80 #[cfg_attr(not(feature = "server"), allow(dead_code))] 81 + fn part_download_href(uri: &AtUri) -> Option<String> { 82 + let path = uri.path()?; 83 + if path.collection.as_str() != "space.polymodel.library.part" { 84 + return None; 85 + } 86 + let rkey = path.rkey?; 87 + Some(format!( 88 + "/app/parts/{}/{}/download", 89 + uri.authority().as_str(), 90 + rkey.as_ref() 91 + )) 92 + } 93 + 73 94 fn thing_uri_from_route(repo: &str, rkey: &str) -> Result<AtUri, String> { 74 95 let repo: AtIdentifier<String> = AtIdentifier::new_owned(repo) 75 96 .map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?; ··· 227 248 data: ThingDetailData, 228 249 mut selected_model_uri: Signal<Option<AtUri>>, 229 250 ) -> Element { 251 + let client = use_context::<PolymodelClient>(); 252 + let session = use_context::<Signal<SessionIdentity>>(); 230 253 let ThingDetailData { 231 254 thing, 232 255 thing_record, ··· 257 280 let part_count = count_label(thing.part_count, "part"); 258 281 let like_count = count_label(thing.like_count, "like"); 259 282 let save_count = count_label(thing.save_count, "save"); 283 + let mut viewer_like = use_signal(|| thing.viewer.like.clone()); 284 + let mut viewer_save = use_signal(|| thing.viewer.save.clone()); 285 + let mut action_pending = use_signal(|| false); 286 + let mut action_error = use_signal(|| None::<String>); 287 + let like_label = if viewer_like.read().is_some() { 288 + "Liked" 289 + } else { 290 + "Like" 291 + }; 292 + let save_label = if viewer_save.read().is_some() { 293 + "Saved" 294 + } else { 295 + "Save" 296 + }; 297 + let can_write = matches!(&*session.read(), SessionIdentity::Authenticated(_)); 298 + let subject = StrongRef { 299 + uri: thing.uri.clone(), 300 + cid: Cid::new_owned(thing.cid.as_str().as_bytes()).expect("thing cid is server-validated"), 301 + extra_data: None, 302 + }; 303 + let like_client = client.clone(); 304 + let like_subject = subject.clone(); 305 + let save_client = client.clone(); 306 + let save_subject = subject.clone(); 307 + let first_download = selected_model 308 + .as_ref() 309 + .and_then(|selected| selected.parts.first()) 310 + .and_then(|part| part_download_href(&part.view.uri)); 260 311 let instructions = thing_record.instructions; 261 312 262 313 rsx! { ··· 302 353 } 303 354 304 355 div { class: "thing-detail-actions", aria_label: "Thing actions", 305 - button { class: "button button-primary", "Download files" } 306 - button { class: "button button-secondary", disabled: true, aria_label: "Save disabled until sign in is available", "Save" } 356 + if let Some(href) = &first_download { 357 + a { class: "button button-primary", href: "{href}", "Download files" } 358 + } else { 359 + a { class: "button button-primary", href: "#parts-files", "View files" } 360 + } 361 + if can_write { 362 + button { 363 + class: "button button-secondary", 364 + disabled: *action_pending.read(), 365 + aria_pressed: "{viewer_like.read().is_some()}", 366 + onclick: move |_| { 367 + let client = like_client.clone(); 368 + let subject = like_subject.clone(); 369 + let current = viewer_like.read().clone(); 370 + action_pending.set(true); 371 + action_error.set(None); 372 + spawn(async move { 373 + let result = if let Some(uri) = current { 374 + match client.send(DeleteLike { uri: Some(uri), subject: None, extra_data: None }).await { 375 + Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 376 + Err(error) => Err(error.to_string()), 377 + } 378 + } else { 379 + match client.send(CreateLike { subject, extra_data: None }).await { 380 + Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 381 + Err(error) => Err(error.to_string()), 382 + } 383 + }; 384 + match result { 385 + Ok(next) => viewer_like.set(next), 386 + Err(error) => action_error.set(Some(format!("Like failed: {error}"))), 387 + } 388 + action_pending.set(false); 389 + }); 390 + }, 391 + "{like_label}" 392 + } 393 + button { 394 + class: "button button-secondary", 395 + disabled: *action_pending.read(), 396 + aria_pressed: "{viewer_save.read().is_some()}", 397 + onclick: move |_| { 398 + let client = save_client.clone(); 399 + let subject = save_subject.clone(); 400 + let current = viewer_save.read().clone(); 401 + action_pending.set(true); 402 + action_error.set(None); 403 + spawn(async move { 404 + let result = if let Some(uri) = current { 405 + match client.send(DeleteSave { uri: Some(uri), subject: None, extra_data: None }).await { 406 + Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 407 + Err(error) => Err(error.to_string()), 408 + } 409 + } else { 410 + match client.send(CreateSave { subject, note: None, extra_data: None }).await { 411 + Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 412 + Err(error) => Err(error.to_string()), 413 + } 414 + }; 415 + match result { 416 + Ok(next) => viewer_save.set(next), 417 + Err(error) => action_error.set(Some(format!("Save failed: {error}"))), 418 + } 419 + action_pending.set(false); 420 + }); 421 + }, 422 + "{save_label}" 423 + } 424 + } else { 425 + a { class: "button button-secondary", href: "/oauth/start", "Sign in to like" } 426 + a { class: "button button-secondary", href: "/oauth/start", "Sign in to save" } 427 + } 428 + if let Some(error) = action_error.read().as_ref() { 429 + p { class: "detail-action-error", role: "status", "{error}" } 430 + } 307 431 } 308 432 } 309 433 } ··· 343 467 article { class: "detail-panel model-detail-panel card-surface", 344 468 SelectedModelPanel { model, record } 345 469 } 346 - DetailPanel { title: "Parts & files".to_string(), 470 + DetailPanel { title: "Parts & files".to_string(), id: Some("parts-files".to_string()), 347 471 PartsPanel { parts } 348 472 } 349 473 } 350 474 } 351 475 } else { 352 - DetailPanel { title: "Parts & files".to_string(), 476 + DetailPanel { title: "Parts & files".to_string(), id: Some("parts-files".to_string()), 353 477 p { class: "detail-muted", "Select a model to load its ordered parts and files." } 354 478 } 355 479 } ··· 359 483 360 484 #[allow(clippy::useless_format)] 361 485 #[component] 362 - fn DetailPanel(title: String, children: Element) -> Element { 486 + fn DetailPanel(title: String, #[props(default)] id: Option<String>, children: Element) -> Element { 363 487 rsx! { 364 - article { class: "detail-panel card-surface", 488 + article { class: "detail-panel card-surface", id: id.unwrap_or_default(), 365 489 div { class: "detail-panel-heading", 366 490 h2 { "{title}" } 367 491 } ··· 525 649 .iter() 526 650 .filter_map(|setting| clean_text(Some(setting.as_ref())).map(ToString::to_string)) 527 651 .collect::<Vec<_>>(); 652 + let download_href = part_download_href(&part.view.uri); 528 653 529 654 rsx! { 530 655 article { class: "part-row", role: "listitem", ··· 547 672 li { "{setting}" } 548 673 } 549 674 } 675 + } 676 + if let Some(href) = &download_href { 677 + a { class: "button button-secondary part-download", href: "{href}", "Download part" } 550 678 } 551 679 } 552 680 }