interactive intro to open social

Compare changes

Choose any two refs to compare.

+606 -11
Cargo.lock
··· 62 62 "flate2", 63 63 "foldhash", 64 64 "futures-core", 65 - "h2", 65 + "h2 0.3.27", 66 66 "http 0.2.12", 67 67 "httparse", 68 68 "httpdate", ··· 276 276 ] 277 277 278 278 [[package]] 279 + name = "ahash" 280 + version = "0.8.12" 281 + source = "registry+https://github.com/rust-lang/crates.io-index" 282 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 283 + dependencies = [ 284 + "cfg-if", 285 + "once_cell", 286 + "version_check", 287 + "zerocopy", 288 + ] 289 + 290 + [[package]] 279 291 name = "aho-corasick" 280 292 version = "1.1.3" 281 293 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 395 407 ] 396 408 397 409 [[package]] 410 + name = "async-stream" 411 + version = "0.3.6" 412 + source = "registry+https://github.com/rust-lang/crates.io-index" 413 + checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" 414 + dependencies = [ 415 + "async-stream-impl", 416 + "futures-core", 417 + "pin-project-lite", 418 + ] 419 + 420 + [[package]] 421 + name = "async-stream-impl" 422 + version = "0.3.6" 423 + source = "registry+https://github.com/rust-lang/crates.io-index" 424 + checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" 425 + dependencies = [ 426 + "proc-macro2", 427 + "quote", 428 + "syn 2.0.106", 429 + ] 430 + 431 + [[package]] 398 432 name = "async-trait" 399 433 version = "0.1.89" 400 434 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 412 446 "actix-files", 413 447 "actix-session", 414 448 "actix-web", 449 + "anyhow", 450 + "async-stream", 451 + "async-trait", 415 452 "atrium-api", 416 453 "atrium-common", 417 454 "atrium-identity", 418 455 "atrium-oauth", 419 456 "env_logger", 457 + "futures-util", 420 458 "hickory-resolver", 421 459 "log", 460 + "reqwest", 461 + "rocketman", 422 462 "serde", 423 463 "serde_json", 424 464 "tokio", ··· 543 583 "miniz_oxide", 544 584 "object", 545 585 "rustc-demangle", 546 - "windows-link", 586 + "windows-link 0.2.0", 547 587 ] 548 588 549 589 [[package]] ··· 576 616 577 617 [[package]] 578 618 name = "base64" 619 + version = "0.21.7" 620 + source = "registry+https://github.com/rust-lang/crates.io-index" 621 + checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 622 + 623 + [[package]] 624 + name = "base64" 579 625 version = "0.22.1" 580 626 source = "registry+https://github.com/rust-lang/crates.io-index" 581 627 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" ··· 602 648 ] 603 649 604 650 [[package]] 651 + name = "bon" 652 + version = "3.8.1" 653 + source = "registry+https://github.com/rust-lang/crates.io-index" 654 + checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" 655 + dependencies = [ 656 + "bon-macros", 657 + "rustversion", 658 + ] 659 + 660 + [[package]] 661 + name = "bon-macros" 662 + version = "3.8.1" 663 + source = "registry+https://github.com/rust-lang/crates.io-index" 664 + checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" 665 + dependencies = [ 666 + "darling 0.21.3", 667 + "ident_case", 668 + "prettyplease", 669 + "proc-macro2", 670 + "quote", 671 + "rustversion", 672 + "syn 2.0.106", 673 + ] 674 + 675 + [[package]] 605 676 name = "brotli" 606 677 version = "8.0.2" 607 678 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 629 700 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 630 701 631 702 [[package]] 703 + name = "byteorder" 704 + version = "1.5.0" 705 + source = "registry+https://github.com/rust-lang/crates.io-index" 706 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 707 + 708 + [[package]] 632 709 name = "bytes" 633 710 version = "1.10.1" 634 711 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 672 749 "num-traits", 673 750 "serde", 674 751 "wasm-bindgen", 675 - "windows-link", 752 + "windows-link 0.2.0", 676 753 ] 677 754 678 755 [[package]] ··· 861 938 ] 862 939 863 940 [[package]] 941 + name = "darling" 942 + version = "0.20.11" 943 + source = "registry+https://github.com/rust-lang/crates.io-index" 944 + checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 945 + dependencies = [ 946 + "darling_core 0.20.11", 947 + "darling_macro 0.20.11", 948 + ] 949 + 950 + [[package]] 951 + name = "darling" 952 + version = "0.21.3" 953 + source = "registry+https://github.com/rust-lang/crates.io-index" 954 + checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" 955 + dependencies = [ 956 + "darling_core 0.21.3", 957 + "darling_macro 0.21.3", 958 + ] 959 + 960 + [[package]] 961 + name = "darling_core" 962 + version = "0.20.11" 963 + source = "registry+https://github.com/rust-lang/crates.io-index" 964 + checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 965 + dependencies = [ 966 + "fnv", 967 + "ident_case", 968 + "proc-macro2", 969 + "quote", 970 + "strsim", 971 + "syn 2.0.106", 972 + ] 973 + 974 + [[package]] 975 + name = "darling_core" 976 + version = "0.21.3" 977 + source = "registry+https://github.com/rust-lang/crates.io-index" 978 + checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" 979 + dependencies = [ 980 + "fnv", 981 + "ident_case", 982 + "proc-macro2", 983 + "quote", 984 + "strsim", 985 + "syn 2.0.106", 986 + ] 987 + 988 + [[package]] 989 + name = "darling_macro" 990 + version = "0.20.11" 991 + source = "registry+https://github.com/rust-lang/crates.io-index" 992 + checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 993 + dependencies = [ 994 + "darling_core 0.20.11", 995 + "quote", 996 + "syn 2.0.106", 997 + ] 998 + 999 + [[package]] 1000 + name = "darling_macro" 1001 + version = "0.21.3" 1002 + source = "registry+https://github.com/rust-lang/crates.io-index" 1003 + checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" 1004 + dependencies = [ 1005 + "darling_core 0.21.3", 1006 + "quote", 1007 + "syn 2.0.106", 1008 + ] 1009 + 1010 + [[package]] 864 1011 name = "dashmap" 865 1012 version = "6.1.0" 866 1013 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 920 1067 ] 921 1068 922 1069 [[package]] 1070 + name = "derive_builder" 1071 + version = "0.20.2" 1072 + source = "registry+https://github.com/rust-lang/crates.io-index" 1073 + checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" 1074 + dependencies = [ 1075 + "derive_builder_macro", 1076 + ] 1077 + 1078 + [[package]] 1079 + name = "derive_builder_core" 1080 + version = "0.20.2" 1081 + source = "registry+https://github.com/rust-lang/crates.io-index" 1082 + checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" 1083 + dependencies = [ 1084 + "darling 0.20.11", 1085 + "proc-macro2", 1086 + "quote", 1087 + "syn 2.0.106", 1088 + ] 1089 + 1090 + [[package]] 1091 + name = "derive_builder_macro" 1092 + version = "0.20.2" 1093 + source = "registry+https://github.com/rust-lang/crates.io-index" 1094 + checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" 1095 + dependencies = [ 1096 + "derive_builder_core", 1097 + "syn 2.0.106", 1098 + ] 1099 + 1100 + [[package]] 923 1101 name = "derive_more" 924 1102 version = "1.0.0" 925 1103 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1129 1307 ] 1130 1308 1131 1309 [[package]] 1310 + name = "flume" 1311 + version = "0.11.1" 1312 + source = "registry+https://github.com/rust-lang/crates.io-index" 1313 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 1314 + dependencies = [ 1315 + "futures-core", 1316 + "futures-sink", 1317 + "nanorand", 1318 + "spin", 1319 + ] 1320 + 1321 + [[package]] 1132 1322 name = "fnv" 1133 1323 version = "1.0.7" 1134 1324 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1216 1406 dependencies = [ 1217 1407 "futures-core", 1218 1408 "futures-macro", 1409 + "futures-sink", 1219 1410 "futures-task", 1220 1411 "pin-project-lite", 1221 1412 "pin-utils", ··· 1240 1431 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 1241 1432 dependencies = [ 1242 1433 "cfg-if", 1434 + "js-sys", 1243 1435 "libc", 1244 1436 "wasi 0.11.1+wasi-snapshot-preview1", 1437 + "wasm-bindgen", 1245 1438 ] 1246 1439 1247 1440 [[package]] ··· 1303 1496 ] 1304 1497 1305 1498 [[package]] 1499 + name = "h2" 1500 + version = "0.4.12" 1501 + source = "registry+https://github.com/rust-lang/crates.io-index" 1502 + checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 1503 + dependencies = [ 1504 + "atomic-waker", 1505 + "bytes", 1506 + "fnv", 1507 + "futures-core", 1508 + "futures-sink", 1509 + "http 1.3.1", 1510 + "indexmap", 1511 + "slab", 1512 + "tokio", 1513 + "tokio-util", 1514 + "tracing", 1515 + ] 1516 + 1517 + [[package]] 1306 1518 name = "hashbrown" 1307 1519 version = "0.14.5" 1308 1520 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1467 1679 "bytes", 1468 1680 "futures-channel", 1469 1681 "futures-core", 1682 + "h2 0.4.12", 1470 1683 "http 1.3.1", 1471 1684 "http-body", 1472 1685 "httparse", ··· 1479 1692 ] 1480 1693 1481 1694 [[package]] 1695 + name = "hyper-rustls" 1696 + version = "0.27.7" 1697 + source = "registry+https://github.com/rust-lang/crates.io-index" 1698 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1699 + dependencies = [ 1700 + "http 1.3.1", 1701 + "hyper", 1702 + "hyper-util", 1703 + "rustls 0.23.31", 1704 + "rustls-pki-types", 1705 + "tokio", 1706 + "tokio-rustls 0.26.2", 1707 + "tower-service", 1708 + ] 1709 + 1710 + [[package]] 1482 1711 name = "hyper-tls" 1483 1712 version = "0.6.0" 1484 1713 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1513 1742 "percent-encoding", 1514 1743 "pin-project-lite", 1515 1744 "socket2 0.6.0", 1745 + "system-configuration", 1516 1746 "tokio", 1517 1747 "tower-service", 1518 1748 "tracing", 1749 + "windows-registry", 1519 1750 ] 1520 1751 1521 1752 [[package]] ··· 1629 1860 ] 1630 1861 1631 1862 [[package]] 1863 + name = "ident_case" 1864 + version = "1.0.1" 1865 + source = "registry+https://github.com/rust-lang/crates.io-index" 1866 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 1867 + 1868 + [[package]] 1632 1869 name = "idna" 1633 1870 version = "1.1.0" 1634 1871 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1830 2067 checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" 1831 2068 1832 2069 [[package]] 2070 + name = "lazy_static" 2071 + version = "1.5.0" 2072 + source = "registry+https://github.com/rust-lang/crates.io-index" 2073 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2074 + 2075 + [[package]] 1833 2076 name = "libc" 1834 2077 version = "0.2.176" 1835 2078 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1921 2164 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 1922 2165 1923 2166 [[package]] 2167 + name = "metrics" 2168 + version = "0.24.2" 2169 + source = "registry+https://github.com/rust-lang/crates.io-index" 2170 + checksum = "25dea7ac8057892855ec285c440160265225438c3c45072613c25a4b26e98ef5" 2171 + dependencies = [ 2172 + "ahash", 2173 + "portable-atomic", 2174 + ] 2175 + 2176 + [[package]] 1924 2177 name = "mime" 1925 2178 version = "0.3.17" 1926 2179 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2003 2256 ] 2004 2257 2005 2258 [[package]] 2259 + name = "nanorand" 2260 + version = "0.7.0" 2261 + source = "registry+https://github.com/rust-lang/crates.io-index" 2262 + checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" 2263 + dependencies = [ 2264 + "getrandom 0.2.16", 2265 + ] 2266 + 2267 + [[package]] 2006 2268 name = "native-tls" 2007 2269 version = "0.2.14" 2008 2270 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2020 2282 ] 2021 2283 2022 2284 [[package]] 2285 + name = "nu-ansi-term" 2286 + version = "0.50.3" 2287 + source = "registry+https://github.com/rust-lang/crates.io-index" 2288 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2289 + dependencies = [ 2290 + "windows-sys 0.61.1", 2291 + ] 2292 + 2293 + [[package]] 2023 2294 name = "num-conv" 2024 2295 version = "0.1.0" 2025 2296 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2143 2414 "libc", 2144 2415 "redox_syscall", 2145 2416 "smallvec", 2146 - "windows-link", 2417 + "windows-link 0.2.0", 2147 2418 ] 2148 2419 2149 2420 [[package]] ··· 2222 2493 ] 2223 2494 2224 2495 [[package]] 2496 + name = "prettyplease" 2497 + version = "0.2.37" 2498 + source = "registry+https://github.com/rust-lang/crates.io-index" 2499 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2500 + dependencies = [ 2501 + "proc-macro2", 2502 + "syn 2.0.106", 2503 + ] 2504 + 2505 + [[package]] 2225 2506 name = "primeorder" 2226 2507 version = "0.13.6" 2227 2508 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2366 2647 "async-compression", 2367 2648 "base64 0.22.1", 2368 2649 "bytes", 2650 + "encoding_rs", 2369 2651 "futures-core", 2370 2652 "futures-util", 2653 + "h2 0.4.12", 2371 2654 "http 1.3.1", 2372 2655 "http-body", 2373 2656 "http-body-util", 2374 2657 "hyper", 2658 + "hyper-rustls", 2375 2659 "hyper-tls", 2376 2660 "hyper-util", 2377 2661 "js-sys", 2378 2662 "log", 2663 + "mime", 2379 2664 "native-tls", 2380 2665 "percent-encoding", 2381 2666 "pin-project-lite", ··· 2413 2698 ] 2414 2699 2415 2700 [[package]] 2701 + name = "ring" 2702 + version = "0.17.14" 2703 + source = "registry+https://github.com/rust-lang/crates.io-index" 2704 + checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 2705 + dependencies = [ 2706 + "cc", 2707 + "cfg-if", 2708 + "getrandom 0.2.16", 2709 + "libc", 2710 + "untrusted", 2711 + "windows-sys 0.52.0", 2712 + ] 2713 + 2714 + [[package]] 2715 + name = "rocketman" 2716 + version = "0.2.5" 2717 + source = "registry+https://github.com/rust-lang/crates.io-index" 2718 + checksum = "90cfc4ee9daf6e9d0ee217b9709aa3bd6c921e6926aa15c6ff5ba9162c2c649a" 2719 + dependencies = [ 2720 + "anyhow", 2721 + "async-trait", 2722 + "bon", 2723 + "derive_builder", 2724 + "flume", 2725 + "futures-util", 2726 + "metrics", 2727 + "rand 0.8.5", 2728 + "serde", 2729 + "serde_json", 2730 + "tokio", 2731 + "tokio-tungstenite", 2732 + "tracing", 2733 + "tracing-subscriber", 2734 + "url", 2735 + "zstd", 2736 + ] 2737 + 2738 + [[package]] 2416 2739 name = "rustc-demangle" 2417 2740 version = "0.1.26" 2418 2741 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2441 2764 ] 2442 2765 2443 2766 [[package]] 2767 + name = "rustls" 2768 + version = "0.21.12" 2769 + source = "registry+https://github.com/rust-lang/crates.io-index" 2770 + checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" 2771 + dependencies = [ 2772 + "log", 2773 + "ring", 2774 + "rustls-webpki 0.101.7", 2775 + "sct", 2776 + ] 2777 + 2778 + [[package]] 2779 + name = "rustls" 2780 + version = "0.23.31" 2781 + source = "registry+https://github.com/rust-lang/crates.io-index" 2782 + checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" 2783 + dependencies = [ 2784 + "once_cell", 2785 + "rustls-pki-types", 2786 + "rustls-webpki 0.103.4", 2787 + "subtle", 2788 + "zeroize", 2789 + ] 2790 + 2791 + [[package]] 2792 + name = "rustls-native-certs" 2793 + version = "0.6.3" 2794 + source = "registry+https://github.com/rust-lang/crates.io-index" 2795 + checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" 2796 + dependencies = [ 2797 + "openssl-probe", 2798 + "rustls-pemfile", 2799 + "schannel", 2800 + "security-framework", 2801 + ] 2802 + 2803 + [[package]] 2804 + name = "rustls-pemfile" 2805 + version = "1.0.4" 2806 + source = "registry+https://github.com/rust-lang/crates.io-index" 2807 + checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" 2808 + dependencies = [ 2809 + "base64 0.21.7", 2810 + ] 2811 + 2812 + [[package]] 2444 2813 name = "rustls-pki-types" 2445 2814 version = "1.12.0" 2446 2815 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2450 2819 ] 2451 2820 2452 2821 [[package]] 2822 + name = "rustls-webpki" 2823 + version = "0.101.7" 2824 + source = "registry+https://github.com/rust-lang/crates.io-index" 2825 + checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" 2826 + dependencies = [ 2827 + "ring", 2828 + "untrusted", 2829 + ] 2830 + 2831 + [[package]] 2832 + name = "rustls-webpki" 2833 + version = "0.103.4" 2834 + source = "registry+https://github.com/rust-lang/crates.io-index" 2835 + checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" 2836 + dependencies = [ 2837 + "ring", 2838 + "rustls-pki-types", 2839 + "untrusted", 2840 + ] 2841 + 2842 + [[package]] 2453 2843 name = "rustversion" 2454 2844 version = "1.0.22" 2455 2845 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2475 2865 version = "1.2.0" 2476 2866 source = "registry+https://github.com/rust-lang/crates.io-index" 2477 2867 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2868 + 2869 + [[package]] 2870 + name = "sct" 2871 + version = "0.7.1" 2872 + source = "registry+https://github.com/rust-lang/crates.io-index" 2873 + checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" 2874 + dependencies = [ 2875 + "ring", 2876 + "untrusted", 2877 + ] 2478 2878 2479 2879 [[package]] 2480 2880 name = "sec1" ··· 2619 3019 ] 2620 3020 2621 3021 [[package]] 3022 + name = "sharded-slab" 3023 + version = "0.1.7" 3024 + source = "registry+https://github.com/rust-lang/crates.io-index" 3025 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3026 + dependencies = [ 3027 + "lazy_static", 3028 + ] 3029 + 3030 + [[package]] 2622 3031 name = "shlex" 2623 3032 version = "1.3.0" 2624 3033 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2682 3091 ] 2683 3092 2684 3093 [[package]] 3094 + name = "spin" 3095 + version = "0.9.8" 3096 + source = "registry+https://github.com/rust-lang/crates.io-index" 3097 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3098 + dependencies = [ 3099 + "lock_api", 3100 + ] 3101 + 3102 + [[package]] 2685 3103 name = "stable_deref_trait" 2686 3104 version = "1.2.0" 2687 3105 source = "registry+https://github.com/rust-lang/crates.io-index" 2688 3106 checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 3107 + 3108 + [[package]] 3109 + name = "strsim" 3110 + version = "0.11.1" 3111 + source = "registry+https://github.com/rust-lang/crates.io-index" 3112 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 2689 3113 2690 3114 [[package]] 2691 3115 name = "subtle" ··· 2736 3160 ] 2737 3161 2738 3162 [[package]] 3163 + name = "system-configuration" 3164 + version = "0.6.1" 3165 + source = "registry+https://github.com/rust-lang/crates.io-index" 3166 + checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 3167 + dependencies = [ 3168 + "bitflags", 3169 + "core-foundation", 3170 + "system-configuration-sys", 3171 + ] 3172 + 3173 + [[package]] 3174 + name = "system-configuration-sys" 3175 + version = "0.6.0" 3176 + source = "registry+https://github.com/rust-lang/crates.io-index" 3177 + checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 3178 + dependencies = [ 3179 + "core-foundation-sys", 3180 + "libc", 3181 + ] 3182 + 3183 + [[package]] 2739 3184 name = "tagptr" 2740 3185 version = "0.2.0" 2741 3186 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2772 3217 "proc-macro2", 2773 3218 "quote", 2774 3219 "syn 2.0.106", 3220 + ] 3221 + 3222 + [[package]] 3223 + name = "thread_local" 3224 + version = "1.1.9" 3225 + source = "registry+https://github.com/rust-lang/crates.io-index" 3226 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 3227 + dependencies = [ 3228 + "cfg-if", 2775 3229 ] 2776 3230 2777 3231 [[package]] ··· 2872 3326 ] 2873 3327 2874 3328 [[package]] 3329 + name = "tokio-rustls" 3330 + version = "0.24.1" 3331 + source = "registry+https://github.com/rust-lang/crates.io-index" 3332 + checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" 3333 + dependencies = [ 3334 + "rustls 0.21.12", 3335 + "tokio", 3336 + ] 3337 + 3338 + [[package]] 3339 + name = "tokio-rustls" 3340 + version = "0.26.2" 3341 + source = "registry+https://github.com/rust-lang/crates.io-index" 3342 + checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 3343 + dependencies = [ 3344 + "rustls 0.23.31", 3345 + "tokio", 3346 + ] 3347 + 3348 + [[package]] 3349 + name = "tokio-tungstenite" 3350 + version = "0.20.1" 3351 + source = "registry+https://github.com/rust-lang/crates.io-index" 3352 + checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" 3353 + dependencies = [ 3354 + "futures-util", 3355 + "log", 3356 + "rustls 0.21.12", 3357 + "rustls-native-certs", 3358 + "tokio", 3359 + "tokio-rustls 0.24.1", 3360 + "tungstenite", 3361 + "webpki-roots", 3362 + ] 3363 + 3364 + [[package]] 2875 3365 name = "tokio-util" 2876 3366 version = "0.7.16" 2877 3367 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2959 3449 checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 2960 3450 dependencies = [ 2961 3451 "once_cell", 3452 + "valuable", 3453 + ] 3454 + 3455 + [[package]] 3456 + name = "tracing-log" 3457 + version = "0.2.0" 3458 + source = "registry+https://github.com/rust-lang/crates.io-index" 3459 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 3460 + dependencies = [ 3461 + "log", 3462 + "once_cell", 3463 + "tracing-core", 3464 + ] 3465 + 3466 + [[package]] 3467 + name = "tracing-subscriber" 3468 + version = "0.3.20" 3469 + source = "registry+https://github.com/rust-lang/crates.io-index" 3470 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 3471 + dependencies = [ 3472 + "nu-ansi-term", 3473 + "sharded-slab", 3474 + "smallvec", 3475 + "thread_local", 3476 + "tracing-core", 3477 + "tracing-log", 2962 3478 ] 2963 3479 2964 3480 [[package]] ··· 2979 3495 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2980 3496 2981 3497 [[package]] 3498 + name = "tungstenite" 3499 + version = "0.20.1" 3500 + source = "registry+https://github.com/rust-lang/crates.io-index" 3501 + checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" 3502 + dependencies = [ 3503 + "byteorder", 3504 + "bytes", 3505 + "data-encoding", 3506 + "http 0.2.12", 3507 + "httparse", 3508 + "log", 3509 + "rand 0.8.5", 3510 + "rustls 0.21.12", 3511 + "sha1", 3512 + "thiserror", 3513 + "url", 3514 + "utf-8", 3515 + ] 3516 + 3517 + [[package]] 2982 3518 name = "typenum" 2983 3519 version = "1.19.0" 2984 3520 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3017 3553 version = "0.8.0" 3018 3554 source = "registry+https://github.com/rust-lang/crates.io-index" 3019 3555 checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 3556 + 3557 + [[package]] 3558 + name = "untrusted" 3559 + version = "0.9.0" 3560 + source = "registry+https://github.com/rust-lang/crates.io-index" 3561 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 3020 3562 3021 3563 [[package]] 3022 3564 name = "url" ··· 3031 3573 ] 3032 3574 3033 3575 [[package]] 3576 + name = "utf-8" 3577 + version = "0.7.6" 3578 + source = "registry+https://github.com/rust-lang/crates.io-index" 3579 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 3580 + 3581 + [[package]] 3034 3582 name = "utf8_iter" 3035 3583 version = "1.0.4" 3036 3584 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3060 3608 checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" 3061 3609 3062 3610 [[package]] 3611 + name = "valuable" 3612 + version = "0.1.1" 3613 + source = "registry+https://github.com/rust-lang/crates.io-index" 3614 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 3615 + 3616 + [[package]] 3063 3617 name = "vcpkg" 3064 3618 version = "0.2.15" 3065 3619 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3197 3751 ] 3198 3752 3199 3753 [[package]] 3754 + name = "webpki-roots" 3755 + version = "0.25.4" 3756 + source = "registry+https://github.com/rust-lang/crates.io-index" 3757 + checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" 3758 + 3759 + [[package]] 3200 3760 name = "widestring" 3201 3761 version = "1.2.0" 3202 3762 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3210 3770 dependencies = [ 3211 3771 "windows-implement", 3212 3772 "windows-interface", 3213 - "windows-link", 3214 - "windows-result", 3215 - "windows-strings", 3773 + "windows-link 0.2.0", 3774 + "windows-result 0.4.0", 3775 + "windows-strings 0.5.0", 3216 3776 ] 3217 3777 3218 3778 [[package]] ··· 3239 3799 3240 3800 [[package]] 3241 3801 name = "windows-link" 3802 + version = "0.1.3" 3803 + source = "registry+https://github.com/rust-lang/crates.io-index" 3804 + checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 3805 + 3806 + [[package]] 3807 + name = "windows-link" 3242 3808 version = "0.2.0" 3243 3809 source = "registry+https://github.com/rust-lang/crates.io-index" 3244 3810 checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 3245 3811 3246 3812 [[package]] 3813 + name = "windows-registry" 3814 + version = "0.5.3" 3815 + source = "registry+https://github.com/rust-lang/crates.io-index" 3816 + checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" 3817 + dependencies = [ 3818 + "windows-link 0.1.3", 3819 + "windows-result 0.3.4", 3820 + "windows-strings 0.4.2", 3821 + ] 3822 + 3823 + [[package]] 3824 + name = "windows-result" 3825 + version = "0.3.4" 3826 + source = "registry+https://github.com/rust-lang/crates.io-index" 3827 + checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 3828 + dependencies = [ 3829 + "windows-link 0.1.3", 3830 + ] 3831 + 3832 + [[package]] 3247 3833 name = "windows-result" 3248 3834 version = "0.4.0" 3249 3835 source = "registry+https://github.com/rust-lang/crates.io-index" 3250 3836 checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" 3251 3837 dependencies = [ 3252 - "windows-link", 3838 + "windows-link 0.2.0", 3839 + ] 3840 + 3841 + [[package]] 3842 + name = "windows-strings" 3843 + version = "0.4.2" 3844 + source = "registry+https://github.com/rust-lang/crates.io-index" 3845 + checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 3846 + dependencies = [ 3847 + "windows-link 0.1.3", 3253 3848 ] 3254 3849 3255 3850 [[package]] ··· 3258 3853 source = "registry+https://github.com/rust-lang/crates.io-index" 3259 3854 checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" 3260 3855 dependencies = [ 3261 - "windows-link", 3856 + "windows-link 0.2.0", 3262 3857 ] 3263 3858 3264 3859 [[package]] ··· 3303 3898 source = "registry+https://github.com/rust-lang/crates.io-index" 3304 3899 checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" 3305 3900 dependencies = [ 3306 - "windows-link", 3901 + "windows-link 0.2.0", 3307 3902 ] 3308 3903 3309 3904 [[package]] ··· 3343 3938 source = "registry+https://github.com/rust-lang/crates.io-index" 3344 3939 checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" 3345 3940 dependencies = [ 3346 - "windows-link", 3941 + "windows-link 0.2.0", 3347 3942 "windows_aarch64_gnullvm 0.53.0", 3348 3943 "windows_aarch64_msvc 0.53.0", 3349 3944 "windows_i686_gnu 0.53.0",
+6
Cargo.toml
··· 17 17 hickory-resolver = "0.24" 18 18 env_logger = "0.11" 19 19 log = "0.4" 20 + reqwest = { version = "0.12", features = ["json"] } 21 + rocketman = "0.2.0" 22 + futures-util = "0.3" 23 + anyhow = "1.0" 24 + async-stream = "0.3" 25 + async-trait = "0.1"
+200
src/firehose.rs
··· 1 + use anyhow::Result; 2 + use async_trait::async_trait; 3 + use log::{error, info}; 4 + use rocketman::{ 5 + connection::JetstreamConnection, 6 + ingestion::LexiconIngestor, 7 + options::JetstreamOptions, 8 + types::event::{Event, Operation}, 9 + }; 10 + use serde::{Deserialize, Serialize}; 11 + use serde_json::Value; 12 + use std::collections::HashMap; 13 + use std::sync::{Arc, Mutex}; 14 + use tokio::sync::broadcast; 15 + 16 + /// Represents a firehose event that will be sent to the browser 17 + #[derive(Debug, Clone, Serialize, Deserialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct FirehoseEvent { 20 + pub did: String, 21 + pub action: String, // "create", "update", or "delete" 22 + pub collection: String, 23 + pub rkey: String, 24 + pub namespace: String, // e.g., "app.bsky" extracted from collection 25 + } 26 + 27 + /// Broadcaster for firehose events 28 + pub type FirehoseBroadcaster = Arc<broadcast::Sender<FirehoseEvent>>; 29 + 30 + /// Manager for DID-specific firehose connections 31 + pub type FirehoseManager = Arc<Mutex<HashMap<String, FirehoseBroadcaster>>>; 32 + 33 + /// A generic ingester that broadcasts all events 34 + struct BroadcastIngester { 35 + broadcaster: FirehoseBroadcaster, 36 + } 37 + 38 + #[async_trait] 39 + impl LexiconIngestor for BroadcastIngester { 40 + async fn ingest(&self, message: Event<Value>) -> Result<()> { 41 + // Only process commit events 42 + let Some(commit) = &message.commit else { 43 + return Ok(()); 44 + }; 45 + 46 + // Extract namespace from collection (e.g., "app.bsky.feed.post" -> "app.bsky") 47 + let collection_parts: Vec<&str> = commit.collection.split('.').collect(); 48 + let namespace = if collection_parts.len() >= 2 { 49 + format!("{}.{}", collection_parts[0], collection_parts[1]) 50 + } else { 51 + commit.collection.clone() 52 + }; 53 + 54 + let action = match commit.operation { 55 + Operation::Create => "create", 56 + Operation::Update => "update", 57 + Operation::Delete => "delete", 58 + }; 59 + 60 + let firehose_event = FirehoseEvent { 61 + did: message.did.clone(), 62 + action: action.to_string(), 63 + collection: commit.collection.clone(), 64 + rkey: commit.rkey.clone(), 65 + namespace: namespace.clone(), 66 + }; 67 + 68 + info!( 69 + "Received event: {} {} {} (namespace: {})", 70 + action, message.did, commit.collection, namespace 71 + ); 72 + 73 + // Broadcast the event (ignore if no receivers) 74 + match self.broadcaster.send(firehose_event) { 75 + Ok(receivers) => { 76 + info!("Broadcast to {} receivers", receivers); 77 + } 78 + Err(_) => { 79 + // No receivers, that's ok 80 + } 81 + } 82 + 83 + Ok(()) 84 + } 85 + } 86 + 87 + /// Create a new FirehoseManager 88 + pub fn create_firehose_manager() -> FirehoseManager { 89 + Arc::new(Mutex::new(HashMap::new())) 90 + } 91 + 92 + /// Get or create a firehose broadcaster for a specific DID 93 + pub async fn get_or_create_broadcaster( 94 + manager: &FirehoseManager, 95 + did: String, 96 + ) -> FirehoseBroadcaster { 97 + // Check if we already have a broadcaster for this DID 98 + { 99 + let broadcasters = manager.lock().unwrap(); 100 + if let Some(broadcaster) = broadcasters.get(&did) { 101 + info!("Reusing existing firehose connection for DID: {}", did); 102 + return broadcaster.clone(); 103 + } 104 + } 105 + 106 + info!("Creating new firehose connection for DID: {}", did); 107 + 108 + // Create a broadcast channel with a buffer of 100 events 109 + let (tx, _rx) = broadcast::channel::<FirehoseEvent>(100); 110 + let broadcaster = Arc::new(tx); 111 + 112 + // Store in manager 113 + { 114 + let mut broadcasters = manager.lock().unwrap(); 115 + broadcasters.insert(did.clone(), broadcaster.clone()); 116 + } 117 + 118 + // Clone for the spawn 119 + let broadcaster_clone = broadcaster.clone(); 120 + let did_clone = did.clone(); 121 + 122 + tokio::spawn(async move { 123 + loop { 124 + info!("Starting Jetstream connection for DID: {}...", did_clone); 125 + 126 + // Configure Jetstream to receive events ONLY for this DID 127 + let opts = JetstreamOptions::builder() 128 + .wanted_dids(vec![did_clone.clone()]) 129 + .build(); 130 + let jetstream = JetstreamConnection::new(opts); 131 + 132 + let mut ingesters: HashMap<String, Box<dyn LexiconIngestor + Send + Sync>> = 133 + HashMap::new(); 134 + 135 + // Register ingesters for common Bluesky collections 136 + let collections = vec![ 137 + "app.bsky.feed.post", 138 + "app.bsky.feed.like", 139 + "app.bsky.feed.repost", 140 + "app.bsky.graph.follow", 141 + "app.bsky.actor.profile", 142 + ]; 143 + 144 + for collection in collections { 145 + ingesters.insert( 146 + collection.to_string(), 147 + Box::new(BroadcastIngester { 148 + broadcaster: broadcaster_clone.clone(), 149 + }), 150 + ); 151 + } 152 + 153 + // Get channels 154 + let msg_rx = jetstream.get_msg_rx(); 155 + let reconnect_tx = jetstream.get_reconnect_tx(); 156 + 157 + // Cursor for tracking last processed message 158 + let cursor: Arc<Mutex<Option<u64>>> = Arc::new(Mutex::new(None)); 159 + let c_cursor = cursor.clone(); 160 + 161 + // Spawn task to process messages using proper handler 162 + tokio::spawn(async move { 163 + info!("Starting message processing loop for DID-filtered connection"); 164 + while let Ok(message) = msg_rx.recv_async().await { 165 + if let Err(e) = rocketman::handler::handle_message( 166 + message, 167 + &ingesters, 168 + reconnect_tx.clone(), 169 + c_cursor.clone(), 170 + ) 171 + .await 172 + { 173 + error!("Error processing message: {}", e); 174 + } 175 + } 176 + }); 177 + 178 + // Connect to Jetstream 179 + let failed = { 180 + let connect_result = jetstream.connect(cursor).await; 181 + if let Err(e) = connect_result { 182 + error!("Jetstream connection failed for DID {}: {}", did_clone, e); 183 + true 184 + } else { 185 + false 186 + } 187 + }; 188 + 189 + if failed { 190 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 191 + continue; 192 + } 193 + 194 + info!("Jetstream connection dropped for DID: {}, reconnecting in 5 seconds...", did_clone); 195 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 196 + } 197 + }); 198 + 199 + broadcaster 200 + }
+14
src/main.rs
··· 2 2 use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web}; 3 3 use actix_files::Files; 4 4 5 + mod firehose; 6 + mod mst; 5 7 mod oauth; 6 8 mod routes; 7 9 mod templates; ··· 11 13 env_logger::init(); 12 14 13 15 let client = oauth::create_oauth_client(); 16 + 17 + // Create the firehose manager (connections created lazily per-DID) 18 + let firehose_manager = firehose::create_firehose_manager(); 14 19 15 20 println!("starting server at http://localhost:8080"); 16 21 ··· 30 35 .build(), 31 36 ) 32 37 .app_data(web::Data::new(client.clone())) 38 + .app_data(web::Data::new(firehose_manager.clone())) 33 39 .service(routes::index) 34 40 .service(routes::login) 41 + .service(routes::demo) 42 + .service(routes::demo_exit) 35 43 .service(routes::callback) 36 44 .service(routes::client_metadata) 37 45 .service(routes::logout) 38 46 .service(routes::restore_session) 47 + .service(routes::get_mst) 48 + .service(routes::init) 49 + .service(routes::get_avatar) 50 + .service(routes::validate_url) 51 + .service(routes::get_record) 52 + .service(routes::firehose_watch) 39 53 .service(routes::favicon) 40 54 .service(Files::new("/static", "./static")) 41 55 })
+164
src/mst.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::collections::HashMap; 3 + 4 + #[derive(Debug, Serialize, Deserialize, Clone)] 5 + pub struct Record { 6 + pub uri: String, 7 + pub cid: String, 8 + pub value: serde_json::Value, 9 + } 10 + 11 + #[derive(Debug, Serialize, Clone)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct MSTNode { 14 + pub key: String, 15 + pub cid: Option<String>, 16 + pub uri: Option<String>, 17 + pub value: Option<serde_json::Value>, 18 + pub depth: i32, 19 + pub children: Vec<MSTNode>, 20 + } 21 + 22 + #[derive(Debug, Serialize)] 23 + #[serde(rename_all = "camelCase")] 24 + pub struct MSTResponse { 25 + pub root: MSTNode, 26 + pub record_count: usize, 27 + } 28 + 29 + pub fn build_mst(records: Vec<Record>) -> MSTResponse { 30 + let record_count = records.len(); 31 + 32 + // Extract and sort by key 33 + let mut nodes: Vec<MSTNode> = records 34 + .into_iter() 35 + .map(|r| { 36 + let key = r.uri.split('/').last().unwrap_or("").to_string(); 37 + MSTNode { 38 + key: key.clone(), 39 + cid: Some(r.cid), 40 + uri: Some(r.uri), 41 + value: Some(r.value), 42 + depth: calculate_key_depth(&key), 43 + children: vec![], 44 + } 45 + }) 46 + .collect(); 47 + 48 + nodes.sort_by(|a, b| a.key.cmp(&b.key)); 49 + 50 + // Build tree structure 51 + let root = build_tree(nodes); 52 + 53 + MSTResponse { 54 + root, 55 + record_count, 56 + } 57 + } 58 + 59 + fn calculate_key_depth(key: &str) -> i32 { 60 + // Simplified depth calculation based on key hash 61 + let mut hash: i32 = 0; 62 + for ch in key.chars() { 63 + hash = hash.wrapping_shl(5).wrapping_sub(hash).wrapping_add(ch as i32); 64 + } 65 + 66 + // Count leading zero bits (approximation) 67 + let abs_hash = hash.abs() as u32; 68 + let binary = format!("{:032b}", abs_hash); 69 + 70 + let mut depth = 0; 71 + let chars: Vec<char> = binary.chars().collect(); 72 + let mut i = 0; 73 + while i < chars.len() - 1 { 74 + if chars[i] == '0' && chars[i + 1] == '0' { 75 + depth += 1; 76 + i += 2; 77 + } else { 78 + break; 79 + } 80 + } 81 + 82 + depth.min(5) 83 + } 84 + 85 + fn build_tree(nodes: Vec<MSTNode>) -> MSTNode { 86 + if nodes.is_empty() { 87 + return MSTNode { 88 + key: "root".to_string(), 89 + cid: None, 90 + uri: None, 91 + value: None, 92 + depth: -1, 93 + children: vec![], 94 + }; 95 + } 96 + 97 + // Group by depth 98 + let mut by_depth: HashMap<i32, Vec<MSTNode>> = HashMap::new(); 99 + for node in nodes { 100 + by_depth.entry(node.depth).or_insert_with(Vec::new).push(node); 101 + } 102 + 103 + let mut depths: Vec<i32> = by_depth.keys().copied().collect(); 104 + depths.sort(); 105 + 106 + // Build tree bottom-up 107 + let mut current_level: Vec<MSTNode> = by_depth.remove(&depths[depths.len() - 1]).unwrap_or_default(); 108 + 109 + // Work backwards through depths 110 + for i in (0..depths.len() - 1).rev() { 111 + let depth = depths[i]; 112 + let mut parent_nodes = by_depth.remove(&depth).unwrap_or_default(); 113 + 114 + // Distribute children to parents 115 + let children_per_parent = if parent_nodes.is_empty() { 116 + 0 117 + } else { 118 + (current_level.len() + parent_nodes.len() - 1) / parent_nodes.len() 119 + }; 120 + 121 + for (i, parent) in parent_nodes.iter_mut().enumerate() { 122 + let start = i * children_per_parent; 123 + let end = ((i + 1) * children_per_parent).min(current_level.len()); 124 + if start < current_level.len() { 125 + parent.children = current_level.drain(start..end).collect(); 126 + } 127 + } 128 + 129 + current_level = parent_nodes; 130 + } 131 + 132 + // Create root and attach top-level nodes 133 + MSTNode { 134 + key: "root".to_string(), 135 + cid: None, 136 + uri: None, 137 + value: None, 138 + depth: -1, 139 + children: current_level, 140 + } 141 + } 142 + 143 + pub async fn fetch_records(pds: &str, did: &str, collection: &str) -> Result<Vec<Record>, String> { 144 + let url = format!( 145 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100", 146 + pds, did, collection 147 + ); 148 + 149 + let response = reqwest::get(&url) 150 + .await 151 + .map_err(|e| format!("Failed to fetch records: {}", e))?; 152 + 153 + #[derive(Deserialize)] 154 + struct ListRecordsResponse { 155 + records: Vec<Record>, 156 + } 157 + 158 + let data: ListRecordsResponse = response 159 + .json() 160 + .await 161 + .map_err(|e| format!("Failed to parse response: {}", e))?; 162 + 163 + Ok(data.records) 164 + }
+364 -3
src/routes.rs
··· 3 3 use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope}; 4 4 use serde::Deserialize; 5 5 6 + use crate::firehose::FirehoseManager; 7 + use crate::mst; 6 8 use crate::oauth::OAuthClientType; 7 9 use crate::templates; 8 10 ··· 26 28 let did: Option<String> = session.get("did").unwrap_or(None); 27 29 28 30 match did { 29 - Some(did) => HttpResponse::Ok() 30 - .content_type("text/html") 31 - .body(templates::app_page(&did)), 31 + Some(did) => { 32 + let demo_mode: bool = session.get("demo_mode").unwrap_or(Some(false)).unwrap_or(false); 33 + let demo_handle: Option<String> = session.get("demo_handle").unwrap_or(None); 34 + 35 + HttpResponse::Ok() 36 + .content_type("text/html") 37 + .body(templates::app_page(&did, demo_mode, demo_handle.as_deref())) 38 + }, 32 39 None => HttpResponse::Ok() 33 40 .content_type("text/html") 34 41 .body(templates::login_page()), ··· 62 69 } 63 70 } 64 71 72 + #[get("/demo")] 73 + pub async fn demo(session: Session) -> HttpResponse { 74 + let demo_handle = "pfrazee.com"; 75 + 76 + // Resolve handle to DID 77 + let resolve_url = format!( 78 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}", 79 + demo_handle 80 + ); 81 + 82 + let did = match reqwest::get(&resolve_url).await { 83 + Ok(response) => match response.json::<serde_json::Value>().await { 84 + Ok(data) => match data["did"].as_str() { 85 + Some(did) => did.to_string(), 86 + None => return HttpResponse::InternalServerError().body("failed to resolve demo handle"), 87 + }, 88 + Err(_) => return HttpResponse::InternalServerError().body("failed to parse response"), 89 + }, 90 + Err(_) => return HttpResponse::InternalServerError().body("failed to resolve demo handle"), 91 + }; 92 + 93 + // Store in session with demo flag 94 + session.insert("did", &did).unwrap(); 95 + session.insert("demo_mode", true).unwrap(); 96 + session.insert("demo_handle", demo_handle).unwrap(); 97 + 98 + HttpResponse::SeeOther() 99 + .append_header(("Location", "/")) 100 + .finish() 101 + } 102 + 103 + #[get("/demo/exit")] 104 + pub async fn demo_exit(session: Session) -> HttpResponse { 105 + session.purge(); 106 + HttpResponse::SeeOther() 107 + .append_header(("Location", "/?clear_demo=true")) 108 + .finish() 109 + } 110 + 65 111 #[get("/oauth/callback")] 66 112 pub async fn callback( 67 113 params: web::Query<OAuthParams>, ··· 151 197 .content_type("image/svg+xml") 152 198 .body(FAVICON_SVG) 153 199 } 200 + 201 + #[derive(Deserialize)] 202 + pub struct MSTQuery { 203 + pds: String, 204 + did: String, 205 + collection: String, 206 + } 207 + 208 + #[get("/api/mst")] 209 + pub async fn get_mst(query: web::Query<MSTQuery>) -> HttpResponse { 210 + match mst::fetch_records(&query.pds, &query.did, &query.collection).await { 211 + Ok(records) => { 212 + if records.is_empty() { 213 + return HttpResponse::Ok().json(serde_json::json!({ 214 + "error": "no records found" 215 + })); 216 + } 217 + 218 + let mst_data = mst::build_mst(records); 219 + HttpResponse::Ok().json(mst_data) 220 + } 221 + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 222 + "error": e 223 + })), 224 + } 225 + } 226 + 227 + #[derive(Deserialize)] 228 + pub struct InitQuery { 229 + did: String, 230 + } 231 + 232 + #[derive(serde::Serialize)] 233 + #[serde(rename_all = "camelCase")] 234 + pub struct AppInfo { 235 + namespace: String, 236 + collections: Vec<String>, 237 + } 238 + 239 + #[derive(serde::Serialize)] 240 + #[serde(rename_all = "camelCase")] 241 + pub struct InitResponse { 242 + did: String, 243 + handle: String, 244 + pds: String, 245 + avatar: Option<String>, 246 + apps: Vec<AppInfo>, 247 + } 248 + 249 + #[get("/api/init")] 250 + pub async fn init(query: web::Query<InitQuery>) -> HttpResponse { 251 + let did = &query.did; 252 + 253 + // Fetch DID document 254 + let did_doc_url = format!("https://plc.directory/{}", did); 255 + let did_doc_response = match reqwest::get(&did_doc_url).await { 256 + Ok(r) => r, 257 + Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({ 258 + "error": format!("failed to fetch DID document: {}", e) 259 + })), 260 + }; 261 + 262 + let did_doc: serde_json::Value = match did_doc_response.json().await { 263 + Ok(d) => d, 264 + Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({ 265 + "error": format!("failed to parse DID document: {}", e) 266 + })), 267 + }; 268 + 269 + // Extract PDS and handle 270 + let pds = did_doc["service"] 271 + .as_array() 272 + .and_then(|services| { 273 + services.iter().find(|s| { 274 + s["type"].as_str() == Some("AtprotoPersonalDataServer") 275 + }) 276 + }) 277 + .and_then(|s| s["serviceEndpoint"].as_str()) 278 + .unwrap_or("") 279 + .to_string(); 280 + 281 + let handle = did_doc["alsoKnownAs"] 282 + .as_array() 283 + .and_then(|aka| aka.get(0)) 284 + .and_then(|v| v.as_str()) 285 + .map(|s| s.replace("at://", "")) 286 + .unwrap_or_else(|| did.to_string()); 287 + 288 + // Fetch user avatar from Bluesky 289 + let avatar = fetch_user_avatar(did).await; 290 + 291 + // Fetch collections from PDS 292 + let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); 293 + let repo_response = match reqwest::get(&repo_url).await { 294 + Ok(r) => r, 295 + Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({ 296 + "error": format!("failed to fetch repo: {}", e) 297 + })), 298 + }; 299 + 300 + let repo_data: serde_json::Value = match repo_response.json().await { 301 + Ok(d) => d, 302 + Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({ 303 + "error": format!("failed to parse repo: {}", e) 304 + })), 305 + }; 306 + 307 + let collections = repo_data["collections"] 308 + .as_array() 309 + .map(|arr| { 310 + arr.iter() 311 + .filter_map(|v| v.as_str().map(String::from)) 312 + .collect::<Vec<String>>() 313 + }) 314 + .unwrap_or_default(); 315 + 316 + // Group by namespace 317 + let mut apps: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new(); 318 + for collection in collections { 319 + let parts: Vec<&str> = collection.split('.').collect(); 320 + if parts.len() >= 2 { 321 + let namespace = format!("{}.{}", parts[0], parts[1]); 322 + apps.entry(namespace) 323 + .or_insert_with(Vec::new) 324 + .push(collection); 325 + } 326 + } 327 + 328 + let apps_list: Vec<AppInfo> = apps 329 + .into_iter() 330 + .map(|(namespace, collections)| AppInfo { namespace, collections }) 331 + .collect(); 332 + 333 + HttpResponse::Ok().json(InitResponse { 334 + did: did.to_string(), 335 + handle, 336 + pds, 337 + avatar, 338 + apps: apps_list, 339 + }) 340 + } 341 + 342 + async fn fetch_user_avatar(did: &str) -> Option<String> { 343 + let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did); 344 + if let Ok(response) = reqwest::get(&profile_url).await { 345 + if let Ok(profile) = response.json::<serde_json::Value>().await { 346 + return profile["avatar"].as_str().map(String::from); 347 + } 348 + } 349 + None 350 + } 351 + 352 + #[derive(Deserialize)] 353 + pub struct AvatarQuery { 354 + namespace: String, 355 + } 356 + 357 + #[get("/api/avatar")] 358 + pub async fn get_avatar(query: web::Query<AvatarQuery>) -> HttpResponse { 359 + let namespace = &query.namespace; 360 + 361 + // Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io) 362 + let reversed: String = namespace.split('.').rev().collect::<Vec<&str>>().join("."); 363 + let handles = vec![ 364 + reversed.clone(), 365 + format!("{}.bsky.social", reversed), 366 + ]; 367 + 368 + for handle in handles { 369 + // Try to resolve handle to DID 370 + let resolve_url = format!("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}", handle); 371 + if let Ok(response) = reqwest::get(&resolve_url).await { 372 + if let Ok(data) = response.json::<serde_json::Value>().await { 373 + if let Some(did) = data["did"].as_str() { 374 + // Try to get profile 375 + let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did); 376 + if let Ok(profile_response) = reqwest::get(&profile_url).await { 377 + if let Ok(profile) = profile_response.json::<serde_json::Value>().await { 378 + if let Some(avatar) = profile["avatar"].as_str() { 379 + return HttpResponse::Ok().json(serde_json::json!({ 380 + "avatarUrl": avatar 381 + })); 382 + } 383 + } 384 + } 385 + } 386 + } 387 + } 388 + } 389 + 390 + HttpResponse::Ok().json(serde_json::json!({ 391 + "avatarUrl": null 392 + })) 393 + } 394 + 395 + #[derive(Deserialize)] 396 + pub struct ValidateUrlQuery { 397 + url: String, 398 + } 399 + 400 + #[get("/api/validate-url")] 401 + pub async fn validate_url(query: web::Query<ValidateUrlQuery>) -> HttpResponse { 402 + let url = &query.url; 403 + 404 + // Build client with redirect following and timeout 405 + let client = reqwest::Client::builder() 406 + .timeout(std::time::Duration::from_secs(3)) 407 + .redirect(reqwest::redirect::Policy::limited(5)) 408 + .build() 409 + .unwrap(); 410 + 411 + // Try HEAD first, fall back to GET if HEAD doesn't succeed 412 + let is_valid = match client.head(url).send().await { 413 + Ok(response) => { 414 + let status = response.status(); 415 + if status.is_success() || status.is_redirection() { 416 + true 417 + } else { 418 + // HEAD returned error status (like 405), try GET 419 + match client.get(url).send().await { 420 + Ok(get_response) => get_response.status().is_success(), 421 + Err(_) => false, 422 + } 423 + } 424 + } 425 + Err(_) => { 426 + // HEAD request failed completely, try GET as fallback 427 + match client.get(url).send().await { 428 + Ok(response) => response.status().is_success(), 429 + Err(_) => false, 430 + } 431 + } 432 + }; 433 + 434 + HttpResponse::Ok().json(serde_json::json!({ 435 + "valid": is_valid 436 + })) 437 + } 438 + 439 + #[derive(Deserialize)] 440 + pub struct RecordQuery { 441 + pds: String, 442 + did: String, 443 + collection: String, 444 + rkey: String, 445 + } 446 + 447 + #[get("/api/record")] 448 + pub async fn get_record(query: web::Query<RecordQuery>) -> HttpResponse { 449 + let record_url = format!( 450 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 451 + query.pds, query.did, query.collection, query.rkey 452 + ); 453 + 454 + match reqwest::get(&record_url).await { 455 + Ok(response) => { 456 + if !response.status().is_success() { 457 + return HttpResponse::Ok().json(serde_json::json!({ 458 + "error": "record not found" 459 + })); 460 + } 461 + 462 + match response.json::<serde_json::Value>().await { 463 + Ok(data) => HttpResponse::Ok().json(data), 464 + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 465 + "error": format!("failed to parse record: {}", e) 466 + })), 467 + } 468 + } 469 + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 470 + "error": format!("failed to fetch record: {}", e) 471 + })), 472 + } 473 + } 474 + 475 + #[derive(Deserialize)] 476 + pub struct FirehoseQuery { 477 + did: String, 478 + } 479 + 480 + #[get("/api/firehose/watch")] 481 + pub async fn firehose_watch( 482 + query: web::Query<FirehoseQuery>, 483 + manager: web::Data<FirehoseManager>, 484 + ) -> HttpResponse { 485 + let did = query.did.clone(); 486 + 487 + // Get or create a broadcaster for this DID 488 + let broadcaster = crate::firehose::get_or_create_broadcaster(&manager, did.clone()).await; 489 + let mut rx = broadcaster.subscribe(); 490 + 491 + log::info!("SSE connection established for DID: {}", did); 492 + 493 + let stream = async_stream::stream! { 494 + // Send initial connection message 495 + yield Ok::<_, actix_web::Error>( 496 + web::Bytes::from(format!("data: {{\"type\":\"connected\"}}\n\n")) 497 + ); 498 + 499 + log::info!("Sent initial connection message to client"); 500 + 501 + // Stream firehose events (already filtered by DID at Jetstream level) 502 + while let Ok(event) = rx.recv().await { 503 + log::info!("Sending event to client: {} {} {}", event.action, event.did, event.collection); 504 + let json = serde_json::to_string(&event).unwrap_or_default(); 505 + yield Ok(web::Bytes::from(format!("data: {}\n\n", json))); 506 + } 507 + }; 508 + 509 + HttpResponse::Ok() 510 + .content_type("text/event-stream") 511 + .insert_header(("Cache-Control", "no-cache")) 512 + .insert_header(("X-Accel-Buffering", "no")) 513 + .streaming(Box::pin(stream)) 514 + }
+573 -3
src/templates.rs
··· 170 170 .hidden { display: none; } 171 171 .loading { color: rgba(255, 255, 255, 0.5); font-size: 0.9rem; } 172 172 173 + .demo-btn { 174 + font-family: inherit; 175 + font-size: 0.9rem; 176 + padding: 0.75rem 2rem; 177 + cursor: pointer; 178 + background: transparent; 179 + border: 1px solid rgba(255, 255, 255, 0.15); 180 + border-radius: 4px; 181 + color: rgba(255, 255, 255, 0.6); 182 + transition: all 0.2s; 183 + width: 100%; 184 + margin-top: 0.75rem; 185 + } 186 + 187 + .demo-btn:hover { 188 + background: rgba(10, 10, 15, 0.5); 189 + border-color: rgba(255, 255, 255, 0.3); 190 + color: rgba(255, 255, 255, 0.8); 191 + } 192 + 193 + .divider { 194 + display: flex; 195 + align-items: center; 196 + gap: 1rem; 197 + margin: 1.5rem 0 1rem; 198 + color: rgba(255, 255, 255, 0.3); 199 + font-size: 0.7rem; 200 + } 201 + 202 + .divider::before, 203 + .divider::after { 204 + content: ''; 205 + flex: 1; 206 + height: 1px; 207 + background: rgba(255, 255, 255, 0.1); 208 + } 209 + 210 + .info-toggle { 211 + margin-top: 1.5rem; 212 + color: rgba(255, 255, 255, 0.5); 213 + font-size: 0.75rem; 214 + cursor: pointer; 215 + border: none; 216 + background: none; 217 + padding: 0.5rem; 218 + transition: color 0.2s; 219 + text-decoration: underline; 220 + text-underline-offset: 2px; 221 + } 222 + 223 + .info-toggle:hover { 224 + color: rgba(255, 255, 255, 0.8); 225 + } 226 + 227 + .info-content { 228 + max-height: 0; 229 + overflow: hidden; 230 + transition: max-height 0.3s ease; 231 + margin-top: 1rem; 232 + } 233 + 234 + .info-content.expanded { 235 + max-height: 500px; 236 + } 237 + 238 + .info-section { 239 + background: rgba(10, 10, 15, 0.6); 240 + border: 1px solid rgba(255, 255, 255, 0.1); 241 + border-radius: 4px; 242 + padding: 1.5rem; 243 + text-align: left; 244 + } 245 + 246 + .info-section h3 { 247 + font-size: 0.85rem; 248 + font-weight: 500; 249 + margin-bottom: 0.75rem; 250 + color: rgba(255, 255, 255, 0.9); 251 + } 252 + 253 + .info-section p { 254 + font-size: 0.7rem; 255 + line-height: 1.6; 256 + color: rgba(255, 255, 255, 0.6); 257 + margin-bottom: 1rem; 258 + } 259 + 260 + .info-section p:last-child { 261 + margin-bottom: 0; 262 + } 263 + 264 + .info-section strong { 265 + color: rgba(255, 255, 255, 0.85); 266 + } 267 + 268 + .info-section a { 269 + color: rgba(255, 255, 255, 0.8); 270 + text-decoration: underline; 271 + text-underline-offset: 2px; 272 + } 273 + 274 + .info-section a:hover { 275 + color: rgba(255, 255, 255, 1); 276 + } 277 + 173 278 .footer { 174 279 position: fixed; 175 280 bottom: 1rem; ··· 202 307 <div class="subtitle">explore the atmosphere</div> 203 308 <input type="text" name="handle" placeholder="handle.bsky.social" required autofocus> 204 309 <button type="submit">enter</button> 310 + 311 + <div class="divider">or</div> 312 + <button type="button" class="demo-btn" id="demoBtn">explore demo</button> 313 + 314 + <button type="button" class="info-toggle" id="infoToggle">what is this?</button> 315 + 316 + <div class="info-content" id="infoContent"> 317 + <div class="info-section"> 318 + <h3>visualize your atproto identity</h3> 319 + <p>see all the apps writing to your <strong>Personal Data Server</strong> and explore the records they've created. your content, your server, your control.</p> 320 + 321 + <h3>the problem with silos</h3> 322 + <p>traditional social platforms lock your content in. switching means starting over, losing your network and history. you build their platform, they control everything.</p> 323 + 324 + <h3>the atproto solution</h3> 325 + <p>on <a href="https://atproto.com" target="_blank" rel="noopener noreferrer">atproto</a>, you own your data. it lives on <strong>your</strong> server. apps like bluesky, whitewind, and frontpage just read and write to your space. switch apps anytime, take everything with you.</p> 326 + 327 + <h3>see it yourself</h3> 328 + <p>this isn't just theory. click "explore demo" to see a real atproto account, or log in to visualize your own identity.</p> 329 + </div> 330 + </div> 205 331 </form> 206 332 </div> 207 333 </div> ··· 216 342 "# 217 343 } 218 344 219 - pub fn app_page(did: &str) -> String { 345 + pub fn app_page(did: &str, demo_mode: bool, demo_handle: Option<&str>) -> String { 346 + let demo_banner = if demo_mode && demo_handle.is_some() { 347 + format!(r#" 348 + <div class="demo-banner" id="demoBanner"> 349 + <span>demo mode - viewing <strong>{}</strong></span> 350 + <a href="/demo/exit" class="demo-exit">exit demo</a> 351 + </div>"#, demo_handle.unwrap()) 352 + } else { 353 + String::new() 354 + }; 355 + 220 356 format!(r#" 221 357 <!DOCTYPE html> 222 358 <html> ··· 299 435 -webkit-tap-highlight-color: transparent; 300 436 cursor: pointer; 301 437 border-radius: 2px; 438 + display: flex; 439 + align-items: center; 302 440 }} 303 441 304 442 .logout:hover, .logout:active {{ ··· 469 607 letter-spacing: 0.05em; 470 608 }} 471 609 610 + .identity-pds-label {{ 611 + position: absolute; 612 + bottom: clamp(-1.5rem, -3vmin, -2rem); 613 + font-size: clamp(0.55rem, 1.1vmin, 0.65rem); 614 + color: var(--text-light); 615 + letter-spacing: 0.05em; 616 + font-weight: 500; 617 + }} 618 + 472 619 .identity-avatar {{ 473 620 width: clamp(30px, 6vmin, 45px); 474 621 height: clamp(30px, 6vmin, 45px); ··· 525 672 color: var(--text); 526 673 text-align: center; 527 674 max-width: clamp(80px, 15vmin, 120px); 675 + text-decoration: none; 676 + display: block; 677 + }} 678 + 679 + .app-name:hover {{ 680 + text-decoration: underline; 681 + color: var(--text); 682 + }} 683 + 684 + .app-name.invalid-link {{ 685 + color: var(--text-light); 686 + opacity: 0.5; 687 + cursor: not-allowed; 688 + }} 689 + 690 + .app-name.invalid-link:hover {{ 691 + text-decoration: none; 692 + color: var(--text-light); 528 693 }} 529 694 530 695 .detail-panel {{ ··· 532 697 top: 0; 533 698 left: 0; 534 699 bottom: 0; 535 - width: 320px; 700 + width: 500px; 536 701 background: var(--surface); 537 702 border-right: 2px solid var(--border); 538 703 padding: 2.5rem 2rem; ··· 541 706 transform: translateX(-100%); 542 707 transition: all 0.25s ease; 543 708 z-index: 1000; 709 + scrollbar-width: none; 710 + -ms-overflow-style: none; 711 + }} 712 + 713 + .detail-panel::-webkit-scrollbar {{ 714 + display: none; 544 715 }} 545 716 546 717 .detail-panel.visible {{ ··· 646 817 color: var(--text-light); 647 818 }} 648 819 820 + .collection-content {{ 821 + margin-top: 0.5rem; 822 + padding-top: 0.5rem; 823 + border-top: 1px solid var(--border); 824 + }} 825 + 826 + .collection-tabs {{ 827 + display: flex; 828 + gap: 0; 829 + margin-bottom: 0.75rem; 830 + border: 1px solid var(--border); 831 + border-radius: 2px; 832 + overflow: hidden; 833 + }} 834 + 835 + .collection-tab {{ 836 + flex: 1; 837 + padding: 0.5rem 0.75rem; 838 + background: var(--bg); 839 + border: none; 840 + border-right: 1px solid var(--border); 841 + color: var(--text-light); 842 + font-family: inherit; 843 + font-size: 0.65rem; 844 + cursor: pointer; 845 + transition: all 0.15s ease; 846 + -webkit-tap-highlight-color: transparent; 847 + }} 848 + 849 + .collection-tab:last-child {{ 850 + border-right: none; 851 + }} 852 + 853 + .collection-tab:hover {{ 854 + background: var(--surface); 855 + color: var(--text); 856 + }} 857 + 858 + .collection-tab.active {{ 859 + background: var(--surface-hover); 860 + color: var(--text); 861 + font-weight: 500; 862 + }} 863 + 864 + .collection-view-content {{ 865 + position: relative; 866 + }} 867 + 868 + .collection-view {{ 869 + display: none; 870 + }} 871 + 872 + .collection-view.active {{ 873 + display: block; 874 + }} 875 + 876 + .structure-view {{ 877 + min-height: 600px; 878 + }} 879 + 880 + .mst-canvas {{ 881 + width: 100%; 882 + height: 600px; 883 + border: 1px solid var(--border); 884 + border-radius: 4px; 885 + background: var(--bg); 886 + margin-top: 0.5rem; 887 + }} 888 + 889 + .mst-info {{ 890 + background: var(--bg); 891 + border: 1px solid var(--border); 892 + padding: 0.75rem; 893 + border-radius: 4px; 894 + margin-bottom: 0.75rem; 895 + }} 896 + 897 + .mst-info p {{ 898 + font-size: 0.65rem; 899 + color: var(--text-lighter); 900 + line-height: 1.5; 901 + margin: 0; 902 + }} 903 + 904 + .mst-node-modal {{ 905 + position: fixed; 906 + inset: 0; 907 + background: rgba(0, 0, 0, 0.75); 908 + display: flex; 909 + align-items: center; 910 + justify-content: center; 911 + z-index: 3000; 912 + padding: 1rem; 913 + }} 914 + 915 + .mst-node-modal-content {{ 916 + background: var(--surface); 917 + border: 2px solid var(--border); 918 + padding: 2rem; 919 + border-radius: 4px; 920 + max-width: 600px; 921 + width: 100%; 922 + max-height: 80vh; 923 + overflow-y: auto; 924 + position: relative; 925 + }} 926 + 927 + .mst-node-close {{ 928 + position: absolute; 929 + top: 1rem; 930 + right: 1rem; 931 + width: 32px; 932 + height: 32px; 933 + border: 1px solid var(--border); 934 + background: var(--bg); 935 + color: var(--text-light); 936 + cursor: pointer; 937 + display: flex; 938 + align-items: center; 939 + justify-content: center; 940 + font-size: 1.2rem; 941 + line-height: 1; 942 + transition: all 0.2s ease; 943 + border-radius: 2px; 944 + }} 945 + 946 + .mst-node-close:hover {{ 947 + background: var(--surface-hover); 948 + border-color: var(--text-light); 949 + color: var(--text); 950 + }} 951 + 952 + .mst-node-modal-content h3 {{ 953 + margin-bottom: 1rem; 954 + font-size: 0.9rem; 955 + color: var(--text); 956 + }} 957 + 958 + .mst-node-info {{ 959 + background: var(--bg); 960 + border: 1px solid var(--border); 961 + padding: 0.75rem; 962 + border-radius: 4px; 963 + margin-bottom: 1rem; 964 + }} 965 + 966 + .mst-node-field {{ 967 + display: flex; 968 + gap: 0.5rem; 969 + margin-bottom: 0.5rem; 970 + font-size: 0.65rem; 971 + }} 972 + 973 + .mst-node-field:last-child {{ 974 + margin-bottom: 0; 975 + }} 976 + 977 + .mst-node-label {{ 978 + color: var(--text-light); 979 + font-weight: 500; 980 + min-width: 40px; 981 + }} 982 + 983 + .mst-node-value {{ 984 + color: var(--text); 985 + word-break: break-all; 986 + font-family: monospace; 987 + }} 988 + 989 + .mst-node-explanation {{ 990 + background: var(--bg); 991 + border: 1px solid var(--border); 992 + padding: 0.75rem; 993 + border-radius: 4px; 994 + margin-bottom: 1rem; 995 + }} 996 + 997 + .mst-node-explanation p {{ 998 + font-size: 0.65rem; 999 + color: var(--text-lighter); 1000 + line-height: 1.5; 1001 + margin: 0; 1002 + }} 1003 + 1004 + .mst-node-data {{ 1005 + background: var(--bg); 1006 + border: 1px solid var(--border); 1007 + border-radius: 4px; 1008 + overflow: hidden; 1009 + }} 1010 + 1011 + .mst-node-data-header {{ 1012 + font-size: 0.65rem; 1013 + color: var(--text-light); 1014 + padding: 0.5rem 0.75rem; 1015 + border-bottom: 1px solid var(--border); 1016 + font-weight: 500; 1017 + }} 1018 + 1019 + .mst-node-data pre {{ 1020 + margin: 0; 1021 + padding: 0.75rem; 1022 + font-size: 0.625rem; 1023 + color: var(--text); 1024 + white-space: pre-wrap; 1025 + word-break: break-word; 1026 + line-height: 1.5; 1027 + }} 1028 + 649 1029 .record-list {{ 650 1030 margin-top: 0.5rem; 651 1031 padding-top: 0.5rem; ··· 946 1326 .ownership-text strong {{ 947 1327 color: var(--text); 948 1328 }} 1329 + 1330 + .watch-live-btn {{ 1331 + position: fixed; 1332 + top: clamp(1rem, 2vmin, 1.5rem); 1333 + right: clamp(6rem, 14vmin, 9rem); 1334 + font-family: inherit; 1335 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1336 + color: var(--text-light); 1337 + border: 1px solid var(--border); 1338 + background: var(--bg); 1339 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1340 + transition: all 0.2s ease; 1341 + z-index: 100; 1342 + cursor: pointer; 1343 + border-radius: 2px; 1344 + display: flex; 1345 + align-items: center; 1346 + gap: 0.5rem; 1347 + }} 1348 + 1349 + .watch-live-btn:hover {{ 1350 + background: var(--surface); 1351 + color: var(--text); 1352 + border-color: var(--text-light); 1353 + }} 1354 + 1355 + .watch-live-btn.active {{ 1356 + background: var(--surface-hover); 1357 + color: var(--text); 1358 + border-color: var(--text); 1359 + }} 1360 + 1361 + .watch-indicator {{ 1362 + width: 8px; 1363 + height: 8px; 1364 + border-radius: 50%; 1365 + background: var(--text-light); 1366 + display: none; 1367 + }} 1368 + 1369 + .watch-live-btn.active .watch-indicator {{ 1370 + display: block; 1371 + animation: pulse 2s ease-in-out infinite; 1372 + }} 1373 + 1374 + @keyframes pulse {{ 1375 + 0%, 100% {{ opacity: 1; }} 1376 + 50% {{ opacity: 0.3; }} 1377 + }} 1378 + 1379 + .firehose-toast {{ 1380 + position: fixed; 1381 + top: clamp(4rem, 8vmin, 5rem); 1382 + right: clamp(1rem, 2vmin, 1.5rem); 1383 + background: var(--surface); 1384 + border: 1px solid var(--border); 1385 + padding: 0.75rem 1rem; 1386 + border-radius: 4px; 1387 + font-size: 0.7rem; 1388 + color: var(--text); 1389 + z-index: 200; 1390 + opacity: 0; 1391 + transform: translateY(-10px); 1392 + transition: all 0.3s ease; 1393 + pointer-events: none; 1394 + max-width: 300px; 1395 + }} 1396 + 1397 + .firehose-toast.visible {{ 1398 + opacity: 1; 1399 + transform: translateY(0); 1400 + pointer-events: auto; 1401 + }} 1402 + 1403 + .firehose-toast-action {{ 1404 + font-weight: 600; 1405 + color: var(--text); 1406 + }} 1407 + 1408 + .firehose-toast-collection {{ 1409 + color: var(--text-light); 1410 + font-size: 0.65rem; 1411 + margin-top: 0.25rem; 1412 + }} 1413 + 1414 + .firehose-toast-link {{ 1415 + display: inline-block; 1416 + color: var(--text-light); 1417 + font-size: 0.6rem; 1418 + margin-top: 0.5rem; 1419 + text-decoration: none; 1420 + border-bottom: 1px solid transparent; 1421 + transition: all 0.2s ease; 1422 + pointer-events: auto; 1423 + }} 1424 + 1425 + .firehose-toast-link:hover {{ 1426 + color: var(--text); 1427 + border-bottom-color: var(--text); 1428 + }} 1429 + 1430 + @media (max-width: 768px) {{ 1431 + .watch-live-btn {{ 1432 + right: clamp(1rem, 2vmin, 1.5rem); 1433 + top: clamp(4rem, 8vmin, 5rem); 1434 + }} 1435 + 1436 + .firehose-toast {{ 1437 + top: clamp(7rem, 12vmin, 8rem); 1438 + right: clamp(1rem, 2vmin, 1.5rem); 1439 + left: clamp(1rem, 2vmin, 1.5rem); 1440 + max-width: none; 1441 + }} 1442 + }} 1443 + 1444 + .demo-banner {{ 1445 + position: fixed; 1446 + top: 0; 1447 + left: 0; 1448 + right: 0; 1449 + background: rgba(255, 165, 0, 0.15); 1450 + border-bottom: 1px solid rgba(255, 165, 0, 0.3); 1451 + padding: 0.5rem 1rem; 1452 + display: flex; 1453 + align-items: center; 1454 + justify-content: center; 1455 + gap: 1rem; 1456 + z-index: 200; 1457 + font-size: 0.7rem; 1458 + color: var(--text); 1459 + }} 1460 + 1461 + .demo-banner strong {{ 1462 + color: var(--text); 1463 + font-weight: 600; 1464 + }} 1465 + 1466 + .demo-exit {{ 1467 + color: var(--text-light); 1468 + text-decoration: none; 1469 + border: 1px solid var(--border); 1470 + padding: 0.25rem 0.75rem; 1471 + border-radius: 2px; 1472 + transition: all 0.2s ease; 1473 + font-size: 0.65rem; 1474 + }} 1475 + 1476 + .demo-exit:hover {{ 1477 + background: var(--surface); 1478 + border-color: var(--text-light); 1479 + color: var(--text); 1480 + }} 1481 + 1482 + @media (prefers-color-scheme: dark) {{ 1483 + .demo-banner {{ 1484 + background: rgba(255, 165, 0, 0.1); 1485 + border-bottom-color: rgba(255, 165, 0, 0.25); 1486 + }} 1487 + }} 1488 + 1489 + /* Adjust elements when demo banner is present */ 1490 + .demo-banner ~ .info {{ 1491 + top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem); 1492 + }} 1493 + 1494 + .demo-banner ~ .watch-live-btn {{ 1495 + top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem); 1496 + }} 1497 + 1498 + .demo-banner ~ .logout {{ 1499 + top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem); 1500 + }} 1501 + 1502 + @media (max-width: 768px) {{ 1503 + .demo-banner ~ .watch-live-btn {{ 1504 + top: calc(clamp(4rem, 8vmin, 5rem) + 2.5rem); 1505 + }} 1506 + }} 949 1507 </style> 950 1508 </head> 951 1509 <body> 1510 + {} 952 1511 <div class="info" id="infoBtn">?</div> 1512 + <button class="watch-live-btn" id="watchLiveBtn"> 1513 + <span class="watch-indicator"></span> 1514 + <span class="watch-label">watch live</span> 1515 + </button> 953 1516 <a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a> 954 1517 1518 + <div class="firehose-toast" id="firehoseToast"> 1519 + <div class="firehose-toast-action"></div> 1520 + <div class="firehose-toast-collection"></div> 1521 + <a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view record</a> 1522 + </div> 1523 + 955 1524 <div class="overlay" id="overlay"></div> 956 1525 <div class="info-modal" id="infoModal"> 957 1526 <h2>@me - your repository</h2> ··· 972 1541 <div class="identity-label">@</div> 973 1542 <div class="identity-value" id="handle">loading...</div> 974 1543 <div class="identity-hint">tap for details</div> 1544 + <div class="identity-pds-label">Your PDS</div> 975 1545 </div> 976 1546 <div id="field" class="loading">loading...</div> 977 1547 </div> ··· 987 1557 <script src="/static/onboarding.js"></script> 988 1558 </body> 989 1559 </html> 990 - "#, did) 1560 + "#, demo_banner, did) 991 1561 }
+764 -89
static/app.js
··· 5 5 let globalPds = null; 6 6 let globalHandle = null; 7 7 8 - // Try to fetch app avatar from their bsky profile 8 + // Fetch app avatar from server 9 9 async function fetchAppAvatar(namespace) { 10 10 try { 11 - // Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io) 12 - const reversed = namespace.split('.').reverse().join('.'); 13 - // Try reversed domain, then reversed.bsky.social 14 - const handles = [reversed, `${reversed}.bsky.social`]; 15 - 16 - for (const handle of handles) { 17 - try { 18 - const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 19 - if (!didRes.ok) continue; 20 - 21 - const { did } = await didRes.json(); 22 - const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 23 - if (!profileRes.ok) continue; 24 - 25 - const profile = await profileRes.json(); 26 - if (profile.avatar) { 27 - return profile.avatar; 28 - } 29 - } catch (e) { 30 - // Silently continue to next handle 31 - continue; 32 - } 33 - } 11 + const response = await fetch(`/api/avatar?namespace=${encodeURIComponent(namespace)}`); 12 + const data = await response.json(); 13 + return data.avatarUrl; 34 14 } catch (e) { 35 - // Expected for namespaces without Bluesky accounts 15 + return null; 36 16 } 37 - return null; 38 17 } 39 18 40 19 // Logout handler ··· 62 41 detail.classList.remove('visible'); 63 42 }); 64 43 65 - // First resolve DID to get PDS endpoint and handle 66 - fetch('https://plc.directory/' + did) 44 + // Fetch initialization data from server 45 + fetch(`/api/init?did=${encodeURIComponent(did)}`) 67 46 .then(r => r.json()) 68 - .then(didDoc => { 69 - const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint; 70 - const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did; 71 - 72 - globalPds = pds; 73 - globalHandle = handle; 47 + .then(initData => { 48 + globalPds = initData.pds; 49 + globalHandle = initData.handle; 74 50 75 51 // Update identity display with handle 76 - document.getElementById('handle').textContent = handle; 52 + document.getElementById('handle').textContent = initData.handle; 77 53 78 - // Try to fetch and display user's avatar 79 - fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`) 80 - .then(r => r.json()) 81 - .then(profile => { 82 - if (profile.avatar) { 83 - const identity = document.querySelector('.identity'); 84 - const avatarImg = document.createElement('img'); 85 - avatarImg.src = profile.avatar; 86 - avatarImg.className = 'identity-avatar'; 87 - avatarImg.alt = handle; 88 - // Insert avatar before the @ label 89 - identity.insertBefore(avatarImg, identity.firstChild); 90 - } 91 - }) 92 - .catch(() => { 93 - // User may not have an avatar set 94 - }); 95 - 96 - // Store collections and apps for later use 97 - let allCollections = []; 98 - let apps = {}; 99 - 100 - // Get all collections from PDS 101 - return fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 102 - }) 103 - .then(r => r.json()) 104 - .then(repo => { 105 - const collections = repo.collections || []; 106 - allCollections = collections; 54 + // Display user's avatar if available 55 + if (initData.avatar) { 56 + const identity = document.querySelector('.identity'); 57 + const avatarImg = document.createElement('img'); 58 + avatarImg.src = initData.avatar; 59 + avatarImg.className = 'identity-avatar'; 60 + avatarImg.alt = initData.handle; 61 + // Insert avatar before the @ label 62 + identity.insertBefore(avatarImg, identity.firstChild); 63 + } 107 64 108 - // Group by app namespace (first two parts of lexicon) 109 - apps = {}; 110 - collections.forEach(collection => { 111 - const parts = collection.split('.'); 112 - if (parts.length >= 2) { 113 - const namespace = `${parts[0]}.${parts[1]}`; 114 - if (!apps[namespace]) apps[namespace] = []; 115 - apps[namespace].push(collection); 116 - } 65 + // Convert apps array to object for easier access 66 + const apps = {}; 67 + const allCollections = []; 68 + initData.apps.forEach(app => { 69 + apps[app.namespace] = app.collections; 70 + allCollections.push(...app.collections); 117 71 }); 118 72 119 73 // Add identity click handler now that we have the data ··· 196 150 197 151 const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 198 152 153 + // Reverse namespace for display (app.bsky -> bsky.app) 154 + const displayName = namespace.split('.').reverse().join('.'); 155 + const url = `https://${displayName}`; 156 + 199 157 div.innerHTML = ` 200 158 <div class="app-circle" data-namespace="${namespace}">${firstLetter}</div> 201 - <div class="app-name">${namespace}</div> 159 + <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName}</a> 202 160 `; 203 161 204 162 // Try to fetch and display avatar ··· 209 167 } 210 168 }); 211 169 170 + // Validate URL 171 + fetch(`/api/validate-url?url=${encodeURIComponent(url)}`) 172 + .then(r => r.json()) 173 + .then(data => { 174 + const link = div.querySelector('.app-name'); 175 + if (!data.valid) { 176 + link.classList.add('invalid-link'); 177 + link.setAttribute('title', 'this domain is not reachable'); 178 + link.style.pointerEvents = 'none'; 179 + } 180 + }) 181 + .catch(() => { 182 + // Silently fail validation check 183 + }); 184 + 212 185 div.addEventListener('click', () => { 213 186 const detail = document.getElementById('detail'); 214 187 const collections = apps[namespace]; ··· 216 189 let html = ` 217 190 <button class="detail-close" id="detailClose">ร—</button> 218 191 <h3>${namespace}</h3> 219 - <div class="subtitle">records stored in your pds:</div> 192 + <div class="subtitle">records stored in your <a href="https://atproto.com/guides/self-hosting" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">PDS</a>:</div> 220 193 `; 221 194 222 195 if (collections && collections.length > 0) { ··· 307 280 item.addEventListener('click', (e) => { 308 281 e.stopPropagation(); 309 282 const lexicon = item.dataset.lexicon; 310 - const existingRecords = item.querySelector('.record-list'); 283 + const existingContent = item.querySelector('.collection-content'); 311 284 312 - if (existingRecords) { 313 - existingRecords.remove(); 285 + if (existingContent) { 286 + existingContent.remove(); 314 287 return; 315 288 } 316 289 317 - const recordListDiv = document.createElement('div'); 318 - recordListDiv.className = 'record-list'; 319 - recordListDiv.innerHTML = '<div class="loading">loading records...</div>'; 320 - item.appendChild(recordListDiv); 290 + // Create container for tabs and content 291 + const contentDiv = document.createElement('div'); 292 + contentDiv.className = 'collection-content'; 321 293 322 - fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5`) 294 + // Will add tabs after we know record count 295 + contentDiv.innerHTML = ` 296 + <div class="collection-view-content"> 297 + <div class="collection-view records-view active"> 298 + <div class="loading">loading records...</div> 299 + </div> 300 + <div class="collection-view structure-view"> 301 + <div class="loading">loading structure...</div> 302 + </div> 303 + </div> 304 + `; 305 + item.appendChild(contentDiv); 306 + 307 + const recordsView = contentDiv.querySelector('.records-view'); 308 + const structureView = contentDiv.querySelector('.structure-view'); 309 + 310 + // Load records first to determine if we should show structure tab 311 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`) 323 312 .then(r => r.json()) 324 313 .then(data => { 314 + // Add tabs if there are enough records for structure view 315 + const hasEnoughRecords = data.records && data.records.length >= 5; 316 + if (hasEnoughRecords) { 317 + const tabsHtml = ` 318 + <div class="collection-tabs"> 319 + <button class="collection-tab active" data-tab="records">records</button> 320 + <button class="collection-tab" data-tab="structure">mst</button> 321 + </div> 322 + `; 323 + contentDiv.insertAdjacentHTML('afterbegin', tabsHtml); 324 + 325 + // Tab switching logic 326 + contentDiv.querySelectorAll('.collection-tab').forEach(tab => { 327 + tab.addEventListener('click', (e) => { 328 + e.stopPropagation(); 329 + const tabName = tab.dataset.tab; 330 + 331 + // Update active tab 332 + contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active')); 333 + tab.classList.add('active'); 334 + 335 + // Update active view 336 + contentDiv.querySelectorAll('.collection-view').forEach(v => v.classList.remove('active')); 337 + if (tabName === 'records') { 338 + recordsView.classList.add('active'); 339 + } else if (tabName === 'structure') { 340 + structureView.classList.add('active'); 341 + // Load structure if not already loaded 342 + if (structureView.querySelector('.loading')) { 343 + loadMSTStructure(lexicon, structureView); 344 + } 345 + } 346 + }); 347 + }); 348 + } 349 + 325 350 if (data.records && data.records.length > 0) { 326 351 let recordsHtml = ''; 327 352 data.records.forEach((record, idx) => { ··· 344 369 recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 345 370 } 346 371 347 - recordListDiv.innerHTML = recordsHtml; 372 + recordsView.innerHTML = recordsHtml; 348 373 349 374 // Use event delegation for copy and load more buttons 350 - recordListDiv.addEventListener('click', (e) => { 375 + recordsView.addEventListener('click', (e) => { 351 376 // Handle copy button 352 377 if (e.target.classList.contains('copy-btn')) { 353 378 e.stopPropagation(); ··· 401 426 }); 402 427 403 428 loadMoreBtn.remove(); 404 - recordListDiv.insertAdjacentHTML('beforeend', moreHtml); 429 + recordsView.insertAdjacentHTML('beforeend', moreHtml); 405 430 406 431 if (moreData.cursor && moreData.records.length === 5) { 407 - recordListDiv.insertAdjacentHTML('beforeend', 432 + recordsView.insertAdjacentHTML('beforeend', 408 433 `<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>` 409 434 ); 410 435 } ··· 412 437 } 413 438 }); 414 439 } else { 415 - recordListDiv.innerHTML = '<div class="record">no records found</div>'; 440 + recordsView.innerHTML = '<div class="record">no records found</div>'; 416 441 } 417 442 }) 418 443 .catch(e => { 419 444 console.error('Error fetching records:', e); 420 - recordListDiv.innerHTML = '<div class="record">error loading records</div>'; 445 + recordsView.innerHTML = '<div class="record">error loading records</div>'; 421 446 }); 422 447 }); 423 448 }); ··· 438 463 document.getElementById('field').innerHTML = 'error loading records'; 439 464 console.error(e); 440 465 }); 466 + 467 + // MST Visualization Functions 468 + async function loadMSTStructure(lexicon, containerView) { 469 + try { 470 + // Call server endpoint to build MST 471 + const response = await fetch(`/api/mst?pds=${encodeURIComponent(globalPds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(lexicon)}`); 472 + const data = await response.json(); 473 + 474 + if (data.error) { 475 + containerView.innerHTML = `<div class="mst-info"><p>${data.error}</p></div>`; 476 + return; 477 + } 478 + 479 + const { root, recordCount } = data; 480 + 481 + // Render structure 482 + containerView.innerHTML = ` 483 + <div class="mst-info"> 484 + <p>this shows the <a href="https://atproto.com/specs/repository#mst-structure" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Merkle Search Tree (MST)</a> structure used to store your ${recordCount} record${recordCount !== 1 ? 's' : ''} in your repository. records are organized by their <a href="https://atproto.com/specs/record-key#record-key-type-tid" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">TIDs</a> (timestamp identifiers), which determines how they're arranged in the tree.</p> 485 + </div> 486 + <canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas> 487 + `; 488 + 489 + // Render tree on canvas 490 + setTimeout(() => { 491 + const canvas = containerView.querySelector('.mst-canvas'); 492 + if (canvas) { 493 + renderMSTTree(canvas, root); 494 + } 495 + }, 50); 496 + 497 + } catch (e) { 498 + console.error('Error loading MST structure:', e); 499 + containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>'; 500 + } 501 + } 502 + 503 + function renderMSTTree(canvas, tree) { 504 + const ctx = canvas.getContext('2d'); 505 + const width = canvas.width = canvas.offsetWidth; 506 + const height = canvas.height = canvas.offsetHeight; 507 + 508 + // Calculate tree layout 509 + const layout = layoutTree(tree, width, height); 510 + 511 + // Get CSS colors 512 + const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim(); 513 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 514 + const textLightColor = getComputedStyle(document.documentElement).getPropertyValue('--text-light').trim(); 515 + const surfaceColor = getComputedStyle(document.documentElement).getPropertyValue('--surface').trim(); 516 + const surfaceHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--surface-hover').trim(); 517 + const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim(); 518 + 519 + let hoveredNode = null; 520 + 521 + function draw() { 522 + // Clear canvas 523 + ctx.clearRect(0, 0, width, height); 524 + 525 + // Draw connections first 526 + layout.forEach(node => { 527 + if (node.children) { 528 + node.children.forEach(child => { 529 + ctx.beginPath(); 530 + ctx.moveTo(node.x, node.y); 531 + ctx.lineTo(child.x, child.y); 532 + ctx.strokeStyle = borderColor; 533 + ctx.lineWidth = 1; 534 + ctx.stroke(); 535 + }); 536 + } 537 + }); 538 + 539 + // Draw nodes 540 + layout.forEach(node => { 541 + const isRoot = node.depth === -1; 542 + const isLeaf = !node.children || node.children.length === 0; 543 + const isHovered = hoveredNode === node; 544 + 545 + // Node circle 546 + ctx.beginPath(); 547 + ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2); 548 + 549 + ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor; 550 + ctx.fill(); 551 + 552 + ctx.strokeStyle = isHovered ? textColor : borderColor; 553 + ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1; 554 + ctx.stroke(); 555 + }); 556 + 557 + // Draw label for hovered node 558 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 559 + const padding = 6; 560 + const fontSize = 10; 561 + ctx.font = `${fontSize}px monospace`; 562 + const textWidth = ctx.measureText(hoveredNode.key).width; 563 + 564 + // Position tooltip above node 565 + const tooltipX = hoveredNode.x; 566 + const tooltipY = hoveredNode.y - 20; 567 + const boxWidth = textWidth + padding * 2; 568 + const boxHeight = fontSize + padding * 2; 569 + 570 + // Draw tooltip background 571 + ctx.fillStyle = bgColor; 572 + ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 573 + 574 + // Draw tooltip border 575 + ctx.strokeStyle = borderColor; 576 + ctx.lineWidth = 1; 577 + ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 578 + 579 + // Draw text 580 + ctx.fillStyle = textColor; 581 + ctx.textAlign = 'center'; 582 + ctx.textBaseline = 'middle'; 583 + ctx.fillText(hoveredNode.key, tooltipX, tooltipY); 584 + } 585 + } 586 + 587 + // Mouse move handler 588 + canvas.addEventListener('mousemove', (e) => { 589 + const rect = canvas.getBoundingClientRect(); 590 + const mouseX = e.clientX - rect.left; 591 + const mouseY = e.clientY - rect.top; 592 + 593 + let foundNode = null; 594 + for (const node of layout) { 595 + const isRoot = node.depth === -1; 596 + const radius = isRoot ? 12 : 8; 597 + const dist = Math.sqrt((mouseX - node.x) ** 2 + (mouseY - node.y) ** 2); 598 + if (dist <= radius) { 599 + foundNode = node; 600 + break; 601 + } 602 + } 603 + 604 + if (foundNode !== hoveredNode) { 605 + hoveredNode = foundNode; 606 + canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; 607 + draw(); 608 + } 609 + }); 610 + 611 + // Mouse leave handler 612 + canvas.addEventListener('mouseleave', () => { 613 + if (hoveredNode) { 614 + hoveredNode = null; 615 + canvas.style.cursor = 'default'; 616 + draw(); 617 + } 618 + }); 619 + 620 + // Click handler 621 + canvas.addEventListener('click', (e) => { 622 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 623 + showNodeModal(hoveredNode); 624 + } 625 + }); 626 + 627 + // Initial draw 628 + draw(); 629 + } 630 + 631 + function showNodeModal(node) { 632 + // Create modal 633 + const modal = document.createElement('div'); 634 + modal.className = 'mst-node-modal'; 635 + modal.innerHTML = ` 636 + <div class="mst-node-modal-content"> 637 + <button class="mst-node-close">ร—</button> 638 + <h3>record in MST</h3> 639 + <div class="mst-node-info"> 640 + <div class="mst-node-field"> 641 + <span class="mst-node-label">TID:</span> 642 + <span class="mst-node-value">${node.key}</span> 643 + </div> 644 + <div class="mst-node-field"> 645 + <span class="mst-node-label">CID:</span> 646 + <span class="mst-node-value">${node.cid}</span> 647 + </div> 648 + ${node.uri ? ` 649 + <div class="mst-node-field"> 650 + <span class="mst-node-label">URI:</span> 651 + <span class="mst-node-value">${node.uri}</span> 652 + </div> 653 + ` : ''} 654 + </div> 655 + <div class="mst-node-explanation"> 656 + <p>this is a leaf node in your Merkle Search Tree. the TID (timestamp identifier) determines its position in the tree. records are sorted by TID, making range queries efficient.</p> 657 + </div> 658 + ${node.value ? ` 659 + <div class="mst-node-data"> 660 + <div class="mst-node-data-header">record data</div> 661 + <pre>${JSON.stringify(node.value, null, 2)}</pre> 662 + </div> 663 + ` : ''} 664 + </div> 665 + `; 666 + 667 + // Add to DOM 668 + document.body.appendChild(modal); 669 + 670 + // Close handlers 671 + modal.querySelector('.mst-node-close').addEventListener('click', () => { 672 + modal.remove(); 673 + }); 674 + 675 + modal.addEventListener('click', (e) => { 676 + if (e.target === modal) { 677 + modal.remove(); 678 + } 679 + }); 680 + } 681 + 682 + function layoutTree(tree, width, height) { 683 + const nodes = []; 684 + const padding = 40; 685 + const availableWidth = width - padding * 2; 686 + const availableHeight = height - padding * 2; 687 + 688 + // Calculate max depth and total nodes at each depth 689 + const depthCounts = {}; 690 + function countDepths(node, depth) { 691 + if (!depthCounts[depth]) depthCounts[depth] = 0; 692 + depthCounts[depth]++; 693 + if (node.children) { 694 + node.children.forEach(child => countDepths(child, depth + 1)); 695 + } 696 + } 697 + countDepths(tree, 0); 698 + 699 + const maxDepth = Math.max(...Object.keys(depthCounts).map(Number)); 700 + const verticalSpacing = availableHeight / (maxDepth + 1); 701 + 702 + // Track positions at each depth to avoid overlap 703 + const positionsByDepth = {}; 704 + 705 + function traverse(node, depth, minX, maxX) { 706 + if (!positionsByDepth[depth]) positionsByDepth[depth] = []; 707 + 708 + // Calculate position based on available space 709 + const x = (minX + maxX) / 2; 710 + const y = padding + verticalSpacing * depth; 711 + 712 + const layoutNode = { ...node, x, y }; 713 + nodes.push(layoutNode); 714 + positionsByDepth[depth].push(x); 715 + 716 + if (node.children && node.children.length > 0) { 717 + layoutNode.children = []; 718 + const childWidth = (maxX - minX) / node.children.length; 719 + 720 + node.children.forEach((child, idx) => { 721 + const childMinX = minX + childWidth * idx; 722 + const childMaxX = minX + childWidth * (idx + 1); 723 + const childLayout = traverse(child, depth + 1, childMinX, childMaxX); 724 + layoutNode.children.push(childLayout); 725 + }); 726 + } 727 + 728 + return layoutNode; 729 + } 730 + 731 + traverse(tree, 0, padding, width - padding); 732 + return nodes; 733 + } 734 + 735 + // ============================================================================ 736 + // FIREHOSE VISUALIZATION 737 + // ============================================================================ 738 + 739 + // Particle class for animating firehose events 740 + class FirehoseParticle { 741 + constructor(startX, startY, endX, endY, color, metadata) { 742 + this.x = startX; 743 + this.y = startY; 744 + this.startX = startX; 745 + this.startY = startY; 746 + this.endX = endX; 747 + this.endY = endY; 748 + this.color = color; 749 + this.metadata = metadata; // {action, collection, namespace} 750 + this.progress = 0; 751 + this.speed = 0.012; // Slower for visibility 752 + this.size = 5; 753 + this.glowSize = 10; 754 + } 755 + 756 + update() { 757 + if (this.progress < 1) { 758 + this.progress += this.speed; 759 + // Cubic ease-in-out 760 + const eased = this.progress < 0.5 761 + ? 4 * this.progress * this.progress * this.progress 762 + : 1 - Math.pow(-2 * this.progress + 2, 3) / 2; 763 + 764 + this.x = this.startX + (this.endX - this.startX) * eased; 765 + this.y = this.startY + (this.endY - this.startY) * eased; 766 + } 767 + return this.progress < 1; 768 + } 769 + 770 + draw(ctx) { 771 + // Outer glow 772 + ctx.beginPath(); 773 + ctx.arc(this.x, this.y, this.glowSize, 0, Math.PI * 2); 774 + const gradient = ctx.createRadialGradient( 775 + this.x, this.y, 0, 776 + this.x, this.y, this.glowSize 777 + ); 778 + gradient.addColorStop(0, this.color + '80'); 779 + gradient.addColorStop(1, this.color + '00'); 780 + ctx.fillStyle = gradient; 781 + ctx.fill(); 782 + 783 + // Inner particle 784 + ctx.beginPath(); 785 + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); 786 + ctx.fillStyle = this.color; 787 + ctx.fill(); 788 + } 789 + } 790 + 791 + // Firehose state 792 + let firehoseParticles = []; 793 + let firehoseCanvas = null; 794 + let firehoseCtx = null; 795 + let firehoseAnimationId = null; 796 + let firehoseEventSource = null; 797 + let isWatchingLive = false; 798 + 799 + function initFirehoseCanvas() { 800 + // Create canvas overlay 801 + firehoseCanvas = document.createElement('canvas'); 802 + firehoseCanvas.id = 'firehoseCanvas'; 803 + firehoseCanvas.style.position = 'fixed'; 804 + firehoseCanvas.style.top = '0'; 805 + firehoseCanvas.style.left = '0'; 806 + firehoseCanvas.style.width = '100%'; 807 + firehoseCanvas.style.height = '100%'; 808 + firehoseCanvas.style.pointerEvents = 'none'; 809 + firehoseCanvas.style.zIndex = '50'; 810 + firehoseCanvas.width = window.innerWidth; 811 + firehoseCanvas.height = window.innerHeight; 812 + 813 + document.body.appendChild(firehoseCanvas); 814 + firehoseCtx = firehoseCanvas.getContext('2d'); 815 + 816 + // Handle window resize 817 + window.addEventListener('resize', () => { 818 + firehoseCanvas.width = window.innerWidth; 819 + firehoseCanvas.height = window.innerHeight; 820 + }); 821 + } 822 + 823 + function animateFirehoseParticles() { 824 + if (!firehoseCtx) return; 825 + 826 + firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 827 + 828 + // Update and draw all particles 829 + firehoseParticles = firehoseParticles.filter(particle => { 830 + const alive = particle.update(); 831 + if (alive) { 832 + particle.draw(firehoseCtx); 833 + } else { 834 + // Particle reached destination - pulse the identity/PDS 835 + pulseIdentity(); 836 + } 837 + return alive; 838 + }); 839 + 840 + if (isWatchingLive) { 841 + firehoseAnimationId = requestAnimationFrame(animateFirehoseParticles); 842 + } 843 + } 844 + 845 + function pulseIdentity() { 846 + const identity = document.querySelector('.identity'); 847 + if (identity) { 848 + identity.style.transition = 'all 0.3s ease'; 849 + identity.style.transform = 'scale(1.15)'; 850 + identity.style.boxShadow = '0 0 25px rgba(255, 255, 255, 0.6)'; 851 + 852 + setTimeout(() => { 853 + identity.style.transform = ''; 854 + identity.style.boxShadow = ''; 855 + }, 300); 856 + } 857 + } 858 + 859 + async function fetchRecordDetails(pds, did, collection, rkey) { 860 + try { 861 + const response = await fetch( 862 + `/api/record?pds=${encodeURIComponent(pds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}` 863 + ); 864 + const data = await response.json(); 865 + if (data.error) return null; 866 + return data.value; 867 + } catch (e) { 868 + console.error('Error fetching record:', e); 869 + return null; 870 + } 871 + } 872 + 873 + function formatToastMessage(action, collection, record) { 874 + const actionText = { 875 + 'create': 'created', 876 + 'update': 'updated', 877 + 'delete': 'deleted' 878 + }[action] || action; 879 + 880 + // If we don't have record details, fall back to basic message 881 + if (!record) { 882 + return { 883 + action: `${actionText} record`, 884 + details: collection 885 + }; 886 + } 887 + 888 + // Format based on collection type 889 + if (collection === 'app.bsky.feed.post') { 890 + const text = record.text || ''; 891 + const preview = text.length > 50 ? text.substring(0, 50) + '...' : text; 892 + return { 893 + action: `${actionText} post`, 894 + details: preview || 'no text' 895 + }; 896 + } else if (collection === 'app.bsky.feed.like') { 897 + return { 898 + action: `${actionText} like`, 899 + details: '' 900 + }; 901 + } else if (collection === 'app.bsky.feed.repost') { 902 + return { 903 + action: `${actionText} repost`, 904 + details: '' 905 + }; 906 + } else if (collection === 'app.bsky.graph.follow') { 907 + return { 908 + action: `${actionText} follow`, 909 + details: '' 910 + }; 911 + } else if (collection === 'app.bsky.actor.profile') { 912 + const displayName = record.displayName || ''; 913 + return { 914 + action: `${actionText} profile`, 915 + details: displayName || 'updated profile' 916 + }; 917 + } 918 + 919 + // Default for unknown collections 920 + return { 921 + action: `${actionText} record`, 922 + details: collection 923 + }; 924 + } 925 + 926 + async function showFirehoseToast(event) { 927 + const toast = document.getElementById('firehoseToast'); 928 + const actionEl = toast.querySelector('.firehose-toast-action'); 929 + const collectionEl = toast.querySelector('.firehose-toast-collection'); 930 + const linkEl = document.getElementById('firehoseToastLink'); 931 + 932 + // Build PDS link for the record 933 + if (globalPds && event.did && event.collection && event.rkey) { 934 + const recordUrl = `${globalPds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(event.did)}&collection=${encodeURIComponent(event.collection)}&rkey=${encodeURIComponent(event.rkey)}`; 935 + linkEl.href = recordUrl; 936 + } 937 + 938 + // Fetch record details if available (skip for deletes) 939 + let record = null; 940 + if (event.action !== 'delete' && event.rkey && globalPds) { 941 + record = await fetchRecordDetails(globalPds, event.did, event.collection, event.rkey); 942 + } 943 + 944 + const formatted = formatToastMessage(event.action, event.collection, record); 945 + 946 + actionEl.textContent = formatted.action; 947 + collectionEl.textContent = formatted.details; 948 + 949 + toast.classList.add('visible'); 950 + setTimeout(() => { 951 + toast.classList.remove('visible'); 952 + }, 4000); // Slightly longer to read details 953 + } 954 + 955 + function getParticleColor(action) { 956 + const colors = { 957 + 'create': '#4ade80', // green 958 + 'update': '#60a5fa', // blue 959 + 'delete': '#f87171' // red 960 + }; 961 + return colors[action] || '#a0a0a0'; 962 + } 963 + 964 + function createFirehoseParticle(event) { 965 + // Get source app circle position (where the action happened) 966 + const appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`); 967 + if (!appCircle) return; 968 + 969 + const appRect = appCircle.getBoundingClientRect(); 970 + const startX = appRect.left + appRect.width / 2; 971 + const startY = appRect.top + appRect.height / 2; 972 + 973 + // Get target identity/PDS position (where data is written) 974 + const identity = document.querySelector('.identity'); 975 + if (!identity) return; 976 + 977 + const identityRect = identity.getBoundingClientRect(); 978 + const endX = identityRect.left + identityRect.width / 2; 979 + const endY = identityRect.top + identityRect.height / 2; 980 + 981 + // Create particle (flows from app TO PDS) 982 + const particle = new FirehoseParticle( 983 + startX, startY, 984 + endX, endY, 985 + getParticleColor(event.action), 986 + { 987 + action: event.action, 988 + collection: event.collection, 989 + namespace: event.namespace 990 + } 991 + ); 992 + 993 + firehoseParticles.push(particle); 994 + } 995 + 996 + function connectFirehose() { 997 + console.log('[Firehose] connectFirehose called, did =', did, 'existing connection?', !!firehoseEventSource); 998 + if (!did || firehoseEventSource) { 999 + console.warn('[Firehose] Exiting early - did:', did, 'firehoseEventSource:', firehoseEventSource); 1000 + return; 1001 + } 1002 + 1003 + const url = `/api/firehose/watch?did=${encodeURIComponent(did)}`; 1004 + console.log('[Firehose] Connecting to:', url); 1005 + 1006 + firehoseEventSource = new EventSource(url); 1007 + 1008 + const watchBtn = document.getElementById('watchLiveBtn'); 1009 + const watchLabel = watchBtn.querySelector('.watch-label'); 1010 + 1011 + firehoseEventSource.onopen = () => { 1012 + console.log('Firehose connected'); 1013 + watchLabel.textContent = 'watching...'; 1014 + watchBtn.classList.add('active'); 1015 + }; 1016 + 1017 + firehoseEventSource.onmessage = (e) => { 1018 + try { 1019 + const data = JSON.parse(e.data); 1020 + 1021 + // Skip connection message 1022 + if (data.type === 'connected') { 1023 + console.log('Firehose connection established'); 1024 + return; 1025 + } 1026 + 1027 + console.log('Firehose event:', data); 1028 + 1029 + // Create particle animation 1030 + createFirehoseParticle(data); 1031 + 1032 + // Show toast notification 1033 + showFirehoseToast(data); 1034 + } catch (error) { 1035 + console.error('Error processing firehose message:', error); 1036 + } 1037 + }; 1038 + 1039 + firehoseEventSource.onerror = (error) => { 1040 + console.error('Firehose error:', error); 1041 + watchLabel.textContent = 'connection error'; 1042 + 1043 + // Attempt to reconnect after delay 1044 + if (isWatchingLive) { 1045 + setTimeout(() => { 1046 + if (firehoseEventSource) { 1047 + firehoseEventSource.close(); 1048 + firehoseEventSource = null; 1049 + } 1050 + if (isWatchingLive) { 1051 + watchLabel.textContent = 'reconnecting...'; 1052 + connectFirehose(); 1053 + } 1054 + }, 3000); 1055 + } 1056 + }; 1057 + } 1058 + 1059 + function disconnectFirehose() { 1060 + if (firehoseEventSource) { 1061 + firehoseEventSource.close(); 1062 + firehoseEventSource = null; 1063 + } 1064 + 1065 + if (firehoseAnimationId) { 1066 + cancelAnimationFrame(firehoseAnimationId); 1067 + firehoseAnimationId = null; 1068 + } 1069 + 1070 + firehoseParticles = []; 1071 + if (firehoseCtx) { 1072 + firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 1073 + } 1074 + } 1075 + 1076 + // Toggle watch live 1077 + document.addEventListener('DOMContentLoaded', () => { 1078 + console.log('[Firehose] DOMContentLoaded fired, setting up watch button'); 1079 + const watchBtn = document.getElementById('watchLiveBtn'); 1080 + if (!watchBtn) { 1081 + console.error('[Firehose] Watch button not found!'); 1082 + return; 1083 + } 1084 + 1085 + console.log('[Firehose] Watch button found, attaching click handler'); 1086 + const watchLabel = watchBtn.querySelector('.watch-label'); 1087 + 1088 + watchBtn.addEventListener('click', () => { 1089 + console.log('[Firehose] Watch button clicked! isWatchingLive was:', isWatchingLive); 1090 + isWatchingLive = !isWatchingLive; 1091 + console.log('[Firehose] isWatchingLive now:', isWatchingLive); 1092 + 1093 + if (isWatchingLive) { 1094 + // Start watching 1095 + console.log('[Firehose] Starting watch mode'); 1096 + watchLabel.textContent = 'connecting...'; 1097 + initFirehoseCanvas(); 1098 + connectFirehose(); 1099 + animateFirehoseParticles(); 1100 + } else { 1101 + // Stop watching 1102 + console.log('[Firehose] Stopping watch mode'); 1103 + watchLabel.textContent = 'watch live'; 1104 + watchBtn.classList.remove('active'); 1105 + disconnectFirehose(); 1106 + 1107 + // Clean up canvas 1108 + if (firehoseCanvas) { 1109 + firehoseCanvas.remove(); 1110 + firehoseCanvas = null; 1111 + firehoseCtx = null; 1112 + } 1113 + } 1114 + }); 1115 + });
+32
static/login.js
··· 1 + // Check if we're exiting demo mode 2 + const urlParams = new URLSearchParams(window.location.search); 3 + if (urlParams.get('clear_demo') === 'true') { 4 + localStorage.removeItem('atme_did'); 5 + // Clear the query param from the URL 6 + window.history.replaceState({}, document.title, '/'); 7 + } 8 + 1 9 // Check for saved session 2 10 const savedDid = localStorage.getItem('atme_did'); 3 11 if (savedDid) { ··· 155 163 } 156 164 157 165 renderAtmosphere(); 166 + 167 + // Info toggle 168 + document.getElementById('infoToggle').addEventListener('click', () => { 169 + const content = document.getElementById('infoContent'); 170 + const toggle = document.getElementById('infoToggle'); 171 + 172 + if (content.classList.contains('expanded')) { 173 + content.classList.remove('expanded'); 174 + toggle.textContent = 'what is this?'; 175 + } else { 176 + content.classList.add('expanded'); 177 + toggle.textContent = 'close'; 178 + } 179 + }); 180 + 181 + // Demo mode 182 + document.getElementById('demoBtn').addEventListener('click', () => { 183 + // Store demo flag and navigate 184 + sessionStorage.setItem('atme_demo_mode', 'true'); 185 + sessionStorage.setItem('atme_demo_handle', 'pfrazee.com'); 186 + 187 + // Navigate to demo - this will trigger the login flow with the demo handle 188 + window.location.href = '/demo'; 189 + });