small constellation + pds based little profile viewer karitham.tngl.io/gpreview?user=karitham.dev
gleam bsky-profile
0
fork

Configure Feed

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

some refactoring

karitham 809d3f5f 17db4a2b

+1263 -855
+531 -117
src/gpreview.css
··· 1 1 @import "tailwindcss"; 2 2 3 + /* ─── Google Fonts ─── */ 4 + @import url("https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=Sora:wght@300;400;500;600;700&display=swap"); 5 + 6 + /* ─── Catppuccin Macchiatto Palette ─── */ 3 7 :root { 4 - --color-sky-50: #f0f9ff; 5 - --color-sky-100: #e0f2fe; 6 - --color-sky-200: #bae6fd; 7 - --color-sky-300: #7dd3fc; 8 - --color-sky-400: #38bdf8; 9 - --color-sky-500: #0ea5e9; 10 - --color-sky-600: #0284c7; 11 - --color-sky-700: #0369a1; 12 - --color-sky-800: #075985; 13 - --color-sky-900: #0c4a6e; 8 + /* Colors */ 9 + --rose: #f5c2e7; 10 + --flamingo: #f2cdcd; 11 + --pink: #f5c2e7; 12 + --mauve: #cba6f7; 13 + --red: #f38ba8; 14 + --maroon: #eba0ac; 15 + --peach: #fab387; 16 + --yellow: #f9e2af; 17 + --green: #a6e3a1; 18 + --teal: #94e2d5; 19 + --cyan: #89dceb; 20 + --blue: #89b4fa; 21 + --lavender: #b4befe; 22 + 23 + /* Text */ 24 + --text: #cdd6f4; 25 + --subtext1: #bac2de; 26 + --subtext0: #a6adc8; 27 + --overlay2: #9399b2; 28 + --overlay1: #7f849c; 29 + --overlay0: #6c7086; 30 + 31 + /* Surface */ 32 + --surface2: #585b70; 33 + --surface1: #45475a; 34 + --surface0: #313244; 35 + 36 + /* Base */ 37 + --crust: #11111b; 38 + --mantle: #181825; 39 + --base: #1e1e2e; 40 + 41 + /* Application mapping */ 42 + --bg: var(--base); 43 + --surface: var(--surface0); 44 + --surface-hover: var(--surface1); 45 + --surface-active: var(--surface2); 46 + --surface-elevated: var(--surface0); 47 + 48 + --text-primary: var(--text); 49 + --text-secondary: var(--subtext1); 50 + --text-muted: var(--subtext0); 51 + --text-on-brand: var(--crust); 52 + 53 + --border: var(--surface1); 54 + --border-focus: var(--blue); 55 + 56 + --brand: var(--blue); 57 + --brand-hover: var(--lavender); 58 + --brand-glow: var(--blue); 59 + 60 + --accent-magenta: var(--mauve); 61 + --accent-magenta-subtle: var(--surface1); 62 + 63 + --accent-cyan: var(--cyan); 64 + --accent-emerald: var(--green); 65 + 66 + /* Shadows */ 67 + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3); 68 + --shadow-card-hover: 0 4px 8px rgba(0, 0, 0, 0.4); 69 + --shadow-glow: 0 0 8px var(--brand-glow); 70 + 71 + /* Spacing */ 72 + --space-1: 4px; 73 + --space-2: 8px; 74 + --space-3: 12px; 75 + --space-4: 16px; 76 + --space-5: 24px; 77 + --space-6: 32px; 78 + --space-7: 48px; 79 + --space-8: 64px; 80 + 81 + /* Border radii */ 82 + --radius-sm: 2px; 83 + --radius-md: 4px; 84 + --radius-lg: 6px; 85 + --radius-xl: 8px; 86 + --radius-full: 9999px; 87 + 88 + /* Typography */ 89 + --font-display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif; 90 + --font-body: "Sora", ui-sans-serif, system-ui, sans-serif; 91 + 92 + /* Animation */ 93 + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); 94 + --ease-spring: cubic-bezier(0.15, 0.9, 0.35, 1); 95 + --duration-fast: 120ms; 96 + --duration-normal: 200ms; 97 + --duration-slow: 350ms; 14 98 } 15 99 100 + /* ─── Base ─── */ 16 101 body { 17 - background-color: var(--color-sky-50); 102 + background-color: var(--base); 103 + font-family: var(--font-body); 104 + color: var(--text); 18 105 -webkit-font-smoothing: antialiased; 19 106 -moz-osx-font-smoothing: grayscale; 107 + font-size: 16px; 108 + line-height: 1.6; 20 109 } 21 110 22 - .card { 23 - background-color: white; 24 - border-radius: 0.75rem; 25 - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); 26 - overflow: hidden; 27 - transition: box-shadow 0.2s ease; 111 + /* ─── App Shell ─── */ 112 + .app-shell { 113 + max-width: 840px; 114 + margin: 0 auto; 115 + padding: var(--space-6) var(--space-4); 28 116 } 29 117 30 - .card:hover { 31 - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 118 + @media (min-width: 640px) { 119 + .app-shell { 120 + padding: var(--space-7) var(--space-6); 121 + } 32 122 } 33 123 34 - .btn-primary { 35 - padding: 1rem 1.5rem; 36 - background-color: var(--color-sky-500); 37 - color: white; 38 - border-radius: 0.75rem; 39 - font-weight: 500; 40 - transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.1s ease; 41 - white-space: nowrap; 124 + /* ─── Input Zone ─── */ 125 + .input-zone { 126 + display: flex; 127 + flex-direction: column; 128 + gap: var(--space-4); 129 + margin-bottom: var(--space-7); 42 130 } 43 131 44 - .btn-primary:hover { 45 - background-color: var(--color-sky-600); 132 + .input-zone__row { 133 + display: flex; 134 + gap: var(--space-3); 135 + align-items: stretch; 46 136 } 47 137 48 - .btn-primary:active { 49 - transform: scale(0.96); 138 + /* ─── Input Field ─── */ 139 + .input-field { 140 + flex-grow: 1; 141 + min-width: 0; 142 + padding: var(--space-4) var(--space-5); 143 + font-family: var(--font-body); 144 + font-size: 1rem; 145 + color: var(--text); 146 + background: var(--surface0); 147 + border: 1px solid var(--border); 148 + border-radius: var(--radius-md); 149 + transition: border-color var(--duration-normal) var(--ease-out), 150 + box-shadow var(--duration-normal) var(--ease-out), 151 + background-color var(--duration-fast) var(--ease-out); 152 + } 153 + 154 + .input-field::placeholder { 155 + color: var(--subtext0); 50 156 } 51 157 52 - .btn-primary:focus { 158 + .input-field:focus { 53 159 outline: none; 54 - box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.3); 160 + border-color: var(--blue); 161 + box-shadow: var(--shadow-glow); 162 + background-color: var(--surface1); 163 + } 164 + 165 + /* ─── Button ─── */ 166 + .btn-show { 167 + flex-shrink: 0; 168 + padding: var(--space-4) var(--space-6); 169 + font-family: var(--font-display); 170 + font-size: 1rem; 171 + font-weight: 700; 172 + color: var(--crust); 173 + background: var(--blue); 174 + border: none; 175 + border-radius: var(--radius-md); 176 + white-space: nowrap; 177 + cursor: pointer; 178 + transition: transform var(--duration-fast) var(--ease-spring), 179 + box-shadow var(--duration-normal) var(--ease-out), 180 + filter var(--duration-fast) var(--ease-out); 181 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 182 + } 183 + 184 + .btn-show:hover { 185 + filter: brightness(1.1); 186 + box-shadow: 0 4px 16px rgba(137, 180, 250, 0.4); 187 + } 188 + 189 + .btn-show:active { 190 + transform: scale(0.97); 191 + } 192 + 193 + .btn-show:focus-visible { 194 + outline: 2px solid var(--lavender); 195 + outline-offset: 2px; 55 196 } 56 197 57 - .input-primary { 58 - padding: 1rem 1.25rem; 59 - border: 1px solid #cbd5e1; 60 - border-radius: 0.75rem; 61 - transition: border-color 0.2s ease, box-shadow 0.2s ease; 198 + /* ─── Post Card ─── */ 199 + .post-card { 200 + background: var(--surface0); 201 + border-radius: var(--radius-xl); 202 + box-shadow: var(--shadow-card); 203 + overflow: hidden; 204 + transition: transform var(--duration-normal) var(--ease-spring), 205 + box-shadow var(--duration-normal) var(--ease-out); 206 + border: 1px solid var(--border); 62 207 } 63 208 64 - .input-primary:focus { 65 - outline: none; 66 - border-color: var(--color-sky-500); 67 - box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1); 209 + .post-card:hover { 210 + transform: translateY(-2px); 211 + box-shadow: var(--shadow-card-hover); 68 212 } 69 213 70 - .input-primary::placeholder { 71 - color: #94a3b8; 214 + /* ─── Post Header ─── */ 215 + .post-header { 216 + display: flex; 217 + align-items: center; 218 + gap: var(--space-3); 219 + padding: var(--space-4) var(--space-5) var(--space-3); 220 + border-bottom: 1px solid var(--surface1); 72 221 } 73 222 74 - .avatar { 223 + .post-header__info { 224 + display: flex; 225 + flex-direction: column; 226 + min-width: 0; 227 + } 228 + 229 + .post-header__name { 230 + font-family: var(--font-display); 231 + font-weight: 700; 232 + font-size: 1.05rem; 233 + line-height: 1.3; 234 + color: var(--text); 235 + } 236 + 237 + .post-header__handle { 238 + font-size: 0.85rem; 239 + line-height: 1.3; 240 + color: var(--subtext0); 241 + margin-top: 2px; 242 + } 243 + 244 + .post-header__bio { 245 + font-size: 0.75rem; 246 + line-height: 1.3; 247 + color: var(--subtext1); 248 + margin-top: var(--space-1); 249 + overflow: hidden; 250 + text-overflow: ellipsis; 251 + white-space: nowrap; 252 + } 253 + 254 + /* ─── Avatar Ring ─── */ 255 + .avatar-ring { 75 256 width: 3.5rem; 76 257 height: 3.5rem; 77 258 border-radius: 50%; 78 259 object-fit: cover; 79 260 flex-shrink: 0; 80 - box-shadow: 0 0 0 2px #e0f2fe, 0 0 0 4px white; 261 + background: var(--mauve); 262 + padding: 2px; 81 263 } 82 264 265 + /* ─── Avatar Fallbacks ─── */ 83 266 .avatar-fallback { 84 267 width: 3.5rem; 85 268 height: 3.5rem; 86 269 border-radius: 50%; 87 - background: linear-gradient(135deg, var(--color-sky-400), var(--color-sky-600)); 270 + background: var(--mauve); 88 271 display: flex; 89 272 align-items: center; 90 273 justify-content: center; 91 - color: white; 274 + color: var(--crust); 275 + font-family: var(--font-display); 92 276 font-size: 1.25rem; 93 - font-weight: 700; 277 + font-weight: 800; 94 278 flex-shrink: 0; 95 - box-shadow: 0 0 0 2px #e0f2fe, 0 0 0 4px white; 96 279 } 97 280 98 - .avatar-fallback-sm { 99 - width: 3rem; 100 - height: 3rem; 101 - border-radius: 50%; 102 - background: linear-gradient(135deg, var(--color-sky-400), var(--color-sky-600)); 103 - display: flex; 104 - align-items: center; 105 - justify-content: center; 106 - color: white; 107 - font-size: 1.125rem; 108 - font-weight: 700; 109 - flex-shrink: 0; 110 - box-shadow: 0 0 0 2px #e0f2fe; 111 - } 112 - 113 - .loading-spinner { 114 - animation: spin 1s linear infinite; 115 - border-radius: 50%; 116 - border: 2px solid #e2e8f0; 117 - border-top-color: var(--color-sky-500); 118 - } 119 - 120 - @keyframes spin { 121 - to { 122 - transform: rotate(360deg); 123 - } 124 - } 125 - 126 - .link-card { 127 - display: block; 128 - border-radius: 0.5rem; 129 - overflow: hidden; 130 - transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; 281 + /* ─── Post Body ─── */ 282 + .post-body { 283 + padding: var(--space-4) var(--space-5); 284 + color: var(--text); 285 + font-size: 1.05rem; 286 + line-height: 1.7; 287 + white-space: pre-wrap; 288 + word-break: break-word; 289 + text-wrap: pretty; 131 290 } 132 291 133 - .link-card:hover { 134 - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04); 135 - background-color: #f8fafc; 292 + /* ─── Post Embed ─── */ 293 + .post-embed { 294 + padding: 0 var(--space-5) var(--space-4); 136 295 } 137 296 297 + /* ─── Image Grid ─── */ 138 298 .image-grid { 139 299 display: grid; 140 300 grid-template-columns: repeat(2, 1fr); 141 - gap: 0.5rem; 142 - border-radius: 0.5rem; 301 + gap: var(--space-2); 302 + border-radius: var(--radius-md); 143 303 overflow: hidden; 144 - margin-top: 1rem; 304 + margin-top: var(--space-4); 305 + border: 1px solid var(--border); 145 306 } 146 307 147 308 .image-grid img { 148 309 width: 100%; 149 310 height: auto; 150 311 object-fit: cover; 151 - transition: transform 0.2s ease; 152 - outline: 1px solid rgba(0, 0, 0, 0.08); 312 + outline: 1px solid var(--surface1); 313 + transition: transform var(--duration-normal) var(--ease-out), 314 + filter var(--duration-normal) var(--ease-out); 153 315 } 154 316 155 317 .image-grid img:hover { 156 - transform: scale(1.05); 318 + transform: scale(1.02); 319 + filter: brightness(1.1); 157 320 } 158 321 159 - .label-badge { 160 - display: inline-flex; 161 - align-items: center; 162 - padding: 0.125rem 0.625rem; 163 - border-radius: 9999px; 164 - font-size: 0.75rem; 165 - font-weight: 500; 166 - background-color: #fef3c7; 167 - color: #b45309; 168 - box-shadow: 0 0 0 1px rgba(252, 211, 77, 0.4); 322 + /* ─── Link Card ─── */ 323 + .link-card { 324 + display: block; 325 + background: var(--surface1); 326 + border: 1px solid var(--border); 327 + border-radius: var(--radius-md); 328 + overflow: hidden; 329 + text-decoration: none; 330 + transition: transform var(--duration-normal) var(--ease-spring), 331 + border-color var(--duration-fast) var(--ease-out), 332 + background-color var(--duration-fast) var(--ease-out); 169 333 } 170 334 171 - .post-content { 172 - color: #334155; 173 - font-size: 1.125rem; 174 - line-height: 1.75; 175 - white-space: pre-wrap; 176 - word-break: break-word; 177 - text-wrap: pretty; 335 + .link-card:hover { 336 + transform: translateY(-2px); 337 + border-color: var(--blue); 338 + background-color: var(--surface2); 178 339 } 179 340 180 341 .external-link-preview { 181 - padding: 1rem; 342 + padding: var(--space-4); 182 343 } 183 344 184 345 .external-link-title { 346 + font-family: var(--font-display); 185 347 font-weight: 600; 186 - color: #0f172a; 187 - font-size: 1rem; 188 - margin-bottom: 0.25rem; 348 + color: var(--text); 349 + font-size: 0.95rem; 350 + line-height: 1.4; 351 + margin-bottom: var(--space-1); 189 352 text-wrap: balance; 190 353 } 191 354 192 355 .external-link-desc { 193 - font-size: 0.875rem; 194 - color: #475569; 356 + font-size: 0.85rem; 357 + color: var(--subtext1); 358 + line-height: 1.5; 195 359 display: -webkit-box; 196 360 -webkit-line-clamp: 2; 197 361 -webkit-box-orient: vertical; 198 362 overflow: hidden; 199 363 } 200 364 201 - .tabular-nums { 202 - font-variant-numeric: tabular-nums; 365 + /* ─── Post Footer ─── */ 366 + .post-footer { 367 + display: flex; 368 + align-items: center; 369 + justify-content: space-between; 370 + margin-top: var(--space-4); 371 + padding: var(--space-3) var(--space-5); 372 + border-top: 1px solid var(--border); 203 373 } 204 374 375 + /* ─── Engagement Bar ─── */ 376 + .engagement-bar { 377 + display: flex; 378 + align-items: center; 379 + gap: var(--space-3); 380 + } 381 + 382 + .engagement-bar__likes { 383 + color: var(--mauve); 384 + } 385 + 386 + .engagement-bar__reposts { 387 + color: var(--green); 388 + } 389 + 390 + .engagement-bar__replies { 391 + color: var(--blue); 392 + } 393 + 394 + /* ─── Footer Badges & Timestamp ─── */ 395 + .post-footer__badges { 396 + display: flex; 397 + align-items: center; 398 + gap: var(--space-3); 399 + } 400 + 401 + .post-footer__timestamp { 402 + font-size: 0.8rem; 403 + color: var(--subtext0); 404 + white-space: nowrap; 405 + } 406 + 407 + /* ─── Badge ─── */ 408 + .badge { 409 + display: inline-flex; 410 + align-items: center; 411 + padding: 3px 10px; 412 + border-radius: var(--radius-sm); 413 + font-size: 0.75rem; 414 + font-weight: 600; 415 + background: var(--surface1); 416 + color: var(--peach); 417 + border: 1px solid var(--surface2); 418 + } 419 + 420 + /* ─── Tag Badge ─── */ 421 + .tag-badge { 422 + display: inline-flex; 423 + align-items: center; 424 + padding: 3px 10px; 425 + border-radius: var(--radius-sm); 426 + font-size: 0.75rem; 427 + font-weight: 600; 428 + font-family: var(--font-display); 429 + background: var(--surface1); 430 + color: var(--mauve); 431 + border: 1px solid var(--mauve); 432 + text-decoration: none; 433 + transition: background-color var(--duration-fast) var(--ease-out), 434 + transform var(--duration-fast) var(--ease-spring); 435 + } 436 + 437 + .tag-badge:hover { 438 + background: var(--mauve); 439 + color: var(--crust); 440 + transform: scale(1.03); 441 + } 442 + 443 + /* ─── Facet Links ─── */ 444 + .mention-link { 445 + color: var(--blue); 446 + font-weight: 600; 447 + text-decoration: none; 448 + text-decoration-line: underline; 449 + text-decoration-color: var(--surface2); 450 + text-underline-offset: 2px; 451 + transition: color var(--duration-fast) var(--ease-out); 452 + } 453 + 454 + .mention-link:hover { 455 + color: var(--lavender); 456 + text-decoration-color: var(--lavender); 457 + } 458 + 459 + .link-facet { 460 + color: var(--green); 461 + font-weight: 500; 462 + text-decoration: none; 463 + text-decoration-line: underline; 464 + text-decoration-color: var(--surface2); 465 + text-underline-offset: 2px; 466 + transition: color var(--duration-fast) var(--ease-out); 467 + } 468 + 469 + .link-facet:hover { 470 + color: var(--teal); 471 + text-decoration-color: var(--teal); 472 + } 473 + 474 + .tag-link { 475 + color: var(--mauve); 476 + font-weight: 600; 477 + } 478 + 479 + .tag-link a { 480 + color: inherit; 481 + text-decoration: none; 482 + transition: color var(--duration-fast) var(--ease-out); 483 + } 484 + 485 + .tag-link a:hover { 486 + color: var(--pink); 487 + } 488 + 489 + /* ─── Reply Context ─── */ 490 + .reply-context { 491 + padding: var(--space-2) var(--space-5); 492 + font-size: 0.78rem; 493 + color: var(--subtext0); 494 + background: var(--mantle); 495 + } 496 + 497 + .reply-context__label { 498 + color: var(--subtext0); 499 + opacity: 0.7; 500 + } 501 + 502 + /* ─── Loading Skeleton ─── */ 503 + .loading-skeleton { 504 + background: var(--surface1); 505 + position: relative; 506 + overflow: hidden; 507 + border-radius: var(--radius-md); 508 + border: 1px solid var(--border); 509 + } 510 + 511 + .loading-skeleton::after { 512 + content: ""; 513 + position: absolute; 514 + top: 0; 515 + left: -150%; 516 + width: 50%; 517 + height: 100%; 518 + background: var(--surface2); 519 + animation: shimmer 1.5s ease-in-out infinite; 520 + } 521 + 522 + @keyframes shimmer { 523 + 0% { transform: translateX(0); } 524 + 100% { transform: translateX(300%); } 525 + } 526 + 527 + /* ─── Error State ─── */ 528 + .error-state { 529 + padding: var(--space-5); 530 + color: var(--red); 531 + font-weight: 500; 532 + text-align: center; 533 + background: var(--surface1); 534 + border-radius: var(--radius-lg); 535 + border: 1px solid var(--red); 536 + } 537 + 538 + /* ─── Empty State ─── */ 539 + .empty-state { 540 + padding: var(--space-8) var(--space-5); 541 + text-align: center; 542 + color: var(--subtext0); 543 + } 544 + 545 + .empty-state__title { 546 + font-family: var(--font-display); 547 + font-size: 1.25rem; 548 + font-weight: 700; 549 + color: var(--subtext1); 550 + text-wrap: balance; 551 + margin-bottom: var(--space-2); 552 + } 553 + 554 + .empty-state__desc { 555 + font-size: 0.95rem; 556 + line-height: 1.6; 557 + text-wrap: pretty; 558 + } 559 + 560 + /* ─── Animations ─── */ 561 + @keyframes fadeSlideIn { 562 + from { 563 + opacity: 0; 564 + transform: translateY(12px); 565 + filter: blur(4px); 566 + } 567 + to { 568 + opacity: 1; 569 + transform: translateY(0); 570 + filter: blur(0); 571 + } 572 + } 573 + 574 + .stagger-1 { 575 + animation: fadeSlideIn var(--duration-slow) var(--ease-out) both; 576 + animation-delay: 0ms; 577 + } 578 + 579 + .stagger-2 { 580 + animation: fadeSlideIn var(--duration-slow) var(--ease-out) both; 581 + animation-delay: 60ms; 582 + } 583 + 584 + .stagger-3 { 585 + animation: fadeSlideIn var(--duration-slow) var(--ease-out) both; 586 + animation-delay: 120ms; 587 + } 588 + 589 + /* ─── Reduced Motion ─── */ 205 590 @media (prefers-reduced-motion: reduce) { 206 591 *, 207 592 *::before, ··· 211 596 transition-duration: 0.01ms !important; 212 597 } 213 598 } 599 + 600 + /* ─── Utility ─── */ 601 + .tabular-nums { 602 + font-variant-numeric: tabular-nums; 603 + } 604 + 605 + /* ─── Error Badge ─── */ 606 + .error-badge { 607 + margin-top: var(--space-3); 608 + font-size: 0.8rem; 609 + color: var(--red); 610 + background: var(--surface0); 611 + padding: var(--space-2) var(--space-3); 612 + border-radius: var(--radius-lg); 613 + } 614 + 615 + /* ─── Image Cover ─── */ 616 + .image-cover { 617 + width: 100%; 618 + height: auto; 619 + object-fit: cover; 620 + } 621 + 622 + /* ─── Badge Group ─── */ 623 + .badge-group { 624 + display: flex; 625 + gap: var(--space-1); 626 + flex-wrap: wrap; 627 + }
+39 -733
src/gpreview.gleam
··· 1 - import bsky/decoders 2 - import gleam/dynamic/decode.{field, string, success} 3 - import gleam/int 4 - import gleam/list 5 - import gleam/option.{type Option, None, Some} 6 - import gleam/string 7 - import gleam/uri 1 + import gleam/option.{None, Some} 2 + import gpreview/effects 3 + import gpreview/record.{Record} 4 + import gpreview/types.{ 5 + type Model, type Msg, App, LinkWasSet, MiniDocWasResolved, PostWasFetched, 6 + ProfileWasFetched, ThreadWasFetched, UserClickedShow, 7 + } 8 + import gpreview/views 8 9 import lustre 9 - import lustre/attribute 10 10 import lustre/effect.{type Effect} 11 - import lustre/element.{type Element} 12 - import lustre/element/html 13 - import lustre/event 14 11 import rsvp 15 12 13 + // Re-export functions needed by tests 14 + pub fn extract_did_from_uri(uri: String) -> Result(String, Nil) { 15 + effects.extract_did_from_uri(uri) 16 + } 17 + 18 + pub fn query_from_at_uri(at_url: String) -> Result(String, Nil) { 19 + effects.query_from_at_uri(at_url) 20 + } 21 + 22 + pub fn construct_profile_uri(did: String) -> String { 23 + effects.construct_profile_uri(did) 24 + } 25 + 26 + pub fn error_to_string(e: rsvp.Error) -> String { 27 + types.error_to_string(e) 28 + } 29 + 16 30 pub fn main() { 17 - let app = lustre.application(init, update, view) 31 + let app = lustre.application(init, update, views.view) 18 32 let assert Ok(_) = lustre.start(app, "#app", Nil) 19 33 20 34 Nil 21 35 } 22 36 23 - pub type Model { 24 - App( 25 - at_url: String, 26 - did_doc: Option(Result(decoders.MiniDocJson, String)), 27 - post: Option(Result(Record(decoders.PostJson), String)), 28 - profile: Option(Result(decoders.ProfileJson, String)), 29 - thread_counts: Option(decoders.ThreadCountsJson), 30 - ) 31 - } 32 - 33 - fn init(_args) -> #(Model, Effect(Msg)) { 37 + fn init(_args: Nil) -> #(Model, Effect(Msg)) { 34 38 #( 35 39 App( 36 40 "at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c", ··· 43 47 ) 44 48 } 45 49 46 - pub type Msg { 47 - LinkWasSet(String) 48 - UserClickedShow 49 - MiniDocWasResolved(Result(decoders.MiniDocJson, rsvp.Error)) 50 - PostWasFetched(Result(Record(decoders.PostJson), rsvp.Error)) 51 - ProfileWasFetched(Result(Record(decoders.ProfileJson), rsvp.Error)) 52 - ThreadWasFetched(Result(decoders.ThreadCountsJson, rsvp.Error)) 53 - } 54 - 55 50 pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 56 51 case msg { 57 52 LinkWasSet(url) -> #(App(..model, at_url: url), effect.none()) 58 53 UserClickedShow -> 59 - case extract_did_from_uri(model.at_url) { 60 - Ok(did) -> #(model, resolve_mini_doc(did)) 54 + case effects.extract_did_from_uri(model.at_url) { 55 + Ok(did) -> #(model, effects.resolve_mini_doc(did)) 61 56 Error(_) -> #( 62 57 App(..model, post: Some(Error("Invalid AT-URI"))), 63 58 effect.none(), ··· 65 60 } 66 61 MiniDocWasResolved(Ok(mini_doc)) -> #( 67 62 App(..model, did_doc: Some(Ok(mini_doc))), 68 - get_record(mini_doc.pds, model.at_url), 63 + effects.get_record(mini_doc.pds, model.at_url), 69 64 ) 70 65 MiniDocWasResolved(Error(e)) -> #( 71 66 App( 72 67 ..model, 73 - post: Some(Error("Failed to resolve identity: " <> error_to_string(e))), 68 + post: Some(Error( 69 + "Failed to resolve identity: " <> types.error_to_string(e), 70 + )), 74 71 ), 75 72 effect.none(), 76 73 ) ··· 78 75 case model.did_doc { 79 76 Some(Ok(doc)) -> #( 80 77 App(..model, post: Some(Ok(p))), 81 - fetch_profile(doc.pds, doc.did), 78 + effects.fetch_profile(doc.pds, doc.did), 82 79 ) 83 80 _ -> #(App(..model, post: Some(Ok(p))), effect.none()) 84 81 } 85 82 } 86 83 PostWasFetched(Error(e)) -> #( 87 - App(..model, post: Some(Error(error_to_string(e)))), 84 + App(..model, post: Some(Error(types.error_to_string(e)))), 88 85 effect.none(), 89 86 ) 90 87 ProfileWasFetched(Ok(p)) -> { ··· 93 90 case model.did_doc { 94 91 Some(Ok(doc)) -> #( 95 92 App(..model, profile: Some(Ok(p.value))), 96 - fetch_thread_counts(doc.pds, uri), 93 + effects.fetch_thread_counts(doc.pds, uri), 97 94 ) 98 95 _ -> #(App(..model, profile: Some(Ok(p.value))), effect.none()) 99 96 } ··· 103 100 ProfileWasFetched(Error(e)) -> #( 104 101 App( 105 102 ..model, 106 - profile: Some(Error("Failed to fetch profile: " <> error_to_string(e))), 103 + profile: Some(Error( 104 + "Failed to fetch profile: " <> types.error_to_string(e), 105 + )), 107 106 ), 108 107 effect.none(), 109 108 ) ··· 114 113 ThreadWasFetched(Error(_e)) -> #(model, effect.none()) 115 114 } 116 115 } 117 - 118 - pub fn error_to_string(e: rsvp.Error) -> String { 119 - case e { 120 - rsvp.BadBody -> "Invalid response body" 121 - rsvp.BadUrl(url) -> "Invalid URL: " <> url 122 - rsvp.HttpError(resp) -> "HTTP error: " <> int.to_string(resp.status) 123 - rsvp.JsonError(_) -> "Failed to parse JSON response" 124 - rsvp.NetworkError -> "Network error - check your connection" 125 - rsvp.UnhandledResponse(resp) -> 126 - "Unexpected response: " <> int.to_string(resp.status) 127 - } 128 - } 129 - 130 - fn view(model: Model) -> Element(Msg) { 131 - html.div( 132 - [ 133 - attribute.attribute("class", "min-h-screen p-6 sm:p-12"), 134 - ], 135 - [ 136 - html.div( 137 - [ 138 - attribute.attribute("class", "max-w-2xl mx-auto"), 139 - ], 140 - [ 141 - html.div( 142 - [ 143 - attribute.attribute("class", "mb-10"), 144 - ], 145 - [url_input(model.at_url, get_post_error(model.post))], 146 - ), 147 - display_post(model), 148 - ], 149 - ), 150 - ], 151 - ) 152 - } 153 - 154 - fn get_post_error( 155 - post: Option(Result(Record(decoders.PostJson), String)), 156 - ) -> Option(String) { 157 - case post { 158 - Some(Error(e)) -> Some(e) 159 - _ -> None 160 - } 161 - } 162 - 163 - fn display_post(model: Model) -> Element(Msg) { 164 - case model.post, model.profile { 165 - Some(_), Some(_) -> 166 - post_card(model.post, model.profile, model.did_doc, model.thread_counts) 167 - Some(Ok(_)), None -> loading_state() 168 - _, _ -> element.none() 169 - } 170 - } 171 - 172 - fn loading_state() -> Element(Msg) { 173 - html.div( 174 - [ 175 - attribute.attribute("class", "card"), 176 - ], 177 - [ 178 - html.div( 179 - [ 180 - attribute.attribute( 181 - "class", 182 - "flex items-center justify-center gap-2 text-slate-500 py-12", 183 - ), 184 - ], 185 - [ 186 - html.div( 187 - [ 188 - attribute.attribute("class", "loading-spinner h-6 w-6"), 189 - ], 190 - [], 191 - ), 192 - html.text("Loading profile..."), 193 - ], 194 - ), 195 - ], 196 - ) 197 - } 198 - 199 - fn post_card( 200 - post_opt: Option(Result(Record(decoders.PostJson), String)), 201 - profile_opt: Option(Result(decoders.ProfileJson, String)), 202 - did_doc: Option(Result(decoders.MiniDocJson, String)), 203 - thread_counts: Option(decoders.ThreadCountsJson), 204 - ) -> Element(Msg) { 205 - case post_opt, profile_opt, did_doc { 206 - Some(Ok(Record(uri: _, cid: _, value: post))), 207 - Some(Ok(profile)), 208 - Some(Ok(doc)) 209 - -> { 210 - let reply_context = case post.reply { 211 - Some(reply) -> { 212 - let parent_did = 213 - reply.parent.uri 214 - |> extract_did_from_uri 215 - |> fn(r) { 216 - case r { 217 - Ok(d) -> d 218 - Error(_) -> "unknown" 219 - } 220 - } 221 - let short_did = string.slice(parent_did, 0, 20) <> "…" 222 - html.div( 223 - [ 224 - attribute.attribute("class", "px-6 pb-2 text-xs text-slate-500"), 225 - ], 226 - [ 227 - html.span( 228 - [ 229 - attribute.attribute("class", "text-slate-400"), 230 - ], 231 - [html.text("Replying to " <> short_did)], 232 - ), 233 - ], 234 - ) 235 - } 236 - None -> element.none() 237 - } 238 - 239 - html.div( 240 - [ 241 - attribute.attribute("class", "card"), 242 - ], 243 - [ 244 - post_header(profile, doc.handle, doc.did, doc.pds), 245 - html.div( 246 - [ 247 - attribute.attribute("class", "px-6 pb-4"), 248 - ], 249 - [ 250 - reply_context, 251 - html.p( 252 - [ 253 - attribute.attribute("class", "post-content"), 254 - attribute.attribute("style", "text-wrap: pretty"), 255 - ], 256 - render_post_with_facets(post.text, post.facets, doc.pds), 257 - ), 258 - post_embed(post.embed, doc.pds, doc.did), 259 - post_footer( 260 - post.labels, 261 - post.tags, 262 - post.created_at, 263 - thread_counts, 264 - ), 265 - ], 266 - ), 267 - ], 268 - ) 269 - } 270 - Some(Error(e)), _, _ | _, Some(Error(e)), _ | _, _, Some(Error(e)) -> 271 - html.text(e) 272 - _, _, _ -> element.none() 273 - } 274 - } 275 - 276 - fn post_header( 277 - profile: decoders.ProfileJson, 278 - handle: String, 279 - did: String, 280 - pds_host: String, 281 - ) -> Element(Msg) { 282 - let display_name_el = case profile.display_name { 283 - Some(name) -> 284 - html.span( 285 - [ 286 - attribute.attribute( 287 - "class", 288 - "font-semibold text-slate-900 text-base leading-tight", 289 - ), 290 - ], 291 - [html.text(name)], 292 - ) 293 - None -> element.none() 294 - } 295 - 296 - let description_el = case profile.description { 297 - Some(desc) -> 298 - html.p( 299 - [ 300 - attribute.attribute( 301 - "class", 302 - "text-xs text-slate-500 leading-tight mt-0.5 truncate", 303 - ), 304 - ], 305 - [html.text(desc)], 306 - ) 307 - None -> element.none() 308 - } 309 - 310 - html.div( 311 - [ 312 - attribute.attribute( 313 - "class", 314 - "flex items-center gap-3 mb-4 px-6 pt-4 pb-2", 315 - ), 316 - ], 317 - [ 318 - case profile.avatar { 319 - Some(blob) -> 320 - html.img([ 321 - attribute.attribute("src", blob_ref_to_url(pds_host, did, blob)), 322 - attribute.attribute("alt", "Avatar"), 323 - attribute.attribute("class", "avatar"), 324 - attribute.attribute("referrerpolicy", "no-referrer"), 325 - ]) 326 - None -> 327 - html.div( 328 - [ 329 - attribute.attribute("class", "avatar-fallback-sm"), 330 - ], 331 - [], 332 - ) 333 - }, 334 - html.div( 335 - [ 336 - attribute.attribute("class", "flex flex-col min-w-0"), 337 - ], 338 - [ 339 - display_name_el, 340 - html.div( 341 - [ 342 - attribute.attribute( 343 - "class", 344 - "text-sm text-slate-500 leading-tight mt-0.5", 345 - ), 346 - ], 347 - [html.text("@" <> handle)], 348 - ), 349 - description_el, 350 - ], 351 - ), 352 - ], 353 - ) 354 - } 355 - 356 - fn blob_ref_to_url(pds_host: String, did: String, blob: String) -> String { 357 - pds_host <> "/xrpc/com.atproto.sync.getBlob?did=" <> did <> "&cid=" <> blob 358 - } 359 - 360 - fn render_post_with_facets( 361 - text: String, 362 - facets: Option(List(decoders.FacetJson)), 363 - pds_host: String, 364 - ) -> List(Element(Msg)) { 365 - case facets { 366 - None -> [html.text(text)] 367 - Some(f) -> { 368 - let sorted = 369 - list.sort(f, fn(a, b) { 370 - int.compare(a.index.byte_start, b.index.byte_start) 371 - }) 372 - build_facet_elements(text, sorted, 0, pds_host) 373 - } 374 - } 375 - } 376 - 377 - fn build_facet_elements( 378 - text: String, 379 - facets: List(decoders.FacetJson), 380 - byte_offset: Int, 381 - pds_host: String, 382 - ) -> List(Element(Msg)) { 383 - case facets { 384 - [] -> { 385 - let remaining = 386 - string.slice(text, byte_offset, string.length(text) - byte_offset) 387 - case string.is_empty(remaining) { 388 - True -> [] 389 - False -> [html.text(remaining)] 390 - } 391 - } 392 - [facet, ..rest] -> { 393 - let start = facet.index.byte_start 394 - let end = facet.index.byte_end 395 - 396 - let before = case start > byte_offset { 397 - True -> { 398 - let segment = string.slice(text, byte_offset, start - byte_offset) 399 - case string.is_empty(segment) { 400 - True -> [] 401 - False -> [html.text(segment)] 402 - } 403 - } 404 - False -> [] 405 - } 406 - 407 - let facet_text = string.slice(text, start, end - start) 408 - let facet_elements = 409 - build_facet_element(facet.features, facet_text, pds_host) 410 - 411 - let after = build_facet_elements(text, rest, end, pds_host) 412 - 413 - list.append(before, list.append(facet_elements, after)) 414 - } 415 - } 416 - } 417 - 418 - fn build_facet_element( 419 - features: List(decoders.FacetFeature), 420 - text: String, 421 - _pds_host: String, 422 - ) -> List(Element(Msg)) { 423 - case features { 424 - [] -> [html.text(text)] 425 - [feature, ..] -> { 426 - case feature { 427 - decoders.Mention(did) -> { 428 - let profile_url = "https://bsky.app/profile/" <> did 429 - [ 430 - html.a( 431 - [ 432 - attribute.attribute("href", profile_url), 433 - attribute.attribute("target", "_blank"), 434 - attribute.attribute("rel", "noopener noreferrer"), 435 - attribute.attribute("class", "mention-link"), 436 - ], 437 - [html.text(text)], 438 - ), 439 - ] 440 - } 441 - decoders.Link(uri) -> [ 442 - html.a( 443 - [ 444 - attribute.attribute("href", uri), 445 - attribute.attribute("target", "_blank"), 446 - attribute.attribute("rel", "noopener noreferrer"), 447 - attribute.attribute("class", "link-facet"), 448 - ], 449 - [html.text(text)], 450 - ), 451 - ] 452 - decoders.Tag(tag) -> { 453 - let tag_url = "https://bsky.app/hashtag/" <> tag 454 - [ 455 - html.span( 456 - [ 457 - attribute.attribute("class", "tag-link"), 458 - ], 459 - [ 460 - html.a( 461 - [ 462 - attribute.attribute("href", tag_url), 463 - attribute.attribute("target", "_blank"), 464 - attribute.attribute("rel", "noopener noreferrer"), 465 - ], 466 - [html.text("#" <> tag)], 467 - ), 468 - ], 469 - ), 470 - ] 471 - } 472 - } 473 - } 474 - } 475 - } 476 - 477 - fn post_embed( 478 - embed: Option(decoders.Embed), 479 - pds_host: String, 480 - did: String, 481 - ) -> Element(Msg) { 482 - case embed { 483 - None -> element.none() 484 - Some(embed_obj) -> 485 - case embed_obj { 486 - decoders.Images(images) -> 487 - html.div( 488 - [ 489 - attribute.attribute("class", "image-grid"), 490 - ], 491 - list.map(images, fn(img) { 492 - html.img([ 493 - attribute.attribute( 494 - "src", 495 - blob_ref_to_url(pds_host, did, img.ref), 496 - ), 497 - attribute.attribute("alt", img.alt), 498 - attribute.attribute("class", "w-full h-auto object-cover"), 499 - attribute.attribute("referrerpolicy", "no-referrer"), 500 - ]) 501 - }), 502 - ) 503 - decoders.ExternalLink(external) -> 504 - html.a( 505 - [ 506 - attribute.attribute("href", external.uri), 507 - attribute.attribute("target", "_blank"), 508 - attribute.attribute("rel", "noopener noreferrer"), 509 - attribute.attribute("class", "link-card"), 510 - ], 511 - [ 512 - html.div( 513 - [ 514 - attribute.attribute("class", "external-link-preview"), 515 - ], 516 - [ 517 - html.h3( 518 - [ 519 - attribute.attribute("class", "external-link-title"), 520 - ], 521 - [html.text(external.title)], 522 - ), 523 - html.p( 524 - [ 525 - attribute.attribute("class", "external-link-desc"), 526 - ], 527 - [html.text(external.description)], 528 - ), 529 - ], 530 - ), 531 - ], 532 - ) 533 - decoders.Record(r) -> html.text(r.record.uri) 534 - } 535 - } 536 - } 537 - 538 - fn post_footer( 539 - labels: Option(List(decoders.LabelJson)), 540 - tags: Option(List(String)), 541 - created_at: String, 542 - thread_counts: Option(decoders.ThreadCountsJson), 543 - ) -> Element(Msg) { 544 - let badges = case labels { 545 - None -> [] 546 - Some(lbls) -> 547 - list.map(lbls, fn(label) { 548 - html.span( 549 - [ 550 - attribute.attribute("class", "label-badge"), 551 - ], 552 - [html.text(label.val)], 553 - ) 554 - }) 555 - } 556 - 557 - let tag_badges = case tags { 558 - None -> [] 559 - Some(ts) -> 560 - list.map(ts, fn(tag) { 561 - html.a( 562 - [ 563 - attribute.attribute("href", "https://bsky.app/hashtag/" <> tag), 564 - attribute.attribute("target", "_blank"), 565 - attribute.attribute("rel", "noopener noreferrer"), 566 - attribute.attribute("class", "tag-badge"), 567 - ], 568 - [html.text("#" <> tag)], 569 - ) 570 - }) 571 - } 572 - 573 - let all_badges = list.append(badges, tag_badges) 574 - 575 - let counts_el = case thread_counts { 576 - Some(counts) -> { 577 - let like_count = case counts.like_count { 578 - Some(n) -> int.to_string(n) 579 - None -> "0" 580 - } 581 - let repost_count = case counts.repost_count { 582 - Some(n) -> int.to_string(n) 583 - None -> "0" 584 - } 585 - let reply_count = case counts.reply_count { 586 - Some(n) -> int.to_string(n) 587 - None -> "0" 588 - } 589 - 590 - html.div( 591 - [ 592 - attribute.attribute( 593 - "class", 594 - "flex items-center gap-3 text-xs text-slate-500 tabular-nums", 595 - ), 596 - ], 597 - [ 598 - html.span([], [html.text("♥ " <> like_count)]), 599 - html.span([], [html.text("↗ " <> repost_count)]), 600 - html.span([], [html.text("💬 " <> reply_count)]), 601 - ], 602 - ) 603 - } 604 - None -> element.none() 605 - } 606 - 607 - html.div( 608 - [ 609 - attribute.attribute( 610 - "class", 611 - "flex items-center justify-between mt-4 pt-4 border-t border-slate-100", 612 - ), 613 - ], 614 - [ 615 - html.div( 616 - [ 617 - attribute.attribute("class", "flex items-center gap-3"), 618 - ], 619 - [ 620 - html.p( 621 - [ 622 - attribute.attribute( 623 - "class", 624 - "text-xs text-slate-400 tabular-nums", 625 - ), 626 - ], 627 - [html.text(format_timestamp(created_at))], 628 - ), 629 - counts_el, 630 - ], 631 - ), 632 - case all_badges { 633 - [] -> element.none() 634 - _ -> 635 - html.div( 636 - [ 637 - attribute.attribute("class", "flex gap-1 flex-wrap"), 638 - ], 639 - all_badges, 640 - ) 641 - }, 642 - ], 643 - ) 644 - } 645 - 646 - fn format_timestamp(iso_timestamp: String) -> String { 647 - let parsed_date = iso_timestamp 648 - let parts = string.split(parsed_date, "T") 649 - case parts { 650 - [date_part, ..] -> date_part 651 - _ -> iso_timestamp 652 - } 653 - } 654 - 655 - fn url_input(at_url: String, error_string: Option(String)) -> Element(Msg) { 656 - html.div( 657 - [ 658 - attribute.attribute("class", "flex flex-col gap-4"), 659 - ], 660 - [ 661 - html.div( 662 - [ 663 - attribute.attribute("class", "flex gap-3 items-stretch"), 664 - ], 665 - [ 666 - html.input([ 667 - event.on_change(LinkWasSet), 668 - attribute.inputmode("text"), 669 - attribute.value(at_url), 670 - attribute.attribute( 671 - "placeholder", 672 - "at://did:plc:.../app.bsky.feed.post/...", 673 - ), 674 - attribute.attribute("class", "input-primary flex-grow min-w-0"), 675 - ]), 676 - html.button( 677 - [ 678 - event.on_click(UserClickedShow), 679 - attribute.attribute("class", "btn-primary flex-shrink-0"), 680 - ], 681 - [html.text("Show")], 682 - ), 683 - ], 684 - ), 685 - case error_string { 686 - None -> element.none() 687 - Some(s) -> 688 - html.p( 689 - [ 690 - attribute.attribute("class", "text-red-600 text-sm"), 691 - ], 692 - [html.text(s)], 693 - ) 694 - }, 695 - ], 696 - ) 697 - } 698 - 699 - fn fetch_profile(pds_host: String, did: String) -> Effect(Msg) { 700 - rsvp.get( 701 - pds_host 702 - <> "/xrpc/com.atproto.repo.getRecord?" 703 - <> construct_profile_uri(did), 704 - rsvp.expect_json( 705 - decode_get_record_response(decoders.decode_profile()), 706 - ProfileWasFetched, 707 - ), 708 - ) 709 - } 710 - 711 - fn fetch_thread_counts(_pds_host: String, uri: String) -> Effect(Msg) { 712 - let encoded_uri = 713 - uri 714 - |> string.replace(":", "%3A") 715 - |> string.replace("/", "%2F") 716 - let url = 717 - "https://public.api.bsky.app" 718 - <> "/xrpc/app.bsky.feed.getPostThread?uri=" 719 - <> encoded_uri 720 - <> "&depth=0" 721 - rsvp.get(url, rsvp.expect_json(decode_thread_response(), ThreadWasFetched)) 722 - } 723 - 724 - fn decode_thread_response() -> decode.Decoder(decoders.ThreadCountsJson) { 725 - use thread <- field("thread", decoders.decode_thread_view()) 726 - case thread.post { 727 - Some(post_view) -> success(post_view.counts) 728 - None -> success(decoders.ThreadCountsJson(None, None, None, None)) 729 - } 730 - } 731 - 732 - pub fn extract_did_from_uri(uri: String) -> Result(String, Nil) { 733 - let u = case uri { 734 - "at://" <> rest -> rest 735 - _ -> uri 736 - } 737 - 738 - case string.split(u, "/") { 739 - [did, ..] -> Ok(did) 740 - _ -> Error(Nil) 741 - } 742 - } 743 - 744 - pub fn construct_profile_uri(did: String) -> String { 745 - get_record_query(did, "app.bsky.actor.profile", "self") 746 - } 747 - 748 - fn get_record(pds_host: String, at_url: String) -> Effect(Msg) { 749 - case query_from_at_uri(at_url) { 750 - Error(Nil) -> { 751 - use dispatch <- effect.from 752 - dispatch(PostWasFetched(Error(rsvp.BadBody))) 753 - } 754 - Ok(query) -> { 755 - let url = pds_host <> "/xrpc/com.atproto.repo.getRecord?" <> query 756 - rsvp.get( 757 - url, 758 - rsvp.expect_json( 759 - decode_get_record_response(decoders.decode_post()), 760 - PostWasFetched, 761 - ), 762 - ) 763 - } 764 - } 765 - } 766 - 767 - pub type Record(a) { 768 - Record(uri: String, cid: String, value: a) 769 - } 770 - 771 - const slingshot_base = "https://slingshot.microcosm.blue" 772 - 773 - fn resolve_mini_doc(identifier: String) -> Effect(Msg) { 774 - rsvp.get( 775 - slingshot_base 776 - <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 777 - <> identifier, 778 - rsvp.expect_json(decoders.decode_mini_doc(), MiniDocWasResolved), 779 - ) 780 - } 781 - 782 - fn decode_get_record_response( 783 - decoder: decode.Decoder(a), 784 - ) -> decode.Decoder(Record(a)) { 785 - use uri <- field("uri", string) 786 - use cid <- field("cid", string) 787 - use value <- field("value", decoder) 788 - success(Record(uri:, cid:, value:)) 789 - } 790 - 791 - pub fn query_from_at_uri(at_url: String) -> Result(String, Nil) { 792 - let u = case at_url { 793 - "at://" <> rest -> rest 794 - _ -> at_url 795 - } 796 - 797 - case string.split(u, "/") { 798 - [did, collection, rkey] -> Ok(get_record_query(did, collection, rkey)) 799 - _ -> Error(Nil) 800 - } 801 - } 802 - 803 - fn get_record_query(did, collection, rkey) -> String { 804 - uri.query_to_string([ 805 - #("repo", did), 806 - #("collection", collection), 807 - #("rkey", rkey), 808 - ]) 809 - }
+118
src/gpreview/effects.gleam
··· 1 + import bsky/decoders 2 + import gleam/dynamic/decode.{type Decoder, field, string, success} 3 + import gleam/option.{None, Some} 4 + import gleam/string 5 + import gleam/uri 6 + import gpreview/record.{type Record, Record} 7 + import gpreview/types.{ 8 + type Msg, MiniDocWasResolved, PostWasFetched, ProfileWasFetched, 9 + ThreadWasFetched, 10 + } 11 + import lustre/effect.{type Effect} 12 + import rsvp 13 + 14 + const slingshot_base = "https://slingshot.microcosm.blue" 15 + 16 + pub fn resolve_mini_doc(identifier: String) -> Effect(Msg) { 17 + rsvp.get( 18 + slingshot_base 19 + <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 20 + <> identifier, 21 + rsvp.expect_json(decoders.decode_mini_doc(), MiniDocWasResolved), 22 + ) 23 + } 24 + 25 + pub fn get_record(pds_host: String, at_url: String) -> Effect(Msg) { 26 + case query_from_at_uri(at_url) { 27 + Error(Nil) -> { 28 + use dispatch <- effect.from 29 + dispatch(PostWasFetched(Error(rsvp.BadBody))) 30 + } 31 + Ok(query) -> { 32 + let url = pds_host <> "/xrpc/com.atproto.repo.getRecord?" <> query 33 + rsvp.get( 34 + url, 35 + rsvp.expect_json( 36 + decode_get_record_response(decoders.decode_post()), 37 + PostWasFetched, 38 + ), 39 + ) 40 + } 41 + } 42 + } 43 + 44 + pub fn fetch_profile(pds_host: String, did: String) -> Effect(Msg) { 45 + rsvp.get( 46 + pds_host 47 + <> "/xrpc/com.atproto.repo.getRecord?" 48 + <> construct_profile_uri(did), 49 + rsvp.expect_json( 50 + decode_get_record_response(decoders.decode_profile()), 51 + ProfileWasFetched, 52 + ), 53 + ) 54 + } 55 + 56 + pub fn fetch_thread_counts(_pds_host: String, uri: String) -> Effect(Msg) { 57 + let encoded_uri = 58 + uri 59 + |> string.replace(":", "%3A") 60 + |> string.replace("/", "%2F") 61 + let url = 62 + "https://public.api.bsky.app" 63 + <> "/xrpc/app.bsky.feed.getPostThread?uri=" 64 + <> encoded_uri 65 + <> "&depth=0" 66 + rsvp.get(url, rsvp.expect_json(decode_thread_response(), ThreadWasFetched)) 67 + } 68 + 69 + fn decode_thread_response() -> Decoder(decoders.ThreadCountsJson) { 70 + use thread <- field("thread", decoders.decode_thread_view()) 71 + case thread.post { 72 + Some(post_view) -> success(post_view.counts) 73 + None -> success(decoders.ThreadCountsJson(None, None, None, None)) 74 + } 75 + } 76 + 77 + pub fn query_from_at_uri(at_url: String) -> Result(String, Nil) { 78 + let u = case at_url { 79 + "at://" <> rest -> rest 80 + _ -> at_url 81 + } 82 + 83 + case string.split(u, "/") { 84 + [did, collection, rkey] -> Ok(get_record_query(did, collection, rkey)) 85 + _ -> Error(Nil) 86 + } 87 + } 88 + 89 + fn get_record_query(did, collection, rkey) -> String { 90 + uri.query_to_string([ 91 + #("repo", did), 92 + #("collection", collection), 93 + #("rkey", rkey), 94 + ]) 95 + } 96 + 97 + pub fn extract_did_from_uri(uri: String) -> Result(String, Nil) { 98 + let u = case uri { 99 + "at://" <> rest -> rest 100 + _ -> uri 101 + } 102 + 103 + case string.split(u, "/") { 104 + [did, ..] -> Ok(did) 105 + _ -> Error(Nil) 106 + } 107 + } 108 + 109 + pub fn construct_profile_uri(did: String) -> String { 110 + get_record_query(did, "app.bsky.actor.profile", "self") 111 + } 112 + 113 + fn decode_get_record_response(decoder: Decoder(a)) -> Decoder(Record(a)) { 114 + use uri <- field("uri", string) 115 + use cid <- field("cid", string) 116 + use value <- field("value", decoder) 117 + success(Record(uri:, cid:, value:)) 118 + }
+3
src/gpreview/record.gleam
··· 1 + pub type Record(a) { 2 + Record(uri: String, cid: String, value: a) 3 + }
+36
src/gpreview/types.gleam
··· 1 + import bsky/decoders 2 + import gleam/int 3 + import gleam/option.{type Option} 4 + import gpreview/record.{type Record} 5 + import rsvp 6 + 7 + pub type Model { 8 + App( 9 + at_url: String, 10 + did_doc: Option(Result(decoders.MiniDocJson, String)), 11 + post: Option(Result(Record(decoders.PostJson), String)), 12 + profile: Option(Result(decoders.ProfileJson, String)), 13 + thread_counts: Option(decoders.ThreadCountsJson), 14 + ) 15 + } 16 + 17 + pub type Msg { 18 + LinkWasSet(String) 19 + UserClickedShow 20 + MiniDocWasResolved(Result(decoders.MiniDocJson, rsvp.Error)) 21 + PostWasFetched(Result(Record(decoders.PostJson), rsvp.Error)) 22 + ProfileWasFetched(Result(Record(decoders.ProfileJson), rsvp.Error)) 23 + ThreadWasFetched(Result(decoders.ThreadCountsJson, rsvp.Error)) 24 + } 25 + 26 + pub fn error_to_string(e: rsvp.Error) -> String { 27 + case e { 28 + rsvp.BadBody -> "Invalid response body" 29 + rsvp.BadUrl(url) -> "Invalid URL: " <> url 30 + rsvp.HttpError(resp) -> "HTTP error: " <> int.to_string(resp.status) 31 + rsvp.JsonError(_) -> "Failed to parse JSON response" 32 + rsvp.NetworkError -> "Network error - check your connection" 33 + rsvp.UnhandledResponse(resp) -> 34 + "Unexpected response: " <> int.to_string(resp.status) 35 + } 36 + }
+533
src/gpreview/views.gleam
··· 1 + import bsky/decoders 2 + import gleam/int 3 + import gleam/list 4 + import gleam/option.{type Option, None, Some} 5 + import gleam/string 6 + import gpreview/effects 7 + import gpreview/record.{Record} 8 + import gpreview/types.{type Model, type Msg, LinkWasSet, UserClickedShow} 9 + import lustre/attribute 10 + import lustre/element.{type Element} 11 + import lustre/element/html 12 + import lustre/event 13 + 14 + pub fn view(model: Model) -> Element(Msg) { 15 + html.div([attribute.attribute("class", "app-shell")], [ 16 + input_zone(model), 17 + content_area(model), 18 + ]) 19 + } 20 + 21 + fn input_zone(model: Model) -> Element(Msg) { 22 + let error = case model.post { 23 + Some(Error(e)) -> Some(e) 24 + _ -> None 25 + } 26 + html.div([attribute.attribute("class", "input-zone")], [ 27 + html.div([attribute.attribute("class", "input-zone__row")], [ 28 + html.input([ 29 + event.on_change(LinkWasSet), 30 + attribute.inputmode("text"), 31 + attribute.value(model.at_url), 32 + attribute.attribute( 33 + "placeholder", 34 + "at://did:plc:.../app.bsky.feed.post/...", 35 + ), 36 + attribute.attribute("class", "input-field"), 37 + ]), 38 + html.button( 39 + [ 40 + event.on_click(UserClickedShow), 41 + attribute.attribute("class", "btn-show"), 42 + ], 43 + [html.text("Show")], 44 + ), 45 + ]), 46 + case error { 47 + None -> element.none() 48 + Some(msg) -> error_badge(msg) 49 + }, 50 + ]) 51 + } 52 + 53 + pub fn content_area(model: Model) -> Element(Msg) { 54 + case model.post { 55 + None -> empty_state() 56 + Some(Ok(_)) -> 57 + case model.profile { 58 + Some(Ok(_)) -> render_post_card(model) 59 + _ -> loading_skeleton() 60 + } 61 + Some(Error(e)) -> error_state(e) 62 + } 63 + |> with_profile_error(model) 64 + } 65 + 66 + fn with_profile_error(el: Element(Msg), model: Model) -> Element(Msg) { 67 + case model.profile { 68 + Some(Error(e)) -> error_state(e) 69 + _ -> el 70 + } 71 + } 72 + 73 + pub fn empty_state() -> Element(Msg) { 74 + html.div([attribute.attribute("class", "empty-state")], [ 75 + html.h2([attribute.attribute("class", "empty-state__title")], [ 76 + html.text("Preview a Bluesky post"), 77 + ]), 78 + html.p([attribute.attribute("class", "empty-state__desc")], [ 79 + html.text("Paste an AT-URI above to see it rendered here"), 80 + ]), 81 + ]) 82 + } 83 + 84 + pub fn loading_skeleton() -> Element(Msg) { 85 + html.div([attribute.attribute("class", "loading-skeleton")], [ 86 + html.div([attribute.attribute("class", "post-header")], [ 87 + html.div([attribute.attribute("class", "skeleton-circle")], []), 88 + html.div([attribute.attribute("class", "post-header__info")], [ 89 + html.div( 90 + [attribute.attribute("class", "skeleton-line skeleton-line-md")], 91 + [], 92 + ), 93 + html.div( 94 + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 95 + [], 96 + ), 97 + ]), 98 + ]), 99 + html.div([attribute.attribute("class", "post-body")], [ 100 + html.div([attribute.attribute("class", "skeleton-line")], []), 101 + html.div([attribute.attribute("class", "skeleton-line")], []), 102 + html.div( 103 + [attribute.attribute("class", "skeleton-line skeleton-line-sm")], 104 + [], 105 + ), 106 + ]), 107 + ]) 108 + } 109 + 110 + pub fn error_state(msg: String) -> Element(Msg) { 111 + html.div([attribute.attribute("class", "error-state")], [ 112 + html.text("Error: " <> msg), 113 + ]) 114 + } 115 + 116 + fn error_badge(msg: String) -> Element(Msg) { 117 + html.p( 118 + [ 119 + attribute.attribute("class", "error-badge"), 120 + ], 121 + [html.text(msg)], 122 + ) 123 + } 124 + 125 + fn render_post_card(model: Model) -> Element(Msg) { 126 + case model.post, model.profile, model.did_doc { 127 + Some(Ok(Record(value: post, ..))), Some(Ok(profile)), Some(Ok(doc)) -> 128 + post_card(post, profile, doc, model.thread_counts) 129 + _, _, _ -> element.none() 130 + } 131 + } 132 + 133 + fn post_card( 134 + post: decoders.PostJson, 135 + profile: decoders.ProfileJson, 136 + doc: decoders.MiniDocJson, 137 + thread_counts: Option(decoders.ThreadCountsJson), 138 + ) -> Element(Msg) { 139 + html.div([attribute.attribute("class", "post-card stagger-1")], [ 140 + post_header(profile, doc.handle, doc.did, doc.pds), 141 + html.div([attribute.attribute("class", "post-body stagger-2")], [ 142 + reply_context(post.reply), 143 + html.p([], render_post_with_facets(post.text, post.facets, doc.pds)), 144 + post_embed(post.embed, doc.pds, doc.did), 145 + post_footer(post.labels, post.tags, post.created_at, thread_counts), 146 + ]), 147 + ]) 148 + } 149 + 150 + fn reply_context(reply: Option(decoders.ReplyRefJson)) -> Element(Msg) { 151 + case reply { 152 + Some(reply_ref) -> { 153 + let parent_did = 154 + reply_ref.parent.uri 155 + |> effects.extract_did_from_uri 156 + |> fn(r) { 157 + case r { 158 + Ok(d) -> d 159 + Error(_) -> "unknown" 160 + } 161 + } 162 + let short_did = string.slice(parent_did, 0, 20) <> "…" 163 + html.div([attribute.attribute("class", "reply-context")], [ 164 + html.span([attribute.attribute("class", "reply-context__label")], [ 165 + html.text("Replying to " <> short_did), 166 + ]), 167 + ]) 168 + } 169 + None -> element.none() 170 + } 171 + } 172 + 173 + fn post_header( 174 + profile: decoders.ProfileJson, 175 + handle: String, 176 + did: String, 177 + pds_host: String, 178 + ) -> Element(Msg) { 179 + html.div([attribute.attribute("class", "post-header")], [ 180 + avatar_element(profile, pds_host, did), 181 + html.div([attribute.attribute("class", "post-header__info")], [ 182 + display_name(profile.display_name), 183 + html.span( 184 + [ 185 + attribute.attribute("class", "post-header__handle"), 186 + ], 187 + [html.text("@" <> handle)], 188 + ), 189 + description_line(profile.description), 190 + ]), 191 + ]) 192 + } 193 + 194 + fn avatar_element( 195 + profile: decoders.ProfileJson, 196 + pds_host: String, 197 + did: String, 198 + ) -> Element(Msg) { 199 + case profile.avatar { 200 + Some(blob) -> 201 + html.img([ 202 + attribute.attribute("src", blob_ref_to_url(pds_host, did, blob)), 203 + attribute.attribute("alt", "Avatar"), 204 + attribute.attribute("class", "avatar-ring"), 205 + attribute.attribute("referrerpolicy", "no-referrer"), 206 + ]) 207 + None -> { 208 + let initial = case profile.display_name { 209 + Some(n) -> string.slice(n, 0, 1) 210 + None -> "@" 211 + } 212 + html.div([attribute.attribute("class", "avatar-fallback")], [ 213 + html.text(initial), 214 + ]) 215 + } 216 + } 217 + } 218 + 219 + fn display_name(name: Option(String)) -> Element(Msg) { 220 + case name { 221 + Some(n) -> 222 + html.span( 223 + [ 224 + attribute.attribute("class", "post-header__name"), 225 + ], 226 + [html.text(n)], 227 + ) 228 + None -> element.none() 229 + } 230 + } 231 + 232 + fn description_line(desc: Option(String)) -> Element(Msg) { 233 + case desc { 234 + Some(d) -> 235 + html.p( 236 + [ 237 + attribute.attribute("class", "post-header__bio"), 238 + ], 239 + [html.text(d)], 240 + ) 241 + None -> element.none() 242 + } 243 + } 244 + 245 + pub fn blob_ref_to_url(pds_host: String, did: String, blob: String) -> String { 246 + pds_host <> "/xrpc/com.atproto.sync.getBlob?did=" <> did <> "&cid=" <> blob 247 + } 248 + 249 + pub fn render_post_with_facets( 250 + text: String, 251 + facets: Option(List(decoders.FacetJson)), 252 + pds_host: String, 253 + ) -> List(Element(Msg)) { 254 + case facets { 255 + None -> [html.text(text)] 256 + Some(f) -> { 257 + let sorted = 258 + list.sort(f, fn(a, b) { 259 + int.compare(a.index.byte_start, b.index.byte_start) 260 + }) 261 + build_facet_elements(text, sorted, 0, pds_host) 262 + } 263 + } 264 + } 265 + 266 + fn build_facet_elements( 267 + text: String, 268 + facets: List(decoders.FacetJson), 269 + byte_offset: Int, 270 + pds_host: String, 271 + ) -> List(Element(Msg)) { 272 + case facets { 273 + [] -> { 274 + let remaining = 275 + string.slice(text, byte_offset, string.length(text) - byte_offset) 276 + case string.is_empty(remaining) { 277 + True -> [] 278 + False -> [html.text(remaining)] 279 + } 280 + } 281 + [facet, ..rest] -> { 282 + let start = facet.index.byte_start 283 + let end = facet.index.byte_end 284 + 285 + let before = case start > byte_offset { 286 + True -> { 287 + let segment = string.slice(text, byte_offset, start - byte_offset) 288 + case string.is_empty(segment) { 289 + True -> [] 290 + False -> [html.text(segment)] 291 + } 292 + } 293 + False -> [] 294 + } 295 + 296 + let facet_text = string.slice(text, start, end - start) 297 + let facet_elements = 298 + build_facet_element(facet.features, facet_text, pds_host) 299 + 300 + let after = build_facet_elements(text, rest, end, pds_host) 301 + 302 + list.append(before, list.append(facet_elements, after)) 303 + } 304 + } 305 + } 306 + 307 + fn build_facet_element( 308 + features: List(decoders.FacetFeature), 309 + text: String, 310 + _pds_host: String, 311 + ) -> List(Element(Msg)) { 312 + case features { 313 + [] -> [html.text(text)] 314 + [feature, ..] -> { 315 + case feature { 316 + decoders.Mention(did) -> { 317 + let profile_url = "https://bsky.app/profile/" <> did 318 + [ 319 + html.a( 320 + [ 321 + attribute.attribute("href", profile_url), 322 + attribute.attribute("target", "_blank"), 323 + attribute.attribute("rel", "noopener noreferrer"), 324 + attribute.attribute("class", "mention-link"), 325 + ], 326 + [html.text(text)], 327 + ), 328 + ] 329 + } 330 + decoders.Link(uri) -> [ 331 + html.a( 332 + [ 333 + attribute.attribute("href", uri), 334 + attribute.attribute("target", "_blank"), 335 + attribute.attribute("rel", "noopener noreferrer"), 336 + attribute.attribute("class", "link-facet"), 337 + ], 338 + [html.text(text)], 339 + ), 340 + ] 341 + decoders.Tag(tag) -> { 342 + let tag_url = "https://bsky.app/hashtag/" <> tag 343 + [ 344 + html.span( 345 + [ 346 + attribute.attribute("class", "tag-link"), 347 + ], 348 + [ 349 + html.a( 350 + [ 351 + attribute.attribute("href", tag_url), 352 + attribute.attribute("target", "_blank"), 353 + attribute.attribute("rel", "noopener noreferrer"), 354 + ], 355 + [html.text("#" <> tag)], 356 + ), 357 + ], 358 + ), 359 + ] 360 + } 361 + } 362 + } 363 + } 364 + } 365 + 366 + fn post_embed( 367 + embed: Option(decoders.Embed), 368 + pds_host: String, 369 + did: String, 370 + ) -> Element(Msg) { 371 + case embed { 372 + None -> element.none() 373 + Some(embed_obj) -> 374 + case embed_obj { 375 + decoders.Images(images) -> 376 + html.div([attribute.attribute("class", "post-embed")], [ 377 + html.div( 378 + [attribute.attribute("class", "image-grid")], 379 + list.map(images, fn(img) { 380 + html.img([ 381 + attribute.attribute( 382 + "src", 383 + blob_ref_to_url(pds_host, did, img.ref), 384 + ), 385 + attribute.attribute("alt", img.alt), 386 + attribute.attribute("class", "image-cover"), 387 + attribute.attribute("referrerpolicy", "no-referrer"), 388 + ]) 389 + }), 390 + ), 391 + ]) 392 + decoders.ExternalLink(external) -> 393 + html.div([attribute.attribute("class", "post-embed")], [ 394 + html.a( 395 + [ 396 + attribute.attribute("href", external.uri), 397 + attribute.attribute("target", "_blank"), 398 + attribute.attribute("rel", "noopener noreferrer"), 399 + attribute.attribute("class", "link-card"), 400 + ], 401 + [ 402 + html.div( 403 + [ 404 + attribute.attribute("class", "external-link-preview"), 405 + ], 406 + [ 407 + html.h3( 408 + [ 409 + attribute.attribute("class", "external-link-title"), 410 + ], 411 + [html.text(external.title)], 412 + ), 413 + html.p( 414 + [ 415 + attribute.attribute("class", "external-link-desc"), 416 + ], 417 + [html.text(external.description)], 418 + ), 419 + ], 420 + ), 421 + ], 422 + ), 423 + ]) 424 + decoders.Record(r) -> html.text(r.record.uri) 425 + } 426 + } 427 + } 428 + 429 + fn post_footer( 430 + labels: Option(List(decoders.LabelJson)), 431 + tags: Option(List(String)), 432 + created_at: String, 433 + thread_counts: Option(decoders.ThreadCountsJson), 434 + ) -> Element(Msg) { 435 + let badges = case labels { 436 + None -> [] 437 + Some(lbls) -> 438 + list.map(lbls, fn(label) { 439 + html.span([attribute.attribute("class", "badge")], [ 440 + html.text(label.val), 441 + ]) 442 + }) 443 + } 444 + 445 + let tag_badges = case tags { 446 + None -> [] 447 + Some(ts) -> 448 + list.map(ts, fn(tag) { 449 + html.a( 450 + [ 451 + attribute.attribute("href", "https://bsky.app/hashtag/" <> tag), 452 + attribute.attribute("target", "_blank"), 453 + attribute.attribute("rel", "noopener noreferrer"), 454 + attribute.attribute("class", "tag-badge"), 455 + ], 456 + [html.text("#" <> tag)], 457 + ) 458 + }) 459 + } 460 + 461 + let all_badges = list.append(badges, tag_badges) 462 + 463 + html.div([attribute.attribute("class", "post-footer stagger-3")], [ 464 + engagement_bar(thread_counts), 465 + html.div([attribute.attribute("class", "post-footer__badges")], [ 466 + badge_group(all_badges), 467 + timestamp(created_at), 468 + ]), 469 + ]) 470 + } 471 + 472 + fn timestamp(iso_timestamp: String) -> Element(Msg) { 473 + html.span( 474 + [ 475 + attribute.attribute("class", "post-footer__timestamp"), 476 + ], 477 + [html.text(format_timestamp(iso_timestamp))], 478 + ) 479 + } 480 + 481 + fn engagement_bar( 482 + thread_counts: Option(decoders.ThreadCountsJson), 483 + ) -> Element(Msg) { 484 + case thread_counts { 485 + Some(counts) -> { 486 + let like_count = case counts.like_count { 487 + Some(n) -> int.to_string(n) 488 + None -> "0" 489 + } 490 + let repost_count = case counts.repost_count { 491 + Some(n) -> int.to_string(n) 492 + None -> "0" 493 + } 494 + let reply_count = case counts.reply_count { 495 + Some(n) -> int.to_string(n) 496 + None -> "0" 497 + } 498 + 499 + html.div([attribute.attribute("class", "engagement-bar")], [ 500 + html.span( 501 + [attribute.attribute("class", "engagement-bar__likes tabular-nums")], 502 + [ 503 + html.text("♥ " <> like_count), 504 + ], 505 + ), 506 + html.span( 507 + [attribute.attribute("class", "engagement-bar__reposts tabular-nums")], 508 + [html.text("↗ " <> repost_count)], 509 + ), 510 + html.span( 511 + [attribute.attribute("class", "engagement-bar__replies tabular-nums")], 512 + [html.text("💬 " <> reply_count)], 513 + ), 514 + ]) 515 + } 516 + None -> element.none() 517 + } 518 + } 519 + 520 + fn badge_group(badges: List(Element(Msg))) -> Element(Msg) { 521 + case badges { 522 + [] -> element.none() 523 + _ -> html.div([attribute.attribute("class", "badge-group")], badges) 524 + } 525 + } 526 + 527 + fn format_timestamp(iso_timestamp: String) -> String { 528 + let parts = string.split(iso_timestamp, "T") 529 + case parts { 530 + [date_part, ..] -> date_part 531 + _ -> iso_timestamp 532 + } 533 + }
+3 -5
test/bsky_test.gleam
··· 5 5 import gleam/option.{None, Some} 6 6 import gleeunit 7 7 import gleeunit/should 8 - import gpreview 8 + import gpreview/record.{type Record, Record} 9 9 10 10 pub fn main() { 11 11 gleeunit.main() ··· 556 556 } 557 557 } 558 558 559 - fn decode_get_record( 560 - decoder: decode.Decoder(a), 561 - ) -> decode.Decoder(gpreview.Record(a)) { 559 + fn decode_get_record(decoder: decode.Decoder(a)) -> decode.Decoder(Record(a)) { 562 560 use uri <- field("uri", string) 563 561 use cid <- field("cid", string) 564 562 use value <- field("value", decoder) 565 - success(gpreview.Record(uri:, cid:, value:)) 563 + success(Record(uri:, cid:, value:)) 566 564 } 567 565 568 566 fn decode_thread_response() -> decode.Decoder(decoders.ThreadCountsJson) {