[READ-ONLY] Mirror of https://github.com/flo-bit/ui-kit. 🦊 fox ui, svelte 5 and tailwind 4 flo-bit.dev/ui-kit/
svelte tailwindcss ui-components
0

Configure Feed

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

improve social post, add sanitize

Florian (Mar 3, 2026, 5:35 AM +0100) 9167f3c5 630715ed

+804 -319
+47 -118
pnpm-lock.yaml
··· 29 29 30 30 apps/docs: 31 31 dependencies: 32 - '@atproto/api': 33 - specifier: ^0.15.5 34 - version: 0.15.27 32 + '@atcute/bluesky': 33 + specifier: ^3.2.19 34 + version: 3.2.19 35 35 '@sveltejs/adapter-static': 36 36 specifier: ^3.0.8 37 37 version: 3.0.10(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.2)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)))(svelte@5.53.2)(typescript@5.9.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))) ··· 414 414 clsx: 415 415 specifier: ^2.1.1 416 416 version: 2.1.1 417 + dompurify: 418 + specifier: ^3.3.1 419 + version: 3.3.1 417 420 mode-watcher: 418 421 specifier: ^1.1.0 419 422 version: 1.1.0(svelte@5.53.2) ··· 499 502 500 503 packages/social: 501 504 dependencies: 502 - '@atproto/api': 503 - specifier: ^0.18.21 504 - version: 0.18.21 505 + '@atcute/bluesky': 506 + specifier: ^3.2.19 507 + version: 3.2.19 508 + '@atcute/bluesky-richtext-segmenter': 509 + specifier: ^3.0.0 510 + version: 3.0.0 505 511 '@foxui/core': 506 512 specifier: workspace:* 507 513 version: link:../core ··· 920 926 921 927 packages: 922 928 923 - '@atproto/api@0.15.27': 924 - resolution: {integrity: sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g==, tarball: https://registry.npmjs.org/@atproto/api/-/api-0.15.27.tgz} 929 + '@atcute/atproto@3.1.10': 930 + resolution: {integrity: sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==, tarball: https://registry.npmjs.org/@atcute/atproto/-/atproto-3.1.10.tgz} 925 931 926 - '@atproto/api@0.18.21': 927 - resolution: {integrity: sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==, tarball: https://registry.npmjs.org/@atproto/api/-/api-0.18.21.tgz} 932 + '@atcute/bluesky-richtext-segmenter@3.0.0': 933 + resolution: {integrity: sha512-NhZTUKtFpeBBbILwAcxj5u4RobIoHOmGw3CAaaEFNebKYSvmTecrXJ7XufHw5DFOUdr8SiKXQVRQxGAxulMNWg==, tarball: https://registry.npmjs.org/@atcute/bluesky-richtext-segmenter/-/bluesky-richtext-segmenter-3.0.0.tgz} 928 934 929 - '@atproto/common-web@0.4.17': 930 - resolution: {integrity: sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ==, tarball: https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.17.tgz} 935 + '@atcute/bluesky@3.2.19': 936 + resolution: {integrity: sha512-W/Sy2671A5eKkdWJn6nfCGoixqMFOouEKRbqRV3630I13Er9iS+gA/rGWUXlp5xNpbfCNmsQ6C1pEidUmO+00g==, tarball: https://registry.npmjs.org/@atcute/bluesky/-/bluesky-3.2.19.tgz} 931 937 932 - '@atproto/lex-data@0.0.12': 933 - resolution: {integrity: sha512-aekJudcK1p6sbTqUv2bJMJBAGZaOJS0mgDclpK3U6VuBREK/au4B6ffunBFWgrDfg0Vwj2JGyEA7E51WZkJcRw==, tarball: https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.12.tgz} 938 + '@atcute/lexicons@1.2.9': 939 + resolution: {integrity: sha512-/RRHm2Cw9o8Mcsrq0eo8fjS9okKYLGfuFwrQ0YoP/6sdSDsXshaTLJsvLlcUcaDaSJ1YFOuHIo3zr2Om2F/16g==, tarball: https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.2.9.tgz} 934 940 935 - '@atproto/lex-json@0.0.12': 936 - resolution: {integrity: sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA==, tarball: https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.12.tgz} 941 + '@atcute/uint8array@1.1.1': 942 + resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==, tarball: https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.1.tgz} 937 943 938 - '@atproto/lexicon@0.4.14': 939 - resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==, tarball: https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.14.tgz} 940 - 941 - '@atproto/lexicon@0.6.1': 942 - resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==, tarball: https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz} 943 - 944 - '@atproto/syntax@0.4.3': 945 - resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==, tarball: https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz} 946 - 947 - '@atproto/xrpc@0.7.7': 948 - resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==, tarball: https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz} 944 + '@atcute/util-text@1.1.1': 945 + resolution: {integrity: sha512-JH0SxzUQJAmbOBTYyhxQbkkI6M33YpjlVLEcbP5GYt43xgFArzV0FJVmEpvIj0kjsmphHB45b6IitdvxPdec9w==, tarball: https://registry.npmjs.org/@atcute/util-text/-/util-text-1.1.1.tgz} 949 946 950 947 '@babel/runtime@7.28.6': 951 948 resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz} ··· 2209 2206 resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==, tarball: https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz} 2210 2207 engines: {node: '>=8'} 2211 2208 2212 - await-lock@2.2.2: 2213 - resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==, tarball: https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz} 2214 - 2215 2209 axobject-query@4.1.0: 2216 2210 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==, tarball: https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz} 2217 2211 engines: {node: '>= 0.4'} ··· 2410 2404 domhandler@5.0.3: 2411 2405 resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==, tarball: https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz} 2412 2406 engines: {node: '>= 4'} 2407 + 2408 + dompurify@3.3.1: 2409 + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==, tarball: https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz} 2413 2410 2414 2411 domutils@3.2.2: 2415 2412 resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==, tarball: https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz} ··· 2739 2736 isexe@2.0.0: 2740 2737 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz} 2741 2738 2742 - iso-datestring-validator@2.2.2: 2743 - resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==, tarball: https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz} 2744 - 2745 2739 jiti@2.6.1: 2746 2740 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz} 2747 2741 hasBin: true ··· 3047 3041 3048 3042 ms@2.1.3: 3049 3043 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmjs.org/ms/-/ms-2.1.3.tgz} 3050 - 3051 - multiformats@9.9.0: 3052 - resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==, tarball: https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz} 3053 3044 3054 3045 nanoid@3.3.11: 3055 3046 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz} ··· 3694 3685 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz} 3695 3686 engines: {node: '>=12.0.0'} 3696 3687 3697 - tlds@1.261.0: 3698 - resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==, tarball: https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz} 3699 - hasBin: true 3700 - 3701 3688 to-regex-range@5.0.1: 3702 3689 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, tarball: https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz} 3703 3690 engines: {node: '>=8.0'} ··· 3795 3782 3796 3783 uc.micro@2.1.0: 3797 3784 resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==, tarball: https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz} 3798 - 3799 - uint8arrays@3.0.0: 3800 - resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==, tarball: https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz} 3801 3785 3802 3786 undici@7.22.0: 3803 3787 resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==, tarball: https://registry.npmjs.org/undici/-/undici-7.22.0.tgz} ··· 4005 3989 zimmerframe@1.1.4: 4006 3990 resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==, tarball: https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz} 4007 3991 4008 - zod@3.25.76: 4009 - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz} 4010 - 4011 3992 zwitch@2.0.4: 4012 3993 resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==, tarball: https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz} 4013 3994 4014 3995 snapshots: 4015 3996 4016 - '@atproto/api@0.15.27': 3997 + '@atcute/atproto@3.1.10': 4017 3998 dependencies: 4018 - '@atproto/common-web': 0.4.17 4019 - '@atproto/lexicon': 0.4.14 4020 - '@atproto/syntax': 0.4.3 4021 - '@atproto/xrpc': 0.7.7 4022 - await-lock: 2.2.2 4023 - multiformats: 9.9.0 4024 - tlds: 1.261.0 4025 - zod: 3.25.76 3999 + '@atcute/lexicons': 1.2.9 4026 4000 4027 - '@atproto/api@0.18.21': 4028 - dependencies: 4029 - '@atproto/common-web': 0.4.17 4030 - '@atproto/lexicon': 0.6.1 4031 - '@atproto/syntax': 0.4.3 4032 - '@atproto/xrpc': 0.7.7 4033 - await-lock: 2.2.2 4034 - multiformats: 9.9.0 4035 - tlds: 1.261.0 4036 - zod: 3.25.76 4001 + '@atcute/bluesky-richtext-segmenter@3.0.0': {} 4037 4002 4038 - '@atproto/common-web@0.4.17': 4003 + '@atcute/bluesky@3.2.19': 4039 4004 dependencies: 4040 - '@atproto/lex-data': 0.0.12 4041 - '@atproto/lex-json': 0.0.12 4042 - '@atproto/syntax': 0.4.3 4043 - zod: 3.25.76 4005 + '@atcute/atproto': 3.1.10 4006 + '@atcute/lexicons': 1.2.9 4044 4007 4045 - '@atproto/lex-data@0.0.12': 4008 + '@atcute/lexicons@1.2.9': 4046 4009 dependencies: 4047 - multiformats: 9.9.0 4048 - tslib: 2.8.1 4049 - uint8arrays: 3.0.0 4010 + '@atcute/uint8array': 1.1.1 4011 + '@atcute/util-text': 1.1.1 4012 + '@standard-schema/spec': 1.1.0 4013 + esm-env: 1.2.2 4014 + 4015 + '@atcute/uint8array@1.1.1': {} 4016 + 4017 + '@atcute/util-text@1.1.1': 4018 + dependencies: 4050 4019 unicode-segmenter: 0.14.5 4051 - 4052 - '@atproto/lex-json@0.0.12': 4053 - dependencies: 4054 - '@atproto/lex-data': 0.0.12 4055 - tslib: 2.8.1 4056 - 4057 - '@atproto/lexicon@0.4.14': 4058 - dependencies: 4059 - '@atproto/common-web': 0.4.17 4060 - '@atproto/syntax': 0.4.3 4061 - iso-datestring-validator: 2.2.2 4062 - multiformats: 9.9.0 4063 - zod: 3.25.76 4064 - 4065 - '@atproto/lexicon@0.6.1': 4066 - dependencies: 4067 - '@atproto/common-web': 0.4.17 4068 - '@atproto/syntax': 0.4.3 4069 - iso-datestring-validator: 2.2.2 4070 - multiformats: 9.9.0 4071 - zod: 3.25.76 4072 - 4073 - '@atproto/syntax@0.4.3': 4074 - dependencies: 4075 - tslib: 2.8.1 4076 - 4077 - '@atproto/xrpc@0.7.7': 4078 - dependencies: 4079 - '@atproto/lexicon': 0.6.1 4080 - zod: 3.25.76 4081 4020 4082 4021 '@babel/runtime@7.28.6': {} 4083 4022 ··· 5355 5294 5356 5295 array-union@2.1.0: {} 5357 5296 5358 - await-lock@2.2.2: {} 5359 - 5360 5297 axobject-query@4.1.0: {} 5361 5298 5362 5299 bail@2.0.2: {} ··· 5540 5477 domhandler@5.0.3: 5541 5478 dependencies: 5542 5479 domelementtype: 2.3.0 5480 + 5481 + dompurify@3.3.1: 5482 + optionalDependencies: 5483 + '@types/trusted-types': 2.0.7 5543 5484 5544 5485 domutils@3.2.2: 5545 5486 dependencies: ··· 5983 5924 5984 5925 isexe@2.0.0: {} 5985 5926 5986 - iso-datestring-validator@2.2.2: {} 5987 - 5988 5927 jiti@2.6.1: {} 5989 5928 5990 5929 js-yaml@3.14.2: ··· 6371 6310 mrmime@2.0.1: {} 6372 6311 6373 6312 ms@2.1.3: {} 6374 - 6375 - multiformats@9.9.0: {} 6376 6313 6377 6314 nanoid@3.3.11: {} 6378 6315 ··· 6962 6899 fdir: 6.5.0(picomatch@4.0.3) 6963 6900 picomatch: 4.0.3 6964 6901 6965 - tlds@1.261.0: {} 6966 - 6967 6902 to-regex-range@5.0.1: 6968 6903 dependencies: 6969 6904 is-number: 7.0.0 ··· 7056 6991 typescript@5.9.3: {} 7057 6992 7058 6993 uc.micro@2.1.0: {} 7059 - 7060 - uint8arrays@3.0.0: 7061 - dependencies: 7062 - multiformats: 9.9.0 7063 6994 7064 6995 undici@7.22.0: {} 7065 6996 ··· 7218 7149 yocto-queue@0.1.0: {} 7219 7150 7220 7151 zimmerframe@1.1.4: {} 7221 - 7222 - zod@3.25.76: {} 7223 7152 7224 7153 zwitch@2.0.4: {}
+6
.changeset/stale-pans-tie.md
··· 1 + --- 2 + '@foxui/social': patch 3 + '@foxui/core': patch 4 + --- 5 + 6 + improve social post and bluesky post, add sanitize function
+1 -1
apps/docs/package.json
··· 44 44 "vite": "^6.2.6" 45 45 }, 46 46 "dependencies": { 47 - "@atproto/api": "^0.15.5", 47 + "@atcute/bluesky": "^3.2.19", 48 48 "@sveltejs/adapter-static": "^3.0.8", 49 49 "mdsvex": "^0.12.5", 50 50 "tailwindcss-animate": "^1.0.7",
+1
packages/core/package.json
··· 71 71 "@number-flow/svelte": "^0.3.12", 72 72 "bits-ui": "^2.16.2", 73 73 "clsx": "^2.1.1", 74 + "dompurify": "^3.3.1", 74 75 "mode-watcher": "^1.1.0", 75 76 "svelte-sonner": "^1.0.7", 76 77 "tailwind-merge": "^3.5.0",
+2 -1
packages/social/package.json
··· 68 68 "vite": "^7.3.1" 69 69 }, 70 70 "dependencies": { 71 - "@atproto/api": "^0.18.21", 71 + "@atcute/bluesky": "^3.2.19", 72 + "@atcute/bluesky-richtext-segmenter": "^3.0.0", 72 73 "@foxui/core": "workspace:*", 73 74 "@foxui/time": "workspace:*", 74 75 "@use-gesture/vanilla": "^10.3.1",
+2
packages/core/src/lib/index.ts
··· 2 2 3 3 // base components 4 4 export * from './components/'; 5 + 6 + export { sanitize } from './sanitize';
+30
packages/core/src/lib/sanitize.ts
··· 1 + import { browser } from '$app/environment'; 2 + 3 + // Lightweight regex-based sanitizer for SSR in Cloudflare Workers 4 + // where DOMPurify is not available. Strips common XSS vectors. 5 + function regexSanitize(html: string): string { 6 + return html 7 + .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script\s*>/gi, '') 8 + .replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe\s*>/gi, '') 9 + .replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object\s*>/gi, '') 10 + .replace(/<embed\b[^>]*\/?>/gi, '') 11 + .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style\s*>/gi, '') 12 + .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '') 13 + .replace(/href\s*=\s*["']?\s*javascript\s*:/gi, 'href="') 14 + .replace(/src\s*=\s*["']?\s*javascript\s*:/gi, 'src="'); 15 + } 16 + 17 + let _purify: ((html: string, config?: { ADD_ATTR?: string[] }) => string) | null = null; 18 + 19 + if (browser) { 20 + import('dompurify').then((mod) => { 21 + _purify = (html, config) => mod.default.sanitize(html, config) as string; 22 + }); 23 + } 24 + 25 + export function sanitize(dirty: string, config?: { ADD_ATTR?: string[] }): string { 26 + if (_purify) { 27 + return _purify(dirty, config); 28 + } 29 + return regexSanitize(dirty); 30 + }
+40 -5
packages/social/src/lib/components/bluesky-post/BlueskyPost.svelte
··· 1 1 <script lang="ts"> 2 - import type { FeedViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 3 2 import { Post } from '../post'; 4 3 import { blueskyPostToPostData } from '.'; 5 4 import type { Snippet } from 'svelte'; 5 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 6 6 7 7 let { 8 8 feedViewPost, 9 + reason, 9 10 children, 10 11 showLogo = false, 12 + showAvatar = true, 13 + compact = false, 14 + showImages = true, 15 + showExternal = true, 16 + showVideo = true, 17 + showQuotes = true, 11 18 ...restProps 12 - }: { feedViewPost?: FeedViewPost; children?: Snippet; showLogo?: boolean } = $props(); 19 + }: { 20 + feedViewPost?: PostView; 21 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 + reason?: any; 23 + children?: Snippet; 24 + showLogo?: boolean; 25 + showAvatar?: boolean; 26 + compact?: boolean; 27 + showImages?: boolean; 28 + showExternal?: boolean; 29 + showVideo?: boolean; 30 + showQuotes?: boolean; 31 + } = $props(); 13 32 14 - const postData = $derived(feedViewPost ? blueskyPostToPostData(feedViewPost) : undefined); 33 + const postData = $derived( 34 + feedViewPost ? blueskyPostToPostData(feedViewPost, undefined, reason) : undefined 35 + ); 15 36 </script> 16 37 17 38 {#snippet logo()} 18 39 <a 19 - class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500" 40 + class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500 accent:text-accent-900 accent:hover:text-accent-800" 20 41 href={postData?.href} 21 42 > 22 43 <svg xmlns="http://www.w3.org/2000/svg" version="1.1" class="size-4" viewBox="0 0 600 530"> ··· 30 51 {/snippet} 31 52 32 53 {#if postData} 33 - <Post data={postData} logo={showLogo ? logo : undefined} {...restProps}> 54 + <Post 55 + data={postData} 56 + replyHref={postData?.href} 57 + repostHref={postData?.href} 58 + likeHref={postData?.href} 59 + showBookmark={false} 60 + logo={showLogo ? logo : undefined} 61 + {showAvatar} 62 + {compact} 63 + {showImages} 64 + {showExternal} 65 + {showVideo} 66 + {showQuotes} 67 + {...restProps} 68 + > 34 69 {@render children?.()} 35 70 </Post> 36 71 {/if}
+188 -75
packages/social/src/lib/components/bluesky-post/index.ts
··· 1 - import { RichText } from '@atproto/api'; 2 - import type { PostData, PostEmbed } from '../post'; 3 - import type { PostView } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 1 + import type { PostData, PostEmbed, QuotedPostData } from '../post'; 2 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 3 + import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 4 + 5 + function escapeHtml(str: string): string { 6 + return str 7 + .replace(/&/g, '&amp;') 8 + .replace(/</g, '&lt;') 9 + .replace(/>/g, '&gt;') 10 + .replace(/"/g, '&quot;') 11 + .replace(/'/g, '&#39;'); 12 + } 13 + 14 + interface MentionFeature { 15 + $type: 'app.bsky.richtext.facet#mention'; 16 + did: string; 17 + } 18 + 19 + interface LinkFeature { 20 + $type: 'app.bsky.richtext.facet#link'; 21 + uri: string; 22 + } 23 + 24 + interface TagFeature { 25 + $type: 'app.bsky.richtext.facet#tag'; 26 + tag: string; 27 + } 28 + 29 + type Feature = MentionFeature | LinkFeature | TagFeature; 30 + 31 + const renderSegment = (segment: RichtextSegment, baseUrl: string) => { 32 + const { text, features } = segment; 33 + const escaped = escapeHtml(text); 34 + 35 + if (!features) { 36 + return `<span>${escaped}</span>`; 37 + } 38 + 39 + const feature = features[0] as Feature; 40 + 41 + const createLink = (href: string, text: string) => { 42 + return `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${text}</a>`; 43 + }; 44 + 45 + switch (feature.$type) { 46 + case 'app.bsky.richtext.facet#mention': 47 + return createLink(`${baseUrl}/profile/${feature.did}`, escaped); 48 + case 'app.bsky.richtext.facet#link': 49 + return createLink(feature.uri, escaped); 50 + case 'app.bsky.richtext.facet#tag': 51 + return createLink(`${baseUrl}/hashtag/${feature.tag}`, escaped); 52 + default: 53 + return `<span>${escaped}</span>`; 54 + } 55 + }; 56 + 57 + const RichText = ({ text, facets }: { text: string; facets?: Facet[] }, baseUrl: string) => { 58 + const segments = segmentize(text, facets); 59 + return segments.map((v) => renderSegment(v, baseUrl)).join(''); 60 + }; 4 61 5 62 function blueskyEmbedTypeToEmbedType(type: string) { 6 63 switch (type) { ··· 13 70 case 'app.bsky.embed.video#view': 14 71 case 'app.bsky.embed.video': 15 72 return 'video'; 73 + case 'app.bsky.embed.record#view': 74 + case 'app.bsky.embed.record': 75 + return 'record'; 76 + case 'app.bsky.embed.recordWithMedia#view': 77 + case 'app.bsky.embed.recordWithMedia': 78 + return 'recordWithMedia'; 16 79 default: 17 80 return 'unknown'; 18 81 } 19 82 } 20 83 84 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 85 + function extractQuotedPost(recordView: any, baseUrl: string): QuotedPostData | null { 86 + if (!recordView?.author) return null; 87 + 88 + const id = recordView.uri?.split('/').pop(); 89 + const author = recordView.author; 90 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 91 + const value = recordView.value as any; 92 + 93 + let htmlContent = ''; 94 + if (value?.text) { 95 + htmlContent = RichText({ text: value.text, facets: value.facets }, baseUrl).replace( 96 + /\n/g, 97 + '<br>' 98 + ); 99 + } 100 + 101 + let embed: PostEmbed | undefined; 102 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 103 + const firstEmbed = recordView.embeds?.[0] as any; 104 + if (firstEmbed) { 105 + const embedType = blueskyEmbedTypeToEmbedType(firstEmbed.$type); 106 + if (embedType !== 'record' && embedType !== 'recordWithMedia' && embedType !== 'unknown') { 107 + embed = convertEmbed(firstEmbed, baseUrl); 108 + } 109 + } 110 + 111 + return { 112 + author: { 113 + displayName: author.displayName || '', 114 + handle: author.handle, 115 + avatar: author.avatar, 116 + href: `${baseUrl}/profile/${author.did}` 117 + }, 118 + href: `${baseUrl}/profile/${author.handle}/post/${id}`, 119 + htmlContent, 120 + createdAt: value?.createdAt, 121 + embed 122 + }; 123 + } 124 + 125 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 126 + function convertEmbed(embedView: any, baseUrl: string): PostEmbed { 127 + const type = blueskyEmbedTypeToEmbedType(embedView?.$type); 128 + 129 + switch (type) { 130 + case 'images': 131 + return { 132 + type: 'images', 133 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 134 + images: embedView.images?.map((image: any) => ({ 135 + alt: image.alt, 136 + thumb: image.thumb, 137 + aspectRatio: image.aspectRatio, 138 + fullsize: image.fullsize 139 + })) 140 + }; 141 + case 'external': 142 + return embedView.external 143 + ? { 144 + type: 'external', 145 + external: { 146 + href: embedView.external.uri, 147 + title: embedView.external.title, 148 + description: embedView.external.description, 149 + thumb: embedView.external.thumb 150 + } 151 + } 152 + : { type: 'unknown' }; 153 + case 'video': 154 + return embedView.playlist 155 + ? { 156 + type: 'video', 157 + video: { 158 + playlist: embedView.playlist, 159 + thumb: embedView.thumbnail, 160 + alt: embedView.alt, 161 + aspectRatio: embedView.aspectRatio 162 + } 163 + } 164 + : { type: 'unknown' }; 165 + case 'record': { 166 + const record = extractQuotedPost(embedView.record, baseUrl); 167 + return record ? { type: 'record', record } : { type: 'unknown' }; 168 + } 169 + case 'recordWithMedia': { 170 + const record = extractQuotedPost(embedView.record?.record, baseUrl); 171 + const media = embedView.media ? convertEmbed(embedView.media, baseUrl) : undefined; 172 + if (record) { 173 + return { 174 + type: 'recordWithMedia', 175 + record, 176 + media: media ?? { type: 'unknown' } 177 + }; 178 + } 179 + return media ?? { type: 'unknown' }; 180 + } 181 + default: 182 + return { type: 'unknown' }; 183 + } 184 + } 185 + 21 186 export function blueskyPostToPostData( 22 187 data: PostView, 23 - baseUrl: string = 'https://bsky.app' 188 + baseUrl: string = 'https://bsky.app', 189 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 190 + reason?: any 24 191 ): PostData { 25 - console.log(data); 26 192 const post = data; 27 - // const reason = data.reason; 28 - // const reply = data.reply?.parent; 29 - // const replyId = reply?.uri?.split('/').pop(); 30 - 31 193 const id = post.uri.split('/').pop(); 194 + 195 + const reposted = 196 + reason?.$type === 'app.bsky.feed.defs#reasonRepost' && reason?.by 197 + ? { 198 + handle: reason.by.handle as string, 199 + href: `${baseUrl}/profile/${reason.by.did ?? reason.by.handle}` 200 + } 201 + : undefined; 32 202 33 203 return { 34 204 id, 35 205 href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 36 - // reposted: 37 - // reason && reason.$type === 'app.bsky.feed.defs#reasonRepost' 38 - // ? { 39 - // handle: reason.by.handle, 40 - // href: `${baseUrl}/profile/${reason.by.handle}` 41 - // } 42 - // : undefined, 43 - 44 - // replyTo: 45 - // reply && replyId 46 - // ? { 47 - // handle: reply.author.handle, 48 - // href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}` 49 - // } 50 - // : undefined, 206 + reposted, 51 207 author: { 52 - displayName: post.author.displayName, 208 + displayName: post.author.displayName || '', 53 209 handle: post.author.handle, 54 210 avatar: post.author.avatar, 55 211 href: `${baseUrl}/profile/${post.author.did}` ··· 57 213 replyCount: post.replyCount ?? 0, 58 214 repostCount: post.repostCount ?? 0, 59 215 likeCount: post.likeCount ?? 0, 60 - createdAt: post.record.createdAt ?? 0, 216 + createdAt: (post.record as { createdAt?: string }).createdAt ?? '', 61 217 62 - embed: post.embed 63 - ? ({ 64 - type: blueskyEmbedTypeToEmbedType(post.embed?.$type), 65 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 66 - images: post.embed?.images?.map((image: any) => ({ 67 - alt: image.alt, 68 - thumb: image.thumb, 69 - aspectRatio: image.aspectRatio, 70 - fullsize: image.fullsize 71 - })), 72 - external: post.embed?.external 73 - ? { 74 - href: post.embed.external.uri, 75 - title: post.embed.external.title, 76 - description: post.embed.external.description, 77 - thumb: post.embed.external.thumb 78 - } 79 - : undefined, 80 - video: post.embed 81 - ? { 82 - playlist: post.embed.playlist, 83 - thumb: post.embed.thumbnail, 84 - alt: post.embed.alt, 85 - aspectRatio: post.embed.aspectRatio 86 - } 87 - : undefined 88 - } as PostEmbed) 89 - : undefined, 218 + embed: post.embed ? convertEmbed(post.embed, baseUrl) : undefined, 90 219 91 - htmlContent: blueskyPostToHTML(post, baseUrl) 220 + htmlContent: blueskyPostToHTML(post, baseUrl), 221 + labels: post.labels ? post.labels.map((label) => label.val) : undefined 92 222 }; 93 223 } 94 224 95 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 96 - export function blueskyPostToHTML(post: any, baseUrl: string = 'https://bsky.app') { 225 + export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') { 97 226 if (!post?.record) { 98 227 return ''; 99 228 } 100 - const rt = new RichText(post.record); 101 - let html = ''; 102 229 103 - const createLink = (href: string, text: string) => { 104 - return `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${encodeURI(text)}</a>`; 105 - }; 106 - 107 - for (const segment of rt.segments()) { 108 - if (!segment) continue; 109 - if (segment.isLink() && segment.link?.uri) { 110 - html += createLink(segment.link?.uri, segment.text); 111 - } else if (segment.isMention() && segment.mention?.did) { 112 - html += createLink(`${baseUrl}/profile/${segment.mention?.did}`, segment.text); 113 - } else if (segment.isTag() && segment.tag?.tag) { 114 - html += createLink(`${baseUrl}/hashtag/${segment.tag?.tag}`, segment.text); 115 - } else { 116 - html += segment.text; 117 - } 118 - } 230 + const record = post.record as { text?: string; facets?: Facet[] }; 231 + const html = RichText({ text: record.text ?? '', facets: record.facets }, baseUrl); 119 232 120 233 return html.replace(/\n/g, '<br>'); 121 234 }
+195 -43
packages/social/src/lib/components/post/Post.svelte
··· 1 1 <script lang="ts"> 2 - import Embed from './embeds/Embed.svelte'; 3 - import { cn, Avatar, Prose } from '@foxui/core'; 2 + import { cn, sanitize } from '@foxui/core'; 4 3 import type { WithChildren, WithElementRef } from 'bits-ui'; 5 4 import type { HTMLAttributes } from 'svelte/elements'; 6 - import type { PostData } from '.'; 5 + import type { PostData, PostImageData, QuotedPostData } from '.'; 7 6 import PostAction from './PostAction.svelte'; 8 7 import type { Snippet } from 'svelte'; 9 8 import { numberToHumanReadable } from '..'; 10 9 import { RelativeTime } from '@foxui/time'; 10 + import PostEmbed from './PostEmbed.svelte'; 11 11 12 12 let { 13 13 ref = $bindable(), ··· 26 26 onLikeClick, 27 27 onBookmarkClick, 28 28 29 + replyHref, 30 + repostHref, 31 + likeHref, 32 + 33 + onclickimage, 34 + onclickpost, 35 + onclickhandle, 36 + 29 37 customActions, 30 38 31 39 children, 32 40 33 - logo 41 + logo, 42 + 43 + showAvatar = true, 44 + compact = false, 45 + 46 + showImages = true, 47 + showExternal = true, 48 + showVideo = true, 49 + showQuotes = true 34 50 }: WithElementRef<WithChildren<HTMLAttributes<HTMLDivElement>>> & { 35 51 data: PostData; 36 52 class?: string; ··· 48 64 onLikeClick?: () => void; 49 65 onBookmarkClick?: () => void; 50 66 67 + replyHref?: string; 68 + repostHref?: string; 69 + likeHref?: string; 70 + 71 + onclickimage?: (image: PostImageData) => void; 72 + onclickpost?: (data: PostData | QuotedPostData, href?: string) => void; 73 + onclickhandle?: (handle: string, href?: string) => void; 74 + 51 75 customActions?: Snippet; 52 76 53 77 logo?: Snippet; 78 + 79 + showAvatar?: boolean; 80 + compact?: boolean; 81 + 82 + showImages?: boolean; 83 + showExternal?: boolean; 84 + showVideo?: boolean; 85 + showQuotes?: boolean; 54 86 } = $props(); 87 + 88 + function handleContentClick(e: MouseEvent) { 89 + if (!onclickhandle) return; 90 + const target = (e.target as HTMLElement).closest('a'); 91 + if (!target) return; 92 + const href = target.getAttribute('href'); 93 + if (!href) return; 94 + // Match profile links like /profile/did:... or /profile/handle 95 + const match = href.match(/\/profile\/([^/]+)$/); 96 + if (match) { 97 + e.preventDefault(); 98 + onclickhandle(match[1], href); 99 + } 100 + } 55 101 </script> 56 102 57 103 <div ··· 75 121 76 122 <div class="inline-flex gap-1"> 77 123 reposted by 78 - <a 79 - href={data.reposted.href} 80 - class="hover:text-accent-600 dark:hover:text-accent-400 font-bold" 81 - > 82 - @{data.reposted.handle} 83 - </a> 124 + {#if onclickhandle} 125 + <button 126 + class="hover:text-accent-600 dark:hover:text-accent-400 cursor-pointer font-bold" 127 + onclick={() => onclickhandle(data.reposted!.handle, data.reposted!.href)} 128 + > 129 + @{data.reposted.handle} 130 + </button> 131 + {:else} 132 + <a 133 + href={data.reposted.href} 134 + target="_blank" 135 + class="hover:text-accent-600 dark:hover:text-accent-400 font-bold" 136 + > 137 + @{data.reposted.handle} 138 + </a> 139 + {/if} 84 140 </div> 85 141 </div> 86 142 {/if} 87 143 {#if data.replyTo} 88 144 <div class="mb-3 inline-flex items-center gap-2 text-xs"> 89 - 90 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-3"> 91 - <path fill-rule="evenodd" d="M14.47 2.47a.75.75 0 0 1 1.06 0l6 6a.75.75 0 0 1 0 1.06l-6 6a.75.75 0 1 1-1.06-1.06l4.72-4.72H9a5.25 5.25 0 1 0 0 10.5h3a.75.75 0 0 1 0 1.5H9a6.75 6.75 0 0 1 0-13.5h10.19l-4.72-4.72a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /> 92 - </svg> 93 - 94 - 145 + <svg 146 + xmlns="http://www.w3.org/2000/svg" 147 + viewBox="0 0 24 24" 148 + fill="currentColor" 149 + class="size-3" 150 + > 151 + <path 152 + fill-rule="evenodd" 153 + d="M14.47 2.47a.75.75 0 0 1 1.06 0l6 6a.75.75 0 0 1 0 1.06l-6 6a.75.75 0 1 1-1.06-1.06l4.72-4.72H9a5.25 5.25 0 1 0 0 10.5h3a.75.75 0 0 1 0 1.5H9a6.75 6.75 0 0 1 0-13.5h10.19l-4.72-4.72a.75.75 0 0 1 0-1.06Z" 154 + clip-rule="evenodd" 155 + /> 156 + </svg> 157 + 95 158 <div class="inline-flex gap-1"> 96 159 replying to 97 - <a 98 - href={data.replyTo.href} 99 - class="hover:text-accent-600 dark:hover:text-accent-400 font-bold" 100 - > 101 - @{data.replyTo.handle} 102 - </a> 160 + {#if onclickhandle} 161 + <button 162 + class="hover:text-accent-600 dark:hover:text-accent-400 cursor-pointer font-bold" 163 + onclick={() => onclickhandle(data.replyTo!.handle, data.replyTo!.href)} 164 + > 165 + @{data.replyTo.handle} 166 + </button> 167 + {:else} 168 + <a 169 + href={data.replyTo.href} 170 + target="_blank" 171 + class="hover:text-accent-600 dark:hover:text-accent-400 font-bold" 172 + > 173 + @{data.replyTo.handle} 174 + </a> 175 + {/if} 103 176 </div> 104 177 </div> 105 178 {/if} 106 179 <div class="flex gap-4"> 107 - <Avatar src={data.author.avatar} /> 180 + {#if showAvatar && data.author.avatar} 181 + {#if onclickhandle} 182 + <button 183 + class="flex-shrink-0 cursor-pointer" 184 + onclick={() => onclickhandle(data.author.handle, data.author.href)} 185 + > 186 + <img 187 + src={data.author.avatar} 188 + alt="" 189 + class={compact 190 + ? 'size-7 rounded-full object-cover' 191 + : 'size-10 rounded-full object-cover'} 192 + /> 193 + </button> 194 + {:else} 195 + <a href={data.author.href} target="_blank" class="flex-shrink-0"> 196 + <img 197 + src={data.author.avatar} 198 + alt="" 199 + class={compact 200 + ? 'size-7 rounded-full object-cover' 201 + : 'size-10 rounded-full object-cover'} 202 + /> 203 + </a> 204 + {/if} 205 + {/if} 108 206 109 - <div class="w-full"> 207 + <div class="min-w-0 w-full"> 110 208 <div class="mb-1 flex items-start justify-between gap-2"> 111 209 <div class="flex items-start gap-4"> 112 - {#if data.author.href} 113 - <a 114 - class="hover:bg-accent-900/5 group/post-author -mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 115 - href={data.author.href} 210 + {#if onclickhandle} 211 + <button 212 + class="hover:bg-accent-900/5 accent:hover:bg-accent-100/10 group/post-author -mx-2 -my-0.5 flex cursor-pointer flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 213 + onclick={() => onclickhandle(data.author.handle, data.author.href)} 116 214 > 117 215 {#if data.author.displayName} 118 216 <div 119 - class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 text-sm font-semibold leading-tight" 217 + class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 accent:group-hover/post-author:text-accent-950 line-clamp-1 text-sm leading-tight font-semibold" 120 218 > 121 219 {data.author.displayName} 122 220 </div> 123 221 {/if} 124 222 <div 125 223 class={cn( 126 - 'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 text-sm', 224 + 'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 accent:text-accent-950 accent:group-hover/post-author:text-accent-900 text-sm', 225 + !data.author.displayName 226 + ? 'text-base-900 dark:text-base-50 font-semibold' 227 + : 'text-base-600 dark:text-base-400' 228 + )} 229 + > 230 + @{data.author.handle} 231 + </div> 232 + </button> 233 + {:else if data.author.href} 234 + <a 235 + class="hover:bg-accent-900/5 accent:hover:bg-accent-100/10 group/post-author -mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 236 + href={data.author.href} 237 + target="_blank" 238 + > 239 + {#if data.author.displayName} 240 + <div 241 + class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 accent:group-hover/post-author:text-accent-950 line-clamp-1 text-sm leading-tight font-semibold" 242 + > 243 + {data.author.displayName} 244 + </div> 245 + {/if} 246 + <div 247 + class={cn( 248 + 'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 accent:text-accent-950 accent:group-hover/post-author:text-accent-900 text-sm', 127 249 !data.author.displayName 128 250 ? 'text-base-900 dark:text-base-50 font-semibold' 129 251 : 'text-base-600 dark:text-base-400' ··· 136 258 <div 137 259 class="-mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 138 260 > 139 - <div class="text-base-900 dark:text-base-50 text-sm font-semibold leading-tight"> 261 + <div class="text-base-900 dark:text-base-50 text-sm leading-tight font-semibold"> 140 262 {data.author.displayName} 141 263 </div> 142 - <div class="text-base-600 dark:text-base-400 text-sm"> 264 + <div class="text-base-600 dark:text-base-400 accent:text-accent-950 text-sm"> 143 265 @{data.author.handle} 144 266 </div> 145 267 </div> 146 268 {/if} 147 269 148 - <div class="text-base-600 dark:text-base-400 block text-sm no-underline"> 270 + <div 271 + class={cn( 272 + 'text-base-600 dark:text-base-400 accent:text-accent-950 block no-underline', 273 + compact ? 'text-xs' : 'text-sm' 274 + )} 275 + > 149 276 <RelativeTime date={new Date(data.createdAt)} locale="en" /> 150 277 </div> 151 278 </div> ··· 155 282 {/if} 156 283 </div> 157 284 158 - <Prose size="md"> 285 + <!-- svelte-ignore a11y_click_events_have_key_events --> 286 + <!-- svelte-ignore a11y_no_static_element_interactions --> 287 + <div 288 + class={cn('post-content break-words', compact ? 'text-sm' : 'text-base')} 289 + onclick={onclickhandle ? handleContentClick : undefined} 290 + > 159 291 {#if data.htmlContent} 160 - {@html data.htmlContent} 292 + {@html sanitize(data.htmlContent, { ADD_ATTR: ['target'] })} 161 293 {:else} 162 294 {@render children?.()} 163 295 {/if} 164 - </Prose> 296 + </div> 165 297 166 - {#if data.embed} 167 - <Embed embed={data.embed} /> 168 - {/if} 298 + <PostEmbed 299 + {data} 300 + {showImages} 301 + {showExternal} 302 + {showVideo} 303 + {showQuotes} 304 + {onclickimage} 305 + {onclickpost} 306 + {onclickhandle} 307 + /> 169 308 170 - {#if showReply || showRepost || showLike || showBookmark || customActions} 171 - <div class="text-base-500 dark:text-base-400 mt-4 flex justify-between gap-2"> 309 + {#if !compact && (showReply || showRepost || showLike || showBookmark || customActions)} 310 + <div 311 + class="text-base-500 dark:text-base-400 accent:text-base-900 mt-4 flex justify-between gap-2" 312 + > 172 313 {#if showReply} 173 - <PostAction onclick={onReplyClick}> 314 + <PostAction onclick={onReplyClick} href={replyHref}> 174 315 <svg 175 316 xmlns="http://www.w3.org/2000/svg" 176 317 fill="none" ··· 192 333 {/if} 193 334 194 335 {#if showRepost} 195 - <PostAction onclick={onRepostClick}> 336 + <PostAction onclick={onRepostClick} href={repostHref}> 196 337 <svg 197 338 xmlns="http://www.w3.org/2000/svg" 198 339 fill="none" ··· 216 357 <PostAction 217 358 class={liked ? 'text-accent-700 dark:text-accent-500 font-semibold' : ''} 218 359 onclick={onLikeClick} 360 + href={likeHref} 219 361 > 220 362 {#if liked} 221 363 <svg ··· 292 434 </div> 293 435 </div> 294 436 </div> 437 + 438 + <style> 439 + .post-content :global(a) { 440 + color: var(--color-accent-600); 441 + } 442 + 443 + :global(.dark) .post-content :global(a) { 444 + color: var(--color-accent-400); 445 + } 446 + </style>
+10
packages/social/src/lib/components/post/PostAction.svelte
··· 5 5 let { 6 6 onclick, 7 7 children, 8 + href, 8 9 class: className 9 10 }: { 10 11 onclick?: () => void; 11 12 children: Snippet; 12 13 class?: string; 14 + href?: string; 13 15 } = $props(); 14 16 </script> 15 17 ··· 20 22 > 21 23 {@render children?.()} 22 24 </button> 25 + {:else if href} 26 + <a 27 + class={cn('group/post-action inline-flex cursor-pointer items-center gap-2 text-sm', className)} 28 + {href} 29 + target="_blank" 30 + > 31 + {@render children?.()} 32 + </a> 23 33 {:else} 24 34 <div class={cn('inline-flex items-center gap-2 text-sm', className)}> 25 35 {@render children?.()}
+48
packages/social/src/lib/components/post/PostEmbed.svelte
··· 1 + <script lang="ts"> 2 + import { hasNSFWLabel, type PostData, type PostImageData, type QuotedPostData } from '.'; 3 + import Embed from './embeds/Embed.svelte'; 4 + 5 + let { 6 + data, 7 + showImages = true, 8 + showExternal = true, 9 + showVideo = true, 10 + showQuotes = true, 11 + onclickimage, 12 + onclickpost, 13 + onclickhandle 14 + }: { 15 + data: PostData; 16 + showImages?: boolean; 17 + showExternal?: boolean; 18 + showVideo?: boolean; 19 + showQuotes?: boolean; 20 + onclickimage?: (image: PostImageData) => void; 21 + onclickpost?: (data: PostData | QuotedPostData, href?: string) => void; 22 + onclickhandle?: (handle: string, href?: string) => void; 23 + } = $props(); 24 + 25 + let showNSFW = $state(false); 26 + </script> 27 + 28 + {#if data.embed} 29 + {#if hasNSFWLabel(data) && !showNSFW} 30 + <button 31 + onclick={() => (showNSFW = true)} 32 + class="border-base-500/20 bg-base-200/50 text-base-600 dark:border-base-400/20 dark:bg-base-800/50 dark:text-base-400 accent:border-accent-900 mt-4 flex h-18 w-full cursor-pointer items-center justify-center rounded-2xl border text-center text-sm" 33 + > 34 + NSFW content, click to show. 35 + </button> 36 + {:else} 37 + <Embed 38 + embed={data.embed} 39 + {showImages} 40 + {showExternal} 41 + {showVideo} 42 + {showQuotes} 43 + {onclickimage} 44 + {onclickpost} 45 + {onclickhandle} 46 + /> 47 + {/if} 48 + {/if}
+52 -11
packages/social/src/lib/components/post/index.ts
··· 1 + export type PostImageData = { 2 + alt: string; 3 + thumb: string; 4 + fullsize: string; 5 + aspectRatio?: { 6 + width: number; 7 + height: number; 8 + }; 9 + }; 10 + 1 11 export type PostEmbedImage = { 2 12 type: 'images'; 3 - 4 - images: { 5 - alt: string; 6 - thumb: string; 7 - fullsize: string; 8 - aspectRatio?: { 9 - width: number; 10 - height: number; 11 - }; 12 - }[]; 13 + images: PostImageData[]; 13 14 }; 14 15 15 16 export type PostEmbedExternal = { ··· 39 40 }; 40 41 }; 41 42 43 + export type QuotedPostData = { 44 + author: { 45 + displayName: string; 46 + handle: string; 47 + avatar?: string; 48 + href?: string; 49 + }; 50 + href?: string; 51 + htmlContent?: string; 52 + createdAt?: string; 53 + embed?: PostEmbed; 54 + }; 55 + 56 + export type PostEmbedRecord = { 57 + type: 'record'; 58 + record: QuotedPostData; 59 + }; 60 + 61 + export type PostEmbedRecordWithMedia = { 62 + type: 'recordWithMedia'; 63 + record: QuotedPostData; 64 + media: PostEmbed; 65 + }; 66 + 42 67 export type UnknownEmbed = { 43 68 type: 'unknown'; 44 69 } & Record<string, unknown>; 45 70 46 - export type PostEmbed = PostEmbedImage | PostEmbedExternal | PostEmbedVideo | UnknownEmbed; 71 + export type PostEmbed = 72 + | PostEmbedImage 73 + | PostEmbedExternal 74 + | PostEmbedVideo 75 + | PostEmbedRecord 76 + | PostEmbedRecordWithMedia 77 + | UnknownEmbed; 47 78 48 79 export type PostData = { 49 80 href?: string; ··· 70 101 htmlContent?: string; 71 102 72 103 replies?: PostData[]; 104 + 105 + labels?: string[]; 73 106 }; 107 + 108 + export const nsfwLabels = ['porn', 'sexual', 'graphic-media', 'nudity']; 109 + 110 + export function hasNSFWLabel(post: PostData): boolean { 111 + if (!post.labels) return false; 112 + 113 + return post.labels.some((label) => nsfwLabels.includes(label)); 114 + } 74 115 75 116 export { default as Post } from './Post.svelte';
+41 -7
packages/social/src/lib/components/post/embeds/Embed.svelte
··· 1 1 <script lang="ts"> 2 - import type { PostEmbed } from '../'; 2 + import type { PostEmbed, PostImageData, PostData, QuotedPostData } from '../'; 3 3 import External from './External.svelte'; 4 4 import Images from './Images.svelte'; 5 5 import Video from './Video.svelte'; 6 + import QuotedPost from './QuotedPost.svelte'; 6 7 7 - const { embed }: { embed: PostEmbed } = $props(); 8 + const { 9 + embed, 10 + showImages = true, 11 + showExternal = true, 12 + showVideo = true, 13 + showQuotes = true, 14 + onclickimage, 15 + onclickpost, 16 + onclickhandle 17 + }: { 18 + embed: PostEmbed; 19 + showImages?: boolean; 20 + showExternal?: boolean; 21 + showVideo?: boolean; 22 + showQuotes?: boolean; 23 + onclickimage?: (image: PostImageData) => void; 24 + onclickpost?: (data: PostData | QuotedPostData, href?: string) => void; 25 + onclickhandle?: (handle: string, href?: string) => void; 26 + } = $props(); 8 27 </script> 9 28 10 29 <div class="flex flex-col gap-2 pt-3 text-sm"> 11 - {#if embed.type === 'images'} 12 - <Images data={embed} /> 13 - {:else if embed.type === 'external' && embed.external} 30 + {#if embed.type === 'images' && showImages} 31 + <Images data={embed} {onclickimage} /> 32 + {:else if embed.type === 'external' && embed.external && showExternal} 14 33 <External data={embed} /> 15 - {:else if embed.type === 'video' && embed.video} 34 + {:else if embed.type === 'video' && embed.video && showVideo} 16 35 <Video data={embed} /> 36 + {:else if embed.type === 'record' && embed.record && showQuotes} 37 + <QuotedPost record={embed.record} {onclickpost} {onclickhandle} {onclickimage} /> 38 + {:else if embed.type === 'recordWithMedia' && embed.record} 39 + {#if embed.media} 40 + {#if embed.media.type === 'images' && showImages} 41 + <Images data={embed.media} {onclickimage} /> 42 + {:else if embed.media.type === 'external' && embed.media.external && showExternal} 43 + <External data={embed.media} /> 44 + {:else if embed.media.type === 'video' && embed.media.video && showVideo} 45 + <Video data={embed.media} /> 46 + {/if} 47 + {/if} 48 + {#if showQuotes} 49 + <QuotedPost record={embed.record} {onclickpost} {onclickhandle} {onclickimage} /> 50 + {/if} 17 51 {:else if embed.type === 'unknown'} 18 52 <div 19 53 class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm" 20 54 > 21 - Unknown embed type 55 + Unsupported embed type 22 56 </div> 23 57 {/if} 24 58 </div>
+4 -3
packages/social/src/lib/components/post/embeds/External.svelte
··· 3 3 4 4 const { data }: { data: PostEmbedExternal } = $props(); 5 5 6 + // svelte-ignore state_referenced_locally 6 7 const domain = new URL(data.external.href).hostname.replace('www.', ''); 7 8 </script> 8 9 9 10 <article 10 11 class={[ 11 - 'group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 max-w-md relative isolate flex flex-col justify-end overflow-hidden rounded-2xl border p-4', 12 - data.external.thumb ? 'aspect-[16/9]' : '' 12 + 'group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 relative isolate flex max-w-md flex-col justify-end overflow-hidden rounded-2xl border p-4', 13 + data.external.thumb ? 'aspect-video' : '' 13 14 ]} 14 15 > 15 16 {#if data.external.thumb} ··· 20 21 /> 21 22 {/if} 22 23 <div 23 - class="dark:from-base-950/90 dark:via-base-950/40 from-base-50/90 via-base-50/40 absolute inset-0 -z-10 bg-gradient-to-t" 24 + class="dark:from-base-950/90 dark:via-base-950/40 from-base-50/90 via-base-50/40 absolute inset-0 -z-10 bg-linear-to-t" 24 25 ></div> 25 26 26 27 <div
+36 -24
packages/social/src/lib/components/post/embeds/Images.svelte
··· 1 1 <script lang="ts"> 2 - import type { PostEmbedImage } from '..'; 2 + import type { PostEmbedImage, PostImageData } from '..'; 3 3 4 - const { data }: { data: PostEmbedImage } = $props(); 4 + const { 5 + data, 6 + onclickimage 7 + }: { data: PostEmbedImage; onclickimage?: (image: PostImageData) => void } = $props(); 5 8 </script> 6 9 7 - {#snippet imageSnippet( 8 - image: { 9 - alt: string; 10 - thumb: string; 11 - fullsize: string; 12 - aspectRatio?: { width: number; height: number }; 13 - }, 14 - className?: string 15 - )} 16 - <img 17 - loading="lazy" 18 - src={image.thumb} 19 - alt={image.alt} 20 - style={image.aspectRatio 21 - ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 22 - : 'aspect-ratio: 1 / 1'} 23 - class={[ 24 - 'border-base-500/20 dark:border-base-400/20 w-fit max-w-full rounded-2xl border max-h-[40rem] object-contain', 25 - className 26 - ]} 27 - /> 10 + {#snippet imageSnippet(image: PostImageData, className?: string)} 11 + {#if onclickimage} 12 + <button class="cursor-pointer" onclick={() => onclickimage(image)}> 13 + <img 14 + loading="lazy" 15 + src={image.thumb} 16 + alt={image.alt} 17 + style={image.aspectRatio 18 + ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 19 + : 'aspect-ratio: 1 / 1'} 20 + class={[ 21 + 'border-base-500/20 dark:border-base-400/20 accent:border-accent-900 max-h-[40rem] w-fit max-w-full rounded-2xl border object-contain', 22 + className 23 + ]} 24 + /> 25 + </button> 26 + {:else} 27 + <img 28 + loading="lazy" 29 + src={image.thumb} 30 + alt={image.alt} 31 + style={image.aspectRatio 32 + ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 33 + : 'aspect-ratio: 1 / 1'} 34 + class={[ 35 + 'border-base-500/20 dark:border-base-400/20 accent:border-accent-900 max-h-[40rem] w-fit max-w-full rounded-2xl border object-contain', 36 + className 37 + ]} 38 + /> 39 + {/if} 28 40 {/snippet} 29 41 30 42 {#if data.images.length === 1} 31 43 {@render imageSnippet(data.images[0])} 32 44 {:else} 33 45 <div class="columns-2 gap-4"> 34 - {#each data.images as image} 46 + {#each data.images as image (image.thumb)} 35 47 {@render imageSnippet(image, 'mb-4')} 36 48 {/each} 37 49 </div>
+72
packages/social/src/lib/components/post/embeds/QuotedPost.svelte
··· 1 + <script lang="ts"> 2 + import type { QuotedPostData, PostData, PostImageData } from '..'; 3 + import Post from '../Post.svelte'; 4 + 5 + const { 6 + record, 7 + showEmbed = false, 8 + onclickpost, 9 + onclickhandle, 10 + onclickimage 11 + }: { 12 + record: QuotedPostData; 13 + showEmbed?: boolean; 14 + onclickpost?: (data: PostData | QuotedPostData, href?: string) => void; 15 + onclickhandle?: (handle: string, href?: string) => void; 16 + onclickimage?: (image: PostImageData) => void; 17 + } = $props(); 18 + 19 + const postData: PostData = $derived({ 20 + author: record.author, 21 + href: record.href, 22 + htmlContent: record.htmlContent, 23 + embed: record.embed, 24 + createdAt: record.createdAt ?? '', 25 + replyCount: 0, 26 + repostCount: 0, 27 + likeCount: 0 28 + }); 29 + </script> 30 + 31 + {#if onclickpost} 32 + <button 33 + class="border-base-300 dark:border-base-600/30 accent:border-accent-300/20 accent:bg-accent-100/10 bg-base-500/10 dark:bg-black/30 hover:bg-base-500/15 dark:hover:bg-black/40 w-full cursor-pointer overflow-hidden rounded-2xl border p-3 text-left text-sm transition-colors" 34 + onclick={() => onclickpost(record, record.href)} 35 + > 36 + <Post 37 + data={postData} 38 + compact 39 + showAvatar={false} 40 + showReply={false} 41 + showRepost={false} 42 + showLike={false} 43 + showBookmark={false} 44 + showImages={showEmbed} 45 + showExternal={showEmbed} 46 + showVideo={showEmbed} 47 + showQuotes={false} 48 + {onclickhandle} 49 + {onclickimage} 50 + /> 51 + </button> 52 + {:else} 53 + <div 54 + class="border-base-300 dark:border-base-600/30 accent:border-accent-300/20 accent:bg-accent-100/10 bg-base-500/10 dark:bg-black/30 overflow-hidden rounded-2xl border p-3 text-sm" 55 + > 56 + <Post 57 + data={postData} 58 + compact 59 + showAvatar={false} 60 + showReply={false} 61 + showRepost={false} 62 + showLike={false} 63 + showBookmark={false} 64 + showImages={showEmbed} 65 + showExternal={showEmbed} 66 + showVideo={showEmbed} 67 + showQuotes={false} 68 + {onclickhandle} 69 + {onclickimage} 70 + /> 71 + </div> 72 + {/if}
+6 -3
packages/social/src/lib/components/post/embeds/Video.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 - import Hls from 'hls.js'; 4 3 import type { PostEmbedVideo } from '..'; 5 4 6 5 const { data }: { data: PostEmbedVideo } = $props(); 7 6 8 7 onMount(async () => { 9 - const Plyr = (await import('plyr')).default; 8 + const [{ default: Plyr }, { default: Hls }] = await Promise.all([ 9 + import('plyr'), 10 + import('hls.js') 11 + ]); 12 + 10 13 if (Hls.isSupported()) { 11 - var hls = new Hls(); 14 + const hls = new Hls(); 12 15 hls.loadSource(data.video.playlist); 13 16 hls.attachMedia(element); 14 17 }
+1 -4
apps/docs/src/routes/(main)/components/social/nested-comments/+page.svelte
··· 1 1 <script lang="ts"> 2 - import type { 3 - FeedViewPost, 4 - ThreadViewPost 5 - } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 2 + import type { ThreadViewPost } from '@atcute/bluesky/types/app/feed/defs'; 6 3 import { blueskyPostToPostData, NestedComments, Post, type PostData } from '@foxui/all'; 7 4 import { onMount } from 'svelte'; 8 5
+13 -22
apps/docs/src/routes/(main)/components/social/post/Example-bluesky.svelte
··· 1 1 <script lang="ts"> 2 2 import { Box } from '@foxui/all'; 3 - import { BlueskyPost, blueskyPostToPostData, Post } from '@foxui/all'; 4 - import { BskyAgent } from '@atproto/api'; 3 + import { BlueskyPost, blueskyPostToPostData } from '@foxui/all'; 5 4 import { onMount } from 'svelte'; 6 - import type { FeedViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 5 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 7 6 8 - async function fetchLatestPosts(handle: string) { 9 - const agent = new BskyAgent({ 10 - service: 'https://public.api.bsky.app' 11 - }); 12 - 7 + async function fetchLatestPosts(actor: string) { 13 8 try { 14 - const did = await agent.resolveHandle({ handle }); 15 - console.log(did); 16 - const feed = await agent.getAuthorFeed({ 17 - actor: did.data.did, 18 - limit: 10 19 - }); 20 - 21 - console.log(feed); 22 - return feed.data.feed; 9 + const url = `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=10`; 10 + const res = await fetch(url); 11 + if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`); 12 + const data = await res.json(); 13 + return data.feed as { post: PostView; reason?: unknown }[]; 23 14 } catch (error) { 24 15 console.error('Error fetching posts:', error); 25 16 } 26 17 } 27 18 28 - let posts = $state<FeedViewPost[]>([]); 19 + let posts = $state<{ post: PostView; reason?: unknown }[]>([]); 29 20 onMount(async () => { 30 - let loadedPosts = await fetchLatestPosts('flo-bit.dev'); 21 + const loadedPosts = await fetchLatestPosts('did:plc:257wekqxg4hyapkq6k47igmp'); 31 22 if (loadedPosts) { 32 23 posts = loadedPosts; 33 24 } 34 25 }); 35 26 </script> 36 27 37 - <Box class="not-prose relative flex min-h-24 w-full flex-col gap-8"> 38 - {#each posts as post} 39 - <BlueskyPost feedViewPost={post} showLogo></BlueskyPost> 28 + <Box class="not-prose relative flex min-h-24 w-full flex-col gap-8 p-8"> 29 + {#each posts as { post, reason } (post.uri)} 30 + <BlueskyPost feedViewPost={post} {reason} showLogo showAvatar={false}></BlueskyPost> 40 31 {/each} 41 32 </Box>
+9 -2
apps/docs/src/routes/(main)/components/social/post/SocialMediaPost.md
··· 1 1 <script lang="ts"> 2 - import GithubCornerExample from './Example.svelte'; 2 + import PostExample from './Example.svelte'; 3 + import BlueskyExample from './Example-bluesky.svelte'; 3 4 </script> 4 5 5 6 # Social Media Post 6 7 7 8 ## Example 8 9 9 - <GithubCornerExample /> 10 + <PostExample /> 10 11 11 12 ## Usage 12 13 ··· 32 33 Hello, world! 33 34 </Post> 34 35 ``` 36 + 37 + ## Bluesky Posts 38 + 39 + Live posts loaded from the Bluesky API: 40 + 41 + <BlueskyExample />