an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app

added labelmerge useAutoLabels, improved useGetOneToOneState, expanded policy.ts

+2987 -321
+5
README.md
··· 5 5 6 6 huge thanks to [Microcosm](https://microcosm.blue/) for making this possible 7 7 8 + issue tracker kanban board: [https://github.com/users/rimar1337/projects/1/views/1]https://github.com/users/rimar1337/projects/1/views/1 9 + 8 10 ## running dev and build 9 11 in the `vite.config.ts` file you should change these values 10 12 ```ts ··· 30 32 all core data fetching logic is now centralized in `src/utils/useQuery.ts` and exposed as a collection of custom react hooks. theres two basic types of custom hooks, the use-once, and the inifinite query ones (used for paginated requests like feed skeletons and listrecord) 31 33 32 34 ## UniversalPostRenderer 35 + > [!NOTE] 36 + > UPR is undergoing a refactor, so this info might be out of date 37 + 33 38 its a mega component rooted in my Masonry "[TestFront](https://testfront-87q.pages.dev/)" project. its goal is simple: have one component render everything. it has several shims to normalize different post data formats into a single format the component can handle. unlike TestFront, it has no animations, though some weird component splits might linger from the old version. 34 39 35 40 to adapt TestFront's bsky-api-based `UniversalPostRenderer` to Red Dwarf's model of fetching records directly from each user's PDS and then querying constellation for backlinks, i wrap it in `UniversalPostRendererATURILoader`, which handles raw record and backlink fetching. to bridge the gap between bsky api shapes like `PostView` and the raw record, i use `UniversalPostRendererRawRecordShim`. this way, the core `UniversalPostRenderer` remains the same between TestFront and Red Dwarf (with the only difference being in the red dwarf version the framer motion animations are removed).
+67 -25
package-lock.json
··· 6 6 "": { 7 7 "name": "red-dwarf-tanstack", 8 8 "dependencies": { 9 - "@atproto/api": "^0.16.6", 9 + "@atproto/api": "^0.18.20", 10 10 "@atproto/common-web": "^0.4.11", 11 11 "@atproto/oauth-client-browser": "^0.3.33", 12 12 "@radix-ui/react-dialog": "^1.1.15", ··· 21 21 "@tanstack/react-router": "^1.130.2", 22 22 "@tanstack/react-router-devtools": "^1.131.5", 23 23 "@tanstack/router-plugin": "^1.121.2", 24 + "@yornaath/batshit": "^0.14.0", 24 25 "dompurify": "^3.3.0", 25 26 "html-to-image": "^1.11.13", 26 27 "i": "^0.3.7", ··· 206 207 "license": "ISC" 207 208 }, 208 209 "node_modules/@atproto/api": { 209 - "version": "0.16.6", 210 - "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.6.tgz", 211 - "integrity": "sha512-/ZDWeHNAMmQFicyITAoGCxQujDU+wyzsWjpPnQfVa+ZKMfZEngipMFOr4fBwxHixEFLmuh58+5vJyylAFVrQ4g==", 210 + "version": "0.18.20", 211 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.20.tgz", 212 + "integrity": "sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw==", 212 213 "license": "MIT", 213 214 "dependencies": { 214 - "@atproto/common-web": "^0.4.2", 215 - "@atproto/lexicon": "^0.5.0", 216 - "@atproto/syntax": "^0.4.1", 217 - "@atproto/xrpc": "^0.7.4", 215 + "@atproto/common-web": "^0.4.15", 216 + "@atproto/lexicon": "^0.6.1", 217 + "@atproto/syntax": "^0.4.3", 218 + "@atproto/xrpc": "^0.7.7", 218 219 "await-lock": "^2.2.2", 219 220 "multiformats": "^9.9.0", 220 221 "tlds": "^1.234.0", 221 222 "zod": "^3.23.8" 222 223 } 223 224 }, 225 + "node_modules/@atproto/api/node_modules/@atproto/lexicon": { 226 + "version": "0.6.1", 227 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz", 228 + "integrity": "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==", 229 + "license": "MIT", 230 + "dependencies": { 231 + "@atproto/common-web": "^0.4.13", 232 + "@atproto/syntax": "^0.4.3", 233 + "iso-datestring-validator": "^2.2.2", 234 + "multiformats": "^9.9.0", 235 + "zod": "^3.23.8" 236 + } 237 + }, 238 + "node_modules/@atproto/api/node_modules/@atproto/xrpc": { 239 + "version": "0.7.7", 240 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", 241 + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", 242 + "license": "MIT", 243 + "dependencies": { 244 + "@atproto/lexicon": "^0.6.0", 245 + "zod": "^3.23.8" 246 + } 247 + }, 224 248 "node_modules/@atproto/common-web": { 225 - "version": "0.4.11", 226 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.11.tgz", 227 - "integrity": "sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ==", 249 + "version": "0.4.16", 250 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.16.tgz", 251 + "integrity": "sha512-Ufvaff5JgxUyUyTAG0/3o7ltpy3lnZ1DvLjyAnvAf+hHfiK7OMQg+8byr+orN+KP9MtIQaRTsCgYPX+PxMKUoA==", 228 252 "license": "MIT", 229 253 "dependencies": { 230 - "@atproto/lex-data": "0.0.7", 231 - "@atproto/lex-json": "0.0.7", 254 + "@atproto/lex-data": "^0.0.11", 255 + "@atproto/lex-json": "^0.0.11", 256 + "@atproto/syntax": "^0.4.3", 232 257 "zod": "^3.23.8" 233 258 } 234 259 }, ··· 273 298 } 274 299 }, 275 300 "node_modules/@atproto/lex-data": { 276 - "version": "0.0.7", 277 - "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.7.tgz", 278 - "integrity": "sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw==", 301 + "version": "0.0.11", 302 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.11.tgz", 303 + "integrity": "sha512-4+KTtHdqwlhiTKA7D4SACea4jprsNpCQsNALW09wsZ6IHhCDGO5tr1cmV+QnLYe3G3mu1E1yXHXbPUHrUUDT/A==", 279 304 "license": "MIT", 280 305 "dependencies": { 281 - "@atproto/syntax": "0.4.2", 282 306 "multiformats": "^9.9.0", 283 307 "tslib": "^2.8.1", 284 308 "uint8arrays": "3.0.0", ··· 286 310 } 287 311 }, 288 312 "node_modules/@atproto/lex-json": { 289 - "version": "0.0.7", 290 - "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.7.tgz", 291 - "integrity": "sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g==", 313 + "version": "0.0.11", 314 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.11.tgz", 315 + "integrity": "sha512-2IExAoQ4KsR5fyPa1JjIvtR316PvdgRH/l3BVGLBd3cSxM3m5MftIv1B6qZ9HjNiK60SgkWp0mi9574bTNDhBQ==", 292 316 "license": "MIT", 293 317 "dependencies": { 294 - "@atproto/lex-data": "0.0.7", 318 + "@atproto/lex-data": "^0.0.11", 295 319 "tslib": "^2.8.1" 296 320 } 297 321 }, ··· 358 382 } 359 383 }, 360 384 "node_modules/@atproto/syntax": { 361 - "version": "0.4.2", 362 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 363 - "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 364 - "license": "MIT" 385 + "version": "0.4.3", 386 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 387 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 388 + "license": "MIT", 389 + "dependencies": { 390 + "tslib": "^2.8.1" 391 + } 365 392 }, 366 393 "node_modules/@atproto/xrpc": { 367 394 "version": "0.7.5", ··· 5206 5233 "funding": { 5207 5234 "url": "https://opencollective.com/vitest" 5208 5235 } 5236 + }, 5237 + "node_modules/@yornaath/batshit": { 5238 + "version": "0.14.0", 5239 + "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.14.0.tgz", 5240 + "integrity": "sha512-0I+xMi5JoRs3+qVXXhk2AmsEl43MwrG+L+VW+nqw/qQqMFtgRPszLaxhJCfsBKnjfJ0gJzTI1Q9Q9+y903HyHQ==", 5241 + "license": "MIT", 5242 + "dependencies": { 5243 + "@yornaath/batshit-devtools": "^1.7.1" 5244 + } 5245 + }, 5246 + "node_modules/@yornaath/batshit-devtools": { 5247 + "version": "1.7.1", 5248 + "resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz", 5249 + "integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==", 5250 + "license": "MIT" 5209 5251 }, 5210 5252 "node_modules/acorn": { 5211 5253 "version": "8.15.0",
+2 -1
package.json
··· 10 10 "test": "vitest run" 11 11 }, 12 12 "dependencies": { 13 - "@atproto/api": "^0.16.6", 13 + "@atproto/api": "^0.18.20", 14 14 "@atproto/common-web": "^0.4.11", 15 15 "@atproto/oauth-client-browser": "^0.3.33", 16 16 "@radix-ui/react-dialog": "^1.1.15", ··· 25 25 "@tanstack/react-router": "^1.130.2", 26 26 "@tanstack/react-router-devtools": "^1.131.5", 27 27 "@tanstack/router-plugin": "^1.121.2", 28 + "@yornaath/batshit": "^0.14.0", 28 29 "dompurify": "^3.3.0", 29 30 "html-to-image": "^1.11.13", 30 31 "i": "^0.3.7",
+97 -7
policy.ts
··· 1 1 // please change the branding if you are not it hosting on reddwarf.app 2 - export const HOST_TITLE = "Red Dwarf" 2 + export const HOST_MAIN_TITLE = "Red Dwarf" // large text in branding 3 + export const HOST_SUB_TITLE = " .app" // smaller text in branding 4 + export const HOST_TITLE = HOST_MAIN_TITLE + HOST_SUB_TITLE // composite used in paragraphs 3 5 // also replace favicon files and defaultpfp.png and check LogoSvg.tsx 4 - // todo generate manifest.json and index.html from this file 5 - // todo have the bottom left and right blurbs on the desktop (should move it to settings for mobile) also customizable 6 + export const HOST_LOGO_USE_FAVICON = false; // ignores LogoSvg.tsx (recolorable svg) for a static image (the favicon) 7 + export const HOST_DEFAULT_HUE = 28; // default is 28 for red. mod 360. 294 is a nice purple 8 + export const HOST_HERO = "/sunset.jpg" // path to the "banner" image of the instance 9 + export const HOST_ADMIN = "did:plc:tufumi46dykq4fzwtp2ur6kx" // did of the owner/admin, does not give special perms 10 + export const HOST_DESCRIPTION = "The official flagship hosted Red Dwarf instance, hosted on reddwarf.app, running the latest updates and features" // short 1 sentence description 11 + /** 12 + * --- RED DWARF POLICY.TS — MARKDOWN FLAVOR --- 13 + * 14 + * This file uses a deliberately minimal Markdown subset. 15 + * 16 + * Supported syntax: 17 + * - Two consecutive line breaks create a new paragraph 18 + * - `##` denotes a collapsible section heading 19 + * - links via [link text](link url) 20 + * - Self-closing predefined components (e.g. `<PolicyViewer />`) 21 + * 22 + * REQUIRED COMPONENTS (strictly one of each): 23 + * - <PolicyViewer /> 24 + * 25 + * NOTE: 26 + * If the app detects that any required custom component is missing, 27 + * the application will refuse to run. Treat this file as critical. 28 + */ 29 + export const HOST_ABOUT_MARKDOWN = ` 30 + ## About this instance 31 + 32 + reddwarf.app is the flagship hosted instance of Red Dwarf, a Bluesky application built to provide an independent social experience. 33 + This hosted instance mandates Bluesky Moderation to limit moderation scope and keep resources focused on developing Red Dwarf software. 34 + 35 + 36 + ## About Red Dwarf 37 + 38 + Red Dwarf is a Bluesky client that does not rely on Bluesky API App Servers. 39 + Instead, it uses Microcosm to fetch records directly from each user’s PDS (via Slingshot) 40 + and connect them using backlinks (via Constellation). 41 + 42 + ## Hosting Your Own Instance 43 + 44 + Red Dwarf is open source. You can host your own instance specifically tailored to your community. 45 + Hosting your own instance gives you full control over policy, branding, and additional features. 46 + 47 + Repository: [https://tangled.org/whey.party/red-dwarf](https://tangled.org/whey.party/red-dwarf) 48 + 49 + Instructions for setup, configuration, and labeler policies are included in the repository. 50 + 51 + ## RedDwarf.app Policy 6 52 53 + <PolicyViewer /> 54 + ` 55 + 56 + export const HOST_UNAUTHED_DEFAULT_FEEDS = [ 57 + "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot" 58 + ] 59 + 60 + export const HOST_LOGIN_BLURB = "Experience Bluesky under a different light" //todo dont be corny 61 + export const HOST_SIGNUP_PDS = false // false-able string 62 + 63 + // very important. if this is empty the app will refuse to load anything 64 + // because this powers all the labels in the app (assuming youre using the newer useAutoLabels and not useModeration) 65 + export const HOST_LABELMERGE = "https://labelmerge.reddwarf.app" 66 + 67 + 68 + // forced label providers 69 + // applies to everyone 7 70 export const FORCED_LABELER_DIDS = [ 8 71 "did:plc:ar7c4by46qjdydhdevvrndac" // bluesky moderation 9 72 ]; 10 73 74 + 75 + 76 + // unauthed forced label policy 77 + // hides some content only when not logged in 78 + // needs labelers to be set in FORCED_LABELER_DIDS 79 + // TODO: add separate unauthed_forced_labeler_dids later 80 + 11 81 export const UNAUTHED_FORCE_WARN_LABELS = new Set([ 12 - // i dont know if some of these are even valid labels 13 82 "porn", 14 83 "sexual", 15 84 "graphic-media", 16 85 "nudity", 17 86 "nsfl", 18 - "corpse", 19 87 "gore", 20 - "!no-unauthenticated" 88 + "!no-unauthenticated", 89 + "illicit", 90 + "self-harm", 91 + "sensitive", 21 92 ]); 22 93 23 - export const UNAUTHED_PREVENT_OPENING_WARNS = true; 94 + export const UNAUTHED_PREVENT_OPENING_WARNS = true; 95 + 96 + 97 + 98 + // forced label policy 99 + // hides content labeled with FORCE_HIDE_LABELS 100 + // needs labelers to be set in FORCED_LABELER_DIDS and FORCE_HIDE_LABELS_WHITELISTED_SOURCE 101 + 102 + export const FORCE_HIDE_LABELS_WHITELISTED_SOURCE = new Set([ 103 + "did:plc:ar7c4by46qjdydhdevvrndac" // bluesky moderation 104 + ]) 105 + 106 + export const FORCE_HIDE_LABELS = new Set([ 107 + "!takedown", 108 + "!hide", 109 + ]); 110 + 111 + 112 + 113 + // todo generate manifest.json and index.html from this file
public/sunset.jpg

This is a binary file and will not be displayed.

+72
src/api/labelmerge/index.ts
··· 1 + /* eslint-disable unused-imports/no-unused-imports */ 2 + /* eslint-disable simple-import-sort/imports */ 3 + /** 4 + * GENERATED CODE - DO NOT MODIFY 5 + */ 6 + import { 7 + XrpcClient, 8 + type FetchHandler, 9 + type FetchHandlerOptions, 10 + } from '@atproto/xrpc' 11 + import { schemas } from './lexicons.js' 12 + // import { CID } from 'multiformats/cid' 13 + import { type OmitKey, type Un$Typed } from './util.js' 14 + import * as AppReddwarfLabelmergeQueryLabels from './types/app/reddwarf/labelmerge/queryLabels.js' 15 + import * as ComAtprotoLabelDefs from './types/com/atproto/label/defs.js' 16 + 17 + export * as AppReddwarfLabelmergeQueryLabels from './types/app/reddwarf/labelmerge/queryLabels.js' 18 + export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs.js' 19 + 20 + export class AtpBaseClient extends XrpcClient { 21 + app: AppNS 22 + 23 + constructor(options: FetchHandler | FetchHandlerOptions) { 24 + super(options, schemas) 25 + this.app = new AppNS(this) 26 + } 27 + 28 + /** @deprecated use `this` instead */ 29 + get xrpc(): XrpcClient { 30 + return this 31 + } 32 + } 33 + 34 + export class AppNS { 35 + _client: XrpcClient 36 + reddwarf: AppReddwarfNS 37 + 38 + constructor(client: XrpcClient) { 39 + this._client = client 40 + this.reddwarf = new AppReddwarfNS(client) 41 + } 42 + } 43 + 44 + export class AppReddwarfNS { 45 + _client: XrpcClient 46 + labelmerge: AppReddwarfLabelmergeNS 47 + 48 + constructor(client: XrpcClient) { 49 + this._client = client 50 + this.labelmerge = new AppReddwarfLabelmergeNS(client) 51 + } 52 + } 53 + 54 + export class AppReddwarfLabelmergeNS { 55 + _client: XrpcClient 56 + 57 + constructor(client: XrpcClient) { 58 + this._client = client 59 + } 60 + 61 + queryLabels( 62 + params?: AppReddwarfLabelmergeQueryLabels.QueryParams, 63 + opts?: AppReddwarfLabelmergeQueryLabels.CallOptions, 64 + ): Promise<AppReddwarfLabelmergeQueryLabels.Response> { 65 + return this._client.call( 66 + 'app.reddwarf.labelmerge.queryLabels', 67 + params, 68 + undefined, 69 + opts, 70 + ) 71 + } 72 + }
+302
src/api/labelmerge/lexicons.ts
··· 1 + /* eslint-disable unused-imports/no-unused-imports */ 2 + /* eslint-disable simple-import-sort/imports */ 3 + /** 4 + * GENERATED CODE - DO NOT MODIFY 5 + */ 6 + import { 7 + type LexiconDoc, 8 + Lexicons, 9 + ValidationError, 10 + type ValidationResult, 11 + } from '@atproto/lexicon' 12 + import { type $Typed, is$typed, maybe$typed } from './util.js' 13 + 14 + export const schemaDict = { 15 + AppReddwarfLabelmergeQueryLabels: { 16 + lexicon: 1, 17 + id: 'app.reddwarf.labelmerge.queryLabels', 18 + defs: { 19 + main: { 20 + type: 'query', 21 + description: 22 + 'Find labels relevant to the provided AT-URI patterns. Public endpoint for moderation services, though may return different or additional results with auth.', 23 + parameters: { 24 + type: 'params', 25 + properties: { 26 + s: { 27 + type: 'array', 28 + items: { 29 + type: 'string', 30 + }, 31 + description: 'List of label subjects (strings).', 32 + }, 33 + l: { 34 + type: 'array', 35 + items: { 36 + type: 'string', 37 + format: 'did', 38 + }, 39 + description: 'List of label sources (labeler DIDs) to filter on.', 40 + }, 41 + strict: { 42 + type: 'boolean', 43 + description: 44 + 'If true then any errors will throw the entire query', 45 + }, 46 + }, 47 + required: ['s', 'l'], 48 + }, 49 + output: { 50 + encoding: 'application/json', 51 + schema: { 52 + type: 'object', 53 + properties: { 54 + labels: { 55 + type: 'array', 56 + items: { 57 + type: 'ref', 58 + ref: 'lex:com.atproto.label.defs#label', 59 + }, 60 + }, 61 + error: { 62 + type: 'array', 63 + items: { 64 + type: 'ref', 65 + ref: 'lex:app.reddwarf.labelmerge.queryLabels#error', 66 + }, 67 + }, 68 + }, 69 + required: ['labels'], 70 + }, 71 + }, 72 + }, 73 + error: { 74 + type: 'object', 75 + properties: { 76 + s: { 77 + type: 'string', 78 + format: 'did', 79 + }, 80 + e: { 81 + type: 'string', 82 + }, 83 + }, 84 + required: ['s'], 85 + }, 86 + }, 87 + }, 88 + ComAtprotoLabelDefs: { 89 + id: 'com.atproto.label.defs', 90 + defs: { 91 + label: { 92 + type: 'object', 93 + required: ['src', 'uri', 'val', 'cts'], 94 + properties: { 95 + cid: { 96 + type: 'string', 97 + format: 'cid', 98 + description: 99 + "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", 100 + }, 101 + cts: { 102 + type: 'string', 103 + format: 'datetime', 104 + description: 'Timestamp when this label was created.', 105 + }, 106 + exp: { 107 + type: 'string', 108 + format: 'datetime', 109 + description: 110 + 'Timestamp at which this label expires (no longer applies).', 111 + }, 112 + neg: { 113 + type: 'boolean', 114 + description: 115 + 'If true, this is a negation label, overwriting a previous label.', 116 + }, 117 + sig: { 118 + type: 'bytes', 119 + description: 'Signature of dag-cbor encoded label.', 120 + }, 121 + src: { 122 + type: 'string', 123 + format: 'did', 124 + description: 'DID of the actor who created this label.', 125 + }, 126 + uri: { 127 + type: 'string', 128 + format: 'uri', 129 + description: 130 + 'AT URI of the record, repository (account), or other resource that this label applies to.', 131 + }, 132 + val: { 133 + type: 'string', 134 + maxLength: 128, 135 + description: 136 + 'The short string name of the value or type of this label.', 137 + }, 138 + ver: { 139 + type: 'integer', 140 + description: 'The AT Protocol version of the label object.', 141 + }, 142 + }, 143 + description: 144 + 'Metadata tag on an atproto resource (eg, repo or record).', 145 + }, 146 + selfLabel: { 147 + type: 'object', 148 + required: ['val'], 149 + properties: { 150 + val: { 151 + type: 'string', 152 + maxLength: 128, 153 + description: 154 + 'The short string name of the value or type of this label.', 155 + }, 156 + }, 157 + description: 158 + 'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.', 159 + }, 160 + labelValue: { 161 + type: 'string', 162 + knownValues: [ 163 + '!hide', 164 + '!no-promote', 165 + '!warn', 166 + '!no-unauthenticated', 167 + 'dmca-violation', 168 + 'doxxing', 169 + 'porn', 170 + 'sexual', 171 + 'nudity', 172 + 'nsfl', 173 + 'gore', 174 + ], 175 + }, 176 + selfLabels: { 177 + type: 'object', 178 + required: ['values'], 179 + properties: { 180 + values: { 181 + type: 'array', 182 + items: { 183 + ref: 'lex:com.atproto.label.defs#selfLabel', 184 + type: 'ref', 185 + }, 186 + maxLength: 10, 187 + }, 188 + }, 189 + description: 190 + 'Metadata tags on an atproto record, published by the author within the record.', 191 + }, 192 + labelValueDefinition: { 193 + type: 'object', 194 + required: ['identifier', 'severity', 'blurs', 'locales'], 195 + properties: { 196 + blurs: { 197 + type: 'string', 198 + description: 199 + "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 200 + knownValues: ['content', 'media', 'none'], 201 + }, 202 + locales: { 203 + type: 'array', 204 + items: { 205 + ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings', 206 + type: 'ref', 207 + }, 208 + }, 209 + severity: { 210 + type: 'string', 211 + description: 212 + "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 213 + knownValues: ['inform', 'alert', 'none'], 214 + }, 215 + adultOnly: { 216 + type: 'boolean', 217 + description: 218 + 'Does the user need to have adult content enabled in order to configure this label?', 219 + }, 220 + identifier: { 221 + type: 'string', 222 + maxLength: 100, 223 + description: 224 + "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 225 + maxGraphemes: 100, 226 + }, 227 + defaultSetting: { 228 + type: 'string', 229 + default: 'warn', 230 + description: 'The default setting for this label.', 231 + knownValues: ['ignore', 'warn', 'hide'], 232 + }, 233 + }, 234 + description: 235 + 'Declares a label value and its expected interpretations and behaviors.', 236 + }, 237 + labelValueDefinitionStrings: { 238 + type: 'object', 239 + required: ['lang', 'name', 'description'], 240 + properties: { 241 + lang: { 242 + type: 'string', 243 + format: 'language', 244 + description: 245 + 'The code of the language these strings are written in.', 246 + }, 247 + name: { 248 + type: 'string', 249 + maxLength: 640, 250 + description: 'A short human-readable name for the label.', 251 + maxGraphemes: 64, 252 + }, 253 + description: { 254 + type: 'string', 255 + maxLength: 100000, 256 + description: 257 + 'A longer description of what the label means and why it might be applied.', 258 + maxGraphemes: 10000, 259 + }, 260 + }, 261 + description: 262 + 'Strings which describe the label in the UI, localized into a specific language.', 263 + }, 264 + }, 265 + lexicon: 1, 266 + }, 267 + } as const satisfies Record<string, LexiconDoc> 268 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 269 + export const lexicons: Lexicons = new Lexicons(schemas) 270 + 271 + export function validate<T extends { $type: string }>( 272 + v: unknown, 273 + id: string, 274 + hash: string, 275 + requiredType: true, 276 + ): ValidationResult<T> 277 + export function validate<T extends { $type?: string }>( 278 + v: unknown, 279 + id: string, 280 + hash: string, 281 + requiredType?: false, 282 + ): ValidationResult<T> 283 + export function validate( 284 + v: unknown, 285 + id: string, 286 + hash: string, 287 + requiredType?: boolean, 288 + ): ValidationResult { 289 + return (requiredType ? is$typed : maybe$typed)(v, id, hash) 290 + ? lexicons.validate(`${id}#${hash}`, v) 291 + : { 292 + success: false, 293 + error: new ValidationError( 294 + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 295 + ), 296 + } 297 + } 298 + 299 + export const ids = { 300 + AppReddwarfLabelmergeQueryLabels: 'app.reddwarf.labelmerge.queryLabels', 301 + ComAtprotoLabelDefs: 'com.atproto.label.defs', 302 + } as const
+65
src/api/labelmerge/types/app/reddwarf/labelmerge/queryLabels.ts
··· 1 + /* eslint-disable unused-imports/no-unused-imports */ 2 + /* eslint-disable simple-import-sort/imports */ 3 + /** 4 + * GENERATED CODE - DO NOT MODIFY 5 + */ 6 + import { type HeadersMap, XRPCError } from '@atproto/xrpc' 7 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 8 + // import { CID } from 'multiformats/cid' 9 + import { validate as _validate } from '../../../../lexicons' 10 + import { 11 + type $Typed, 12 + is$typed as _is$typed, 13 + type OmitKey, 14 + } from '../../../../util' 15 + import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 16 + 17 + const is$typed = _is$typed, 18 + validate = _validate 19 + const id = 'app.reddwarf.labelmerge.queryLabels' 20 + 21 + export type QueryParams = { 22 + /** List of label subjects (strings). */ 23 + s: string[] 24 + /** List of label sources (labeler DIDs) to filter on. */ 25 + l: string[] 26 + /** If true then any errors will throw the entire query */ 27 + strict?: boolean 28 + } 29 + export type InputSchema = undefined 30 + 31 + export interface OutputSchema { 32 + labels: ComAtprotoLabelDefs.Label[] 33 + error?: Error[] 34 + } 35 + 36 + export interface CallOptions { 37 + signal?: AbortSignal 38 + headers?: HeadersMap 39 + } 40 + 41 + export interface Response { 42 + success: boolean 43 + headers: HeadersMap 44 + data: OutputSchema 45 + } 46 + 47 + export function toKnownErr(e: any) { 48 + return e 49 + } 50 + 51 + export interface Error { 52 + $type?: 'app.reddwarf.labelmerge.queryLabels#error' 53 + s: string 54 + e?: string 55 + } 56 + 57 + const hashError = 'error' 58 + 59 + export function isError<V>(v: V) { 60 + return is$typed(v, id, hashError) 61 + } 62 + 63 + export function validateError<V>(v: V) { 64 + return validate<Error & V>(v, id, hashError) 65 + }
+148
src/api/labelmerge/types/com/atproto/label/defs.ts
··· 1 + /* eslint-disable unused-imports/no-unused-imports */ 2 + /* eslint-disable simple-import-sort/imports */ 3 + /** 4 + * GENERATED CODE - DO NOT MODIFY 5 + */ 6 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 7 + // import { CID } from 'multiformats/cid' 8 + import { validate as _validate } from '../../../../lexicons' 9 + import { 10 + type $Typed, 11 + is$typed as _is$typed, 12 + type OmitKey, 13 + } from '../../../../util' 14 + 15 + const is$typed = _is$typed, 16 + validate = _validate 17 + const id = 'com.atproto.label.defs' 18 + 19 + /** Metadata tag on an atproto resource (eg, repo or record). */ 20 + export interface Label { 21 + $type?: 'com.atproto.label.defs#label' 22 + /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 23 + cid?: string 24 + /** Timestamp when this label was created. */ 25 + cts: string 26 + /** Timestamp at which this label expires (no longer applies). */ 27 + exp?: string 28 + /** If true, this is a negation label, overwriting a previous label. */ 29 + neg?: boolean 30 + /** Signature of dag-cbor encoded label. */ 31 + sig?: Uint8Array 32 + /** DID of the actor who created this label. */ 33 + src: string 34 + /** AT URI of the record, repository (account), or other resource that this label applies to. */ 35 + uri: string 36 + /** The short string name of the value or type of this label. */ 37 + val: string 38 + /** The AT Protocol version of the label object. */ 39 + ver?: number 40 + } 41 + 42 + const hashLabel = 'label' 43 + 44 + export function isLabel<V>(v: V) { 45 + return is$typed(v, id, hashLabel) 46 + } 47 + 48 + export function validateLabel<V>(v: V) { 49 + return validate<Label & V>(v, id, hashLabel) 50 + } 51 + 52 + /** Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel. */ 53 + export interface SelfLabel { 54 + $type?: 'com.atproto.label.defs#selfLabel' 55 + /** The short string name of the value or type of this label. */ 56 + val: string 57 + } 58 + 59 + const hashSelfLabel = 'selfLabel' 60 + 61 + export function isSelfLabel<V>(v: V) { 62 + return is$typed(v, id, hashSelfLabel) 63 + } 64 + 65 + export function validateSelfLabel<V>(v: V) { 66 + return validate<SelfLabel & V>(v, id, hashSelfLabel) 67 + } 68 + 69 + export type LabelValue = 70 + | '!hide' 71 + | '!no-promote' 72 + | '!warn' 73 + | '!no-unauthenticated' 74 + | 'dmca-violation' 75 + | 'doxxing' 76 + | 'porn' 77 + | 'sexual' 78 + | 'nudity' 79 + | 'nsfl' 80 + | 'gore' 81 + | (string & {}) 82 + 83 + /** Metadata tags on an atproto record, published by the author within the record. */ 84 + export interface SelfLabels { 85 + $type?: 'com.atproto.label.defs#selfLabels' 86 + values: SelfLabel[] 87 + } 88 + 89 + const hashSelfLabels = 'selfLabels' 90 + 91 + export function isSelfLabels<V>(v: V) { 92 + return is$typed(v, id, hashSelfLabels) 93 + } 94 + 95 + export function validateSelfLabels<V>(v: V) { 96 + return validate<SelfLabels & V>(v, id, hashSelfLabels) 97 + } 98 + 99 + /** Declares a label value and its expected interpretations and behaviors. */ 100 + export interface LabelValueDefinition { 101 + $type?: 'com.atproto.label.defs#labelValueDefinition' 102 + /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ 103 + blurs: 'content' | 'media' | 'none' | (string & {}) 104 + locales: LabelValueDefinitionStrings[] 105 + /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */ 106 + severity: 'inform' | 'alert' | 'none' | (string & {}) 107 + /** Does the user need to have adult content enabled in order to configure this label? */ 108 + adultOnly?: boolean 109 + /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 110 + identifier: string 111 + /** The default setting for this label. */ 112 + defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {}) 113 + } 114 + 115 + const hashLabelValueDefinition = 'labelValueDefinition' 116 + 117 + export function isLabelValueDefinition<V>(v: V) { 118 + return is$typed(v, id, hashLabelValueDefinition) 119 + } 120 + 121 + export function validateLabelValueDefinition<V>(v: V) { 122 + return validate<LabelValueDefinition & V>(v, id, hashLabelValueDefinition) 123 + } 124 + 125 + /** Strings which describe the label in the UI, localized into a specific language. */ 126 + export interface LabelValueDefinitionStrings { 127 + $type?: 'com.atproto.label.defs#labelValueDefinitionStrings' 128 + /** The code of the language these strings are written in. */ 129 + lang: string 130 + /** A short human-readable name for the label. */ 131 + name: string 132 + /** A longer description of what the label means and why it might be applied. */ 133 + description: string 134 + } 135 + 136 + const hashLabelValueDefinitionStrings = 'labelValueDefinitionStrings' 137 + 138 + export function isLabelValueDefinitionStrings<V>(v: V) { 139 + return is$typed(v, id, hashLabelValueDefinitionStrings) 140 + } 141 + 142 + export function validateLabelValueDefinitionStrings<V>(v: V) { 143 + return validate<LabelValueDefinitionStrings & V>( 144 + v, 145 + id, 146 + hashLabelValueDefinitionStrings, 147 + ) 148 + }
+84
src/api/labelmerge/util.ts
··· 1 + /* eslint-disable unused-imports/no-unused-imports */ 2 + /* eslint-disable simple-import-sort/imports */ 3 + /** 4 + * GENERATED CODE - DO NOT MODIFY 5 + */ 6 + 7 + import { type ValidationResult } from '@atproto/lexicon' 8 + 9 + export type OmitKey<T, K extends keyof T> = { 10 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 11 + } 12 + 13 + export type $Typed<V, T extends string = string> = V & { $type: T } 14 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 15 + 16 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 17 + ? Id 18 + : `${Id}#${Hash}` 19 + 20 + function isObject<V>(v: V): v is V & object { 21 + return v != null && typeof v === 'object' 22 + } 23 + 24 + function is$type<Id extends string, Hash extends string>( 25 + $type: unknown, 26 + id: Id, 27 + hash: Hash, 28 + ): $type is $Type<Id, Hash> { 29 + return hash === 'main' 30 + ? $type === id 31 + : // $type === `${id}#${hash}` 32 + typeof $type === 'string' && 33 + $type.length === id.length + 1 + hash.length && 34 + $type.charCodeAt(id.length) === 35 /* '#' */ && 35 + $type.startsWith(id) && 36 + $type.endsWith(hash) 37 + } 38 + 39 + export type $TypedObject< 40 + V, 41 + Id extends string, 42 + Hash extends string, 43 + > = V extends { 44 + $type: $Type<Id, Hash> 45 + } 46 + ? V 47 + : V extends { $type?: string } 48 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 49 + ? V & { $type: T } 50 + : never 51 + : V & { $type: $Type<Id, Hash> } 52 + 53 + export function is$typed<V, Id extends string, Hash extends string>( 54 + v: V, 55 + id: Id, 56 + hash: Hash, 57 + ): v is $TypedObject<V, Id, Hash> { 58 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 59 + } 60 + 61 + export function maybe$typed<V, Id extends string, Hash extends string>( 62 + v: V, 63 + id: Id, 64 + hash: Hash, 65 + ): v is V & object & { $type?: $Type<Id, Hash> } { 66 + return ( 67 + isObject(v) && 68 + ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 69 + ) 70 + } 71 + 72 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 73 + export type ValidatorParam<V extends Validator> = 74 + V extends Validator<infer R> ? R : never 75 + 76 + /** 77 + * Utility function that allows to convert a "validate*" utility function into a 78 + * type predicate. 79 + */ 80 + export function asPredicate<V extends Validator>(validate: V) { 81 + return function <T>(v: T): v is T & ValidatorParam<V> { 82 + return validate(v).success 83 + } 84 + }
+2 -2
src/api/moderation.ts
··· 2 2 3 3 export const fetchLabelsBatch = async ( 4 4 serviceUrl: string, 5 - uris: string[], 5 + opaqueIdentifierStringsToBeModerated: string[], 6 6 ): Promise<QueryLabelsResponse> => { 7 7 const url = new URL(`${serviceUrl}/xrpc/com.atproto.label.queryLabels`); 8 - uris.forEach((uri) => url.searchParams.append("uriPatterns", uri)); 8 + opaqueIdentifierStringsToBeModerated.forEach((opaqueIdentifierStringToBeModerated) => url.searchParams.append("uriPatterns", opaqueIdentifierStringToBeModerated)); 9 9 10 10 // 1. Setup Timeout (5 seconds) 11 11 const controller = new AbortController();
+7
src/auto-imports.d.ts
··· 11 11 const IconMaterialSymbolsArrowBack: typeof import('~icons/material-symbols/arrow-back.jsx').default 12 12 const IconMaterialSymbolsHome: typeof import('~icons/material-symbols/home.jsx').default 13 13 const IconMaterialSymbolsHomeOutline: typeof import('~icons/material-symbols/home-outline.jsx').default 14 + const IconMaterialSymbolsInfo: typeof import('~icons/material-symbols/info.jsx').default 15 + const IconMaterialSymbolsInfoO: typeof import('~icons/material-symbols/info-o.jsx').default 16 + const IconMaterialSymbolsInfoOutline: typeof import('~icons/material-symbols/info-outline.jsx').default 17 + const IconMaterialSymbolsMoreVert: typeof import('~icons/material-symbols/more-vert.jsx').default 14 18 const IconMaterialSymbolsNotifications: typeof import('~icons/material-symbols/notifications.jsx').default 15 19 const IconMaterialSymbolsNotificationsOutline: typeof import('~icons/material-symbols/notifications-outline.jsx').default 20 + const IconMaterialSymbolsRssFeed: typeof import('~icons/material-symbols/rss-feed.jsx').default 21 + const IconMaterialSymbolsScanDeleteOutline: typeof import('~icons/material-symbols/scan-delete-outline.jsx').default 16 22 const IconMaterialSymbolsSearch: typeof import('~icons/material-symbols/search.jsx').default 17 23 const IconMaterialSymbolsSettings: typeof import('~icons/material-symbols/settings.jsx').default 18 24 const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default ··· 29 35 const IconMdiClockOutline: typeof import('~icons/mdi/clock-outline.jsx').default 30 36 const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 31 37 const IconMdiCommentOutline: typeof import('~icons/mdi/comment-outline.jsx').default 38 + const IconMdiElipsis: typeof import('~icons/mdi/elipsis.jsx').default 32 39 const IconMdiGlobe: typeof import('~icons/mdi/globe.jsx').default 33 40 const IconMdiLock: typeof import('~icons/mdi/lock.jsx').default 34 41 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
+241 -128
src/components/PostEmbeds.tsx
··· 19 19 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 20 20 21 21 import { PollEmbed } from "./PollComponents"; 22 - import { UniversalPostRenderer } from "./UniversalPostRenderer"; 22 + import { UniversalPostRenderer, UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 23 23 24 24 type Embed = 25 25 | AppBskyEmbedRecord.View ··· 27 27 | AppBskyEmbedVideo.View 28 28 | AppBskyEmbedExternal.View 29 29 | AppBskyEmbedRecordWithMedia.View 30 - | { $type: string; [k: string]: unknown }; 30 + | { $type: string;[k: string]: unknown }; 31 31 32 32 enum PostEmbedViewContext { 33 33 ThreadHighlighted = "ThreadHighlighted", ··· 55 55 nopics, 56 56 lightboxCallback, 57 57 constellationLinks, 58 + redactedLoading 58 59 }: { 59 60 embed?: Embed; 60 61 moderation?: ModerationDecision; ··· 67 68 nopics?: boolean; 68 69 lightboxCallback?: (d: LightboxProps) => void; 69 70 constellationLinks?: any; 71 + redactedLoading?: boolean; 70 72 }) { 71 73 function setLightboxIndex(number: number) { 72 74 navigate({ ··· 114 116 nopics={nopics} 115 117 lightboxCallback={lightboxCallback} 116 118 constellationLinks={constellationLinks} 119 + redactedLoading={redactedLoading} 117 120 /> 118 121 <div style={{ height: 12 }} /> 119 122 <div ··· 151 154 const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; 152 155 153 156 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 154 - return <div style={stopgap}>feedgen placeholder</div>; 157 + return <div style={stopgap} className={(redactedLoading ? " blur animate-pulse" : undefined)}>feedgen placeholder</div>; 155 158 } else if ( 156 159 !!reallybaduri && 157 160 !!reallybadaturi && 158 161 reallybadaturi.collection === "app.bsky.feed.generator" 159 162 ) { 160 163 return ( 161 - <div className="rounded-xl border"> 164 + <div className={`rounded-xl border` + (redactedLoading ? " blur animate-pulse" : undefined)}> 162 165 <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 163 166 </div> 164 167 ); 165 168 } 166 169 167 170 if (AppBskyGraphDefs.isListView(embed.record)) { 168 - return <div style={stopgap}>list placeholder</div>; 171 + return <div style={stopgap} className={(redactedLoading ? " blur animate-pulse" : undefined)}>list placeholder</div>; 169 172 } else if ( 170 173 !!reallybaduri && 171 174 !!reallybadaturi && 172 175 reallybadaturi.collection === "app.bsky.graph.list" 173 176 ) { 174 177 return ( 175 - <div className="rounded-xl border"> 178 + <div className={"rounded-xl border" + (redactedLoading ? " blur animate-pulse" : undefined)}> 176 179 <FeedItemRenderAturiLoader 177 180 aturi={reallybaduri} 178 181 disableBottomBorder ··· 184 187 } 185 188 186 189 if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { 187 - return <div style={stopgap}>starter pack card placeholder</div>; 190 + return <div style={stopgap} className={(redactedLoading ? " blur animate-pulse" : undefined)}>starter pack card placeholder</div>; 188 191 } else if ( 189 192 !!reallybaduri && 190 193 !!reallybadaturi && 191 194 reallybadaturi.collection === "app.bsky.graph.starterpack" 192 195 ) { 193 196 return ( 194 - <div className="rounded-xl border"> 197 + <div className={"rounded-xl border" + (redactedLoading ? " blur animate-pulse" : undefined)}> 195 198 <FeedItemRenderAturiLoader 196 199 aturi={reallybaduri} 197 200 disableBottomBorder ··· 229 232 borderRadius: 12, 230 233 overflow: "hidden", 231 234 }} 232 - className="shadow border border-gray-200 dark:border-gray-800 was7" 235 + className={"shadow border border-gray-200 dark:border-gray-800 was7" + (redactedLoading ? " blur animate-pulse" : undefined)} 233 236 > 234 237 <UniversalPostRenderer 235 238 post={post} ··· 249 252 /> 250 253 </div> 251 254 ); 255 + 256 + } if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { 257 + return ( 258 + <UniversalPostRendererATURILoader atUri={embed.record.uri} isQuote /> 259 + ) 252 260 } else { 253 261 console.log("what the hell is a ", embed); 254 262 return <>sorry</>; ··· 280 288 width: "100%", 281 289 aspectRatio: image.aspectRatio 282 290 ? (() => { 283 - const { width, height } = image.aspectRatio; 284 - const ratio = width / height; 285 - return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 286 - })() 291 + const { width, height } = image.aspectRatio; 292 + const ratio = width / height; 293 + return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 294 + })() 287 295 : "1 / 1", 288 296 borderRadius: 12, 289 297 overflow: "hidden", 290 298 }} 291 299 className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" 292 300 > 293 - <img 294 - src={image.fullsize} 295 - alt={image.alt} 296 - style={{ 297 - width: "100%", 298 - height: "100%", 299 - objectFit: "contain", 300 - }} 301 - onClick={(e) => { 302 - e.stopPropagation(); 303 - setLightboxIndex(0); 304 - }} 305 - /> 301 + {redactedLoading ? ( 302 + <div 303 + style={{ 304 + width: "100%", 305 + height: "100%", 306 + objectFit: "contain", 307 + }} 308 + className="bg-gray-300 dark:bg-gray-600 blur animate-pulse " 309 + /> 310 + ) : ( 311 + <img 312 + src={image.fullsize} 313 + alt={image.alt} 314 + style={{ 315 + width: "100%", 316 + height: "100%", 317 + objectFit: "contain", 318 + }} 319 + onClick={(e) => { 320 + e.stopPropagation(); 321 + setLightboxIndex(0); 322 + }} 323 + /> 324 + )} 306 325 </div> 307 326 </div> 308 327 ); ··· 326 345 key={i} 327 346 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 328 347 > 329 - <img 330 - src={img.fullsize} 331 - alt={img.alt} 332 - style={{ 333 - width: "100%", 334 - height: "100%", 335 - objectFit: "cover", 336 - borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 337 - }} 338 - onClick={(e) => { 339 - e.stopPropagation(); 340 - setLightboxIndex(i); 341 - }} 342 - /> 348 + {redactedLoading ? ( 349 + <div 350 + style={{ 351 + width: "100%", 352 + height: "100%", 353 + objectFit: "cover", 354 + borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 355 + }} 356 + className="bg-gray-300 dark:bg-gray-600 blur animate-pulse " 357 + /> 358 + ) : ( 359 + <img 360 + src={img.fullsize} 361 + alt={img.alt} 362 + style={{ 363 + width: "100%", 364 + height: "100%", 365 + objectFit: "cover", 366 + borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 367 + }} 368 + onClick={(e) => { 369 + e.stopPropagation(); 370 + setLightboxIndex(i); 371 + }} 372 + /> 373 + )} 343 374 </div> 344 375 ))} 345 376 </div> ··· 362 393 <div 363 394 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 364 395 > 365 - <img 366 - src={images[0].fullsize} 367 - alt={images[0].alt} 368 - style={{ 369 - width: "100%", 370 - height: "100%", 371 - objectFit: "cover", 372 - borderRadius: "12px 0 0 12px", 373 - }} 374 - onClick={(e) => { 375 - e.stopPropagation(); 376 - setLightboxIndex(0); 377 - }} 378 - /> 396 + {redactedLoading ? ( 397 + <div 398 + style={{ 399 + width: "100%", 400 + height: "100%", 401 + objectFit: "cover", 402 + borderRadius: "12px 0 0 12px", 403 + }} 404 + className="bg-gray-300 dark:bg-gray-600 blur animate-pulse " 405 + /> 406 + ) : ( 407 + <img 408 + src={images[0].fullsize} 409 + alt={images[0].alt} 410 + style={{ 411 + width: "100%", 412 + height: "100%", 413 + objectFit: "cover", 414 + borderRadius: "12px 0 0 12px", 415 + }} 416 + onClick={(e) => { 417 + e.stopPropagation(); 418 + setLightboxIndex(0); 419 + }} 420 + /> 421 + )} 379 422 </div> 380 423 <div 381 424 style={{ ··· 394 437 position: "relative", 395 438 }} 396 439 > 397 - <img 398 - src={images[i].fullsize} 399 - alt={images[i].alt} 400 - style={{ 401 - width: "100%", 402 - height: "100%", 403 - objectFit: "cover", 404 - borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 405 - }} 406 - onClick={(e) => { 407 - e.stopPropagation(); 408 - setLightboxIndex(i + 1); 409 - }} 410 - /> 440 + {redactedLoading ? ( 441 + <div 442 + style={{ 443 + width: "100%", 444 + height: "100%", 445 + objectFit: "cover", 446 + borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 447 + }} 448 + className="bg-gray-300 dark:bg-gray-600 blur animate-pulse " 449 + /> 450 + ) : ( 451 + <img 452 + src={images[i].fullsize} 453 + alt={images[i].alt} 454 + style={{ 455 + width: "100%", 456 + height: "100%", 457 + objectFit: "cover", 458 + borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 459 + }} 460 + onClick={(e) => { 461 + e.stopPropagation(); 462 + setLightboxIndex(i + 1); 463 + }} 464 + /> 465 + )} 411 466 </div> 412 467 ))} 413 468 </div> ··· 440 495 position: "relative", 441 496 }} 442 497 > 443 - <img 444 - src={img.fullsize} 445 - alt={img.alt} 446 - style={{ 447 - width: "100%", 448 - height: "100%", 449 - objectFit: "cover", 450 - borderRadius: 451 - i === 0 452 - ? "12px 0 0 0" 453 - : i === 1 454 - ? "0 12px 0 0" 455 - : i === 2 456 - ? "0 0 0 12px" 457 - : "0 0 12px 0", 458 - }} 459 - onClick={(e) => { 460 - e.stopPropagation(); 461 - setLightboxIndex(i); 462 - }} 463 - /> 498 + {redactedLoading ? ( 499 + <div 500 + style={{ 501 + width: "100%", 502 + height: "100%", 503 + objectFit: "cover", 504 + borderRadius: 505 + i === 0 506 + ? "12px 0 0 0" 507 + : i === 1 508 + ? "0 12px 0 0" 509 + : i === 2 510 + ? "0 0 0 12px" 511 + : "0 0 12px 0", 512 + }} 513 + className="bg-gray-300 dark:bg-gray-600 blur animate-pulse " 514 + /> 515 + ) : ( 516 + <img 517 + src={img.fullsize} 518 + alt={img.alt} 519 + style={{ 520 + width: "100%", 521 + height: "100%", 522 + objectFit: "cover", 523 + borderRadius: 524 + i === 0 525 + ? "12px 0 0 0" 526 + : i === 1 527 + ? "0 12px 0 0" 528 + : i === 2 529 + ? "0 0 0 12px" 530 + : "0 0 12px 0", 531 + }} 532 + onClick={(e) => { 533 + e.stopPropagation(); 534 + setLightboxIndex(i); 535 + }} 536 + /> 537 + )} 464 538 </div> 465 539 ))} 466 540 </div> ··· 476 550 const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0; 477 551 478 552 if (hasPollLink && postid) { 479 - return <PollEmbed did={postid.did} rkey={postid.rkey} />; 553 + // warning: i gave up and warpped it in a div lmao 554 + return ( 555 + <div className={(redactedLoading ? " blur animate-pulse " : undefined)}> 556 + <PollEmbed did={postid.did} rkey={postid.rkey} /> 557 + </div> 558 + ); 480 559 } 481 560 482 561 const link = embed.external; 483 562 return ( 484 - <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} /> 563 + <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading} /> 485 564 ); 486 565 } 487 566 ··· 493 572 url={playlist} 494 573 thumbnail={embed.thumbnail} 495 574 aspect={embed.aspectRatio} 575 + redactedLoading={redactedLoading} 496 576 /> 497 577 ); 498 578 } ··· 504 584 link, 505 585 onOpen, 506 586 style, 587 + redactedLoading 507 588 }: { 508 589 link: AppBskyEmbedExternal.ViewExternal; 509 590 onOpen?: () => void; 510 591 style?: React.CSSProperties; 592 + redactedLoading?: boolean; 511 593 }) { 512 594 const { uri, title, description, thumb } = link; 513 595 const thumbAspectRatio = 1.91; ··· 554 636 555 637 return ( 556 638 <a 557 - href={uri} 639 + href={redactedLoading ? undefined : uri} 558 640 target="_blank" 559 641 rel="noopener noreferrer" 560 642 onClick={(e) => { ··· 581 663 }} 582 664 className="border-b border-gray-200 dark:border-gray-800 was7" 583 665 > 584 - <img 585 - src={thumb} 586 - alt={description} 587 - style={{ 588 - position: "absolute", 589 - top: 0, 590 - left: 0, 591 - width: "100%", 592 - height: "100%", 593 - objectFit: "cover", 594 - }} 595 - /> 666 + {redactedLoading ? ( 667 + <div 668 + style={{ 669 + position: "absolute", 670 + top: 0, 671 + left: 0, 672 + width: "100%", 673 + height: "100%", 674 + objectFit: "cover", 675 + }} 676 + className="bg-gray-300 dark:bg-gray-600 blur animate-pulse " 677 + /> 678 + ) : ( 679 + <img 680 + src={thumb} 681 + alt={description} 682 + style={{ 683 + position: "absolute", 684 + top: 0, 685 + left: 0, 686 + width: "100%", 687 + height: "100%", 688 + objectFit: "cover", 689 + }} 690 + /> 691 + )} 596 692 </div> 597 693 )} 598 694 <div ··· 605 701 > 606 702 <div 607 703 style={titleStyle as React.CSSProperties} 608 - className="text-gray-900 dark:text-gray-100" 704 + className={"text-gray-900 dark:text-gray-100 " + (redactedLoading ? " blur animate-pulse " : undefined)} 609 705 > 610 706 {title} 611 707 </div> 612 708 <div 613 709 style={descriptionStyle as React.CSSProperties} 614 - className="text-gray-500 dark:text-gray-400" 710 + className={"text-gray-500 dark:text-gray-400 " + (redactedLoading ? " blur animate-pulse " : undefined)} 615 711 > 616 712 {description} 617 713 </div> ··· 629 725 gap: 4, 630 726 }} 631 727 > 632 - <IconMdiGlobe /> 728 + <div className={redactedLoading ? "blur animate-pulse" : undefined}> 729 + <IconMdiGlobe /> 730 + </div> 633 731 <span 634 732 style={{ 635 733 fontSize: 12, 636 734 }} 637 - className="text-gray-500 dark:text-gray-400" 735 + className={"text-gray-500 dark:text-gray-400 " + (redactedLoading ? " blur animate-pulse " : undefined)} 638 736 > 639 737 {getDomain(uri)} 640 738 </span> ··· 649 747 url, 650 748 thumbnail, 651 749 aspect, 750 + redactedLoading, 652 751 }: { 653 752 url: string; 654 753 thumbnail?: string; 655 754 aspect?: AppBskyEmbedDefs.AspectRatio; 755 + redactedLoading?: boolean; 656 756 }) => { 657 757 const [playing, setPlaying] = useState(false); 658 758 const containerRef = useRef(null); ··· 693 793 > 694 794 {!playing && ( 695 795 <> 696 - <img 697 - src={thumbnail} 698 - alt="Video thumbnail" 699 - style={{ 700 - width: "100%", 701 - display: "block", 702 - aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9, 703 - borderRadius: 12, 704 - }} 705 - className="border border-gray-200 dark:border-gray-800 was7" 706 - onClick={async (e) => { 707 - e.stopPropagation(); 708 - setPlaying(true); 709 - }} 710 - /> 796 + {redactedLoading ? ( 797 + <div 798 + style={{ 799 + width: "100%", 800 + display: "block", 801 + aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9, 802 + borderRadius: 12, 803 + }} 804 + className="border border-gray-200 dark:border-gray-800 was7 bg-gray-300 dark:bg-gray-600 blur animate-pulse " 805 + /> 806 + ) : ( 807 + <img 808 + src={thumbnail} 809 + alt="Video thumbnail" 810 + style={{ 811 + width: "100%", 812 + display: "block", 813 + aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9, 814 + borderRadius: 12, 815 + }} 816 + className="border border-gray-200 dark:border-gray-800 was7" 817 + onClick={async (e) => { 818 + e.stopPropagation(); 819 + if (redactedLoading) return; 820 + setPlaying(true); 821 + }} 822 + /> 823 + )} 711 824 <div 712 825 onClick={async (e) => { 713 826 e.stopPropagation(); 827 + if (redactedLoading) return; 714 828 setPlaying(true); 715 829 }} 716 830 style={{ ··· 722 836 pointerEvents: "none", 723 837 userSelect: "none", 724 838 }} 725 - className="text-shadow-md" 839 + //className="text-shadow-md" 726 840 > 727 - <IconMdiPlayCircle /> 841 + <IconMdiPlayCircle className="h-14 w-14 drop-shadow-xl drop-shadow-gray-950/10 text-gray-50" /> 728 842 </div> 729 843 </> 730 844 )} ··· 735 849 width: "100%", 736 850 borderRadius: 12, 737 851 overflow: "hidden", 738 - paddingTop: `${ 739 - 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 740 - }%`, 852 + paddingTop: `${100 / (aspect ? aspect.width / aspect.height : 16 / 9) 853 + }%`, 741 854 }} 742 855 className="border border-gray-200 dark:border-gray-800 was7" 743 856 >
+392 -65
src/components/UniversalPostRenderer.tsx
··· 15 15 import * as React from "react"; 16 16 import { useEffect, useState } from "react"; 17 17 18 - import { UNAUTHED_PREVENT_OPENING_WARNS } from "~/../policy"; 18 + import { FORCE_HIDE_LABELS, FORCE_HIDE_LABELS_WHITELISTED_SOURCE, UNAUTHED_PREVENT_OPENING_WARNS } from "~/../policy"; 19 19 import defaultpfp from "~/../public/defaultpfp.png"; 20 + import { getGetHydratedLabelDefs, useAutoLabels } from "~/hooks/useAutoLabels"; 20 21 import { useLabelInfo } from "~/hooks/useLabelInfo"; 21 - import { useModeration } from "~/hooks/useModeration"; 22 + //import { useModeration } from "~/hooks/useModeration"; 22 23 import { useAuth } from "~/providers/UnifiedAuthProvider"; 23 24 import { renderSnack } from "~/routes/__root"; 24 25 //import { ModerationInner } from "~/routes/moderation"; 25 - import { FollowButton, Mutual } from "~/routes/profile.$did"; 26 + import { FollowButton, getLocaleLabel, type LabelWithHydratedLocaleName, Mutual } from "~/routes/profile.$did"; 26 27 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 27 - import type { ContentLabel } from "~/types/moderation"; 28 + //import type { ContentLabel } from "~/types/moderation"; 28 29 import { 29 30 composerAtom, 30 31 constellationURLAtom, ··· 32 33 enableWafrnTextAtom, 33 34 imgCDNAtom, 34 35 } from "~/utils/atoms"; 36 + import { useGetOneToOneState } from "~/utils/followState"; 35 37 import { useFastLike } from "~/utils/likeMutationQueue"; 36 38 import { useHydratedEmbed } from "~/utils/useHydrated"; 37 39 import { ··· 206 208 } 207 209 })(); 208 210 209 - if (!postQuery?.value) { 210 - return <></>; 211 + // placeholder for when a post is missing 212 + if (!isPostLoading && !postQuery?.value || isPostError) { 213 + if (feedviewpost) { 214 + return null // if feed view post then missing post isnt important and just remove it from view 215 + } 216 + return ( 217 + <> 218 + {/* todo add reply lines here. */} 219 + {/* todo dont let the UPR render the shitty placeholder uri we received */} 220 + {/* <div className={`flex flex-row p-4 ${isQuote ? "border-gray-200 dark:border-gray-800 border-1 rounded-lg" : "border-gray-200 dark:border-gray-800 border-b"}`}> */} 221 + 222 + <div className={`flex flex-col gap-0 border-gray-200 dark:border-gray-800 ${bottomReplyLine ? "" : "border-b"}`}> 223 + <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4"> 224 + <div 225 + style={{ 226 + width: 2, 227 + height: 16, 228 + opacity: 0.5, 229 + }} 230 + className={`${topReplyLine ? "bg-gray-500 dark:bg-gray-400" : "bg-transparent"}`} 231 + /> 232 + </div> 233 + <div className="flex flex-row px-4"> 234 + <div className="flex flex-col gap-1 flex-1 rounded-lg py-3 px-4 bg-gray-200 dark:bg-gray-800"> 235 + <div className="flex flex-row flex-1 gap-2 rounded-lg bg-gray-200 dark:bg-gray-800 items-center"> 236 + <IconMaterialSymbolsScanDeleteOutline /> 237 + <span>Missing {isQuote ? "Quoted" : ""} Post</span> 238 + </div> 239 + </div> 240 + </div> 241 + 242 + <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4"> 243 + <div 244 + style={{ 245 + width: 2, 246 + height: 16, 247 + opacity: 0.5, 248 + }} 249 + // maxReplies === undefined to specifically prevent missing apost from threading down with more missings posts 250 + // shouldnt affect thread up (parent) or feed view. im pretty sure missinga post would cut off the thread 251 + className={`${bottomReplyLine && maxReplies === undefined ? "bg-gray-500 dark:bg-gray-400" : "bg-transparent"}`} 252 + /> 253 + </div> 254 + </div> 255 + </> 256 + ); 211 257 } 212 258 213 259 return ( ··· 512 558 bottomReplyLine={bottomReplyLine} 513 559 topReplyLine={topReplyLine} 514 560 bottomBorder={bottomBorder} 561 + feedviewpost={feedviewpost} 515 562 feedviewpostreplyhandle={feedviewpostreplyhandle} 516 563 repostedby={feedviewpostrepostedbyhandle} 517 564 style={style} ··· 540 587 topReplyLine, 541 588 salt, 542 589 bottomBorder = true, 590 + feedviewpost, 543 591 feedviewpostreplyhandle, 544 592 depth = 0, 545 593 repostedby, ··· 563 611 topReplyLine?: boolean; 564 612 salt: string; 565 613 bottomBorder?: boolean; 614 + feedviewpost?: boolean; 566 615 feedviewpostreplyhandle?: string; 567 616 depth?: number; 568 617 repostedby?: string; ··· 575 624 maxReplies?: number; 576 625 constellationLinks?: any; 577 626 }) { 578 - const { isLoading: authorModLoading, labels: authorLabels } = useModeration( 627 + 628 + // todo move moderation to one of the UniversalPostRenderer wrapper components, and not the pure renderer component. please. thanks 629 + // todo please move all moderation including labeling and blocks into a wrapper component please i beg you 630 + 631 + const subjects = [ 579 632 post.author.did, 580 - ); 581 - const { isLoading: contentModLoading, labels: contentLabels } = useModeration( 633 + `at://${post.author.did}/app.bsky.actor.profile/self`, 582 634 post.uri, 583 - ); 635 + ] 636 + 637 + const { 638 + results: labelResults, 639 + hydratedLabelDefs, 640 + } = useAutoLabels({ 641 + subjects, 642 + type: "post", // or whatever you’re keying on for now 643 + }) 644 + 645 + const ghld = getGetHydratedLabelDefs(hydratedLabelDefs) 646 + const accountResult = labelResults.get(post.author.did) 647 + const profileResult = labelResults.get( 648 + `at://${post.author.did}/app.bsky.actor.profile/self`, 649 + ) 650 + const postResult = labelResults.get(post.uri) 651 + 652 + const accountLabelVerdict = accountResult?.labelVerdict ?? "unknown" 653 + const authorLabels = accountResult?.labels ?? [] 654 + 655 + const profileLabelVerdict = profileResult?.labelVerdict ?? "unknown" 656 + const profileLabels = profileResult?.labels ?? [] 657 + 658 + const postLabelVerdict = postResult?.labelVerdict ?? "unknown" 659 + const contentLabels = postResult?.labels ?? [] 660 + 661 + const combinedLabels = [...authorLabels, ...profileLabels, ...contentLabels] 662 + 663 + const authorModUnknown = accountLabelVerdict === "unknown"; 664 + const profileModUnknown = profileLabelVerdict === "unknown"; 665 + const contentModUnknown = postLabelVerdict === "unknown"; 666 + 667 + const authorModLoading = accountLabelVerdict === "loading"; 668 + const profileModLoading = profileLabelVerdict === "loading"; 669 + const contentModLoading = postLabelVerdict === "loading"; 670 + 671 + const authorModError = accountLabelVerdict === "error"; 672 + const profileModError = profileLabelVerdict === "error"; 673 + const contentModError = postLabelVerdict === "error"; 674 + 675 + const verdictDebugString = `accountLabelVerdict: ${accountLabelVerdict}, profileLabelVerdict: ${profileLabelVerdict}, postLabelVerdict: ${postLabelVerdict}` 676 + //const verdictDebugStringCauses = 677 + 678 + const strictModerationUnknown = authorModUnknown || profileModUnknown || contentModUnknown 679 + const strictModerationLoading = authorModLoading || profileModLoading || contentModLoading 680 + const strictModerationError = authorModError || profileModError || contentModError 681 + 682 + const strictModerationDontShow = strictModerationUnknown || strictModerationLoading || strictModerationError 683 + 584 684 const hideAuthorLabels = authorLabels.filter( 585 - (label) => label.preference === "hide", 685 + (label) => ghld(label.src, label.val)?.pref === "hide", 586 686 ); 587 687 const warnAuthorLabels = authorLabels.filter( 588 - (label) => label.preference === "warn", 688 + (label) => ghld(label.src, label.val)?.pref === "warn", 689 + ); 690 + // const errorAuthorLabels = authorLabels.filter( 691 + // //(label) => ghld(label.src,label.val)?.severity === "hide", 692 + // ); 693 + const hideProfileLabels = profileLabels.filter( 694 + (label) => ghld(label.src, label.val)?.pref === "hide", 695 + ); 696 + const warnProfileLabels = profileLabels.filter( 697 + (label) => ghld(label.src, label.val)?.pref === "warn", 589 698 ); 590 699 const hideContentLabels = contentLabels.filter( 591 - (label) => label.preference === "hide", 700 + (label) => ghld(label.src, label.val)?.pref === "hide", 592 701 ); 593 702 const warnContentLabels = contentLabels.filter( 594 - (label) => label.preference === "warn", 703 + (label) => ghld(label.src, label.val)?.pref === "warn", 704 + ); 705 + 706 + const informCombinedLabels: LabelWithHydratedLocaleName[] = combinedLabels.flatMap( 707 + (label) => { 708 + if (ghld(label.src, label.val)?.severity === "inform" && ghld(label.src, label.val)?.pref === "warn") { 709 + return [{ 710 + ...label, 711 + name: getLocaleLabel(ghld(label.src, label.val))?.name || label.val 712 + }] 713 + } 714 + return [] 715 + }, 595 716 ); 596 717 597 718 const parsed = new AtUri(post.uri); ··· 606 727 ); 607 728 const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 608 729 730 + const agentDid = agent?.did; 731 + const authorDid = post.author.did; 732 + 733 + const userBlocksAuthor = useGetOneToOneState( 734 + agentDid && authorDid 735 + ? { 736 + target: authorDid, 737 + user: agentDid, 738 + collection: "app.bsky.graph.block", 739 + path: ".subject", 740 + } 741 + : undefined, 742 + ); 743 + const authorBlocksUser = useGetOneToOneState( 744 + agentDid && authorDid 745 + ? { 746 + target: agentDid, 747 + user: authorDid, 748 + collection: "app.bsky.graph.block", 749 + path: ".subject", 750 + } 751 + : undefined, 752 + ); 753 + 609 754 const repostOrUnrepostPost = async () => { 610 755 if (!agent) { 611 756 console.error("Agent is null or undefined"); ··· 682 827 const showContentWarning = warnContentLabels.length > 0; 683 828 684 829 const [isOpen, setIsOpen] = useState(!showContentWarning); 830 + 685 831 const [hasUserTouchedToggleYet, setHasUserTouchedToggleYet] = useState(false); 686 832 687 - useEffect(()=>{ 688 - if(!hasUserTouchedToggleYet && showContentWarning) { 833 + // Force Hiddens from host policy 834 + const isForceHiddenAuthor = authorLabels.some((label) => { 835 + return ( 836 + FORCE_HIDE_LABELS.has(label.val) && 837 + FORCE_HIDE_LABELS_WHITELISTED_SOURCE.has(label.src) 838 + ); 839 + }); 840 + const isForceHiddenProfile = profileLabels.some((label) => { 841 + return ( 842 + FORCE_HIDE_LABELS.has(label.val) && 843 + FORCE_HIDE_LABELS_WHITELISTED_SOURCE.has(label.src) 844 + ); 845 + }); 846 + const isForceHiddenPost = contentLabels.some((label) => { 847 + return ( 848 + FORCE_HIDE_LABELS.has(label.val) && 849 + FORCE_HIDE_LABELS_WHITELISTED_SOURCE.has(label.src) 850 + ); 851 + }); 852 + const isForceHidden = isForceHiddenAuthor || isForceHiddenProfile || isForceHiddenPost 853 + 854 + 855 + useEffect(() => { 856 + if (!hasUserTouchedToggleYet && showContentWarning) { 857 + // eslint-disable-next-line react-hooks/set-state-in-effect 689 858 setIsOpen(false); 690 859 } 691 - },[hasUserTouchedToggleYet, showContentWarning]) 860 + }, [hasUserTouchedToggleYet, showContentWarning]) 861 + 862 + 863 + console.log("HLLO HLLO HisForceHidden post UPR" + post.uri + post.author.did + isForceHidden, "1what", contentLabels, "2what", authorLabels) 864 + 865 + // if (hideAuthorLabels.length > 0 || hideContentLabels.length > 0 || isForceHidden || strictModerationDontShow) { 866 + // return ( 867 + // <div ref={ref} style={style} data-index={dataIndexPropPass} className=" leading-normal flex flex-col gap-4 p-4"> 868 + // <span>DEBUG LOADING LABELS</span> 869 + // <span>{post.uri}</span> 870 + // <span>{verdictDebugString}</span> 871 + // </div> 872 + // ); 873 + // } 874 + // if ( isForceHidden ) { 875 + // return ( 876 + // <div ref={ref} style={style} data-index={dataIndexPropPass} className=" leading-normal flex flex-col gap-4 p-4"> 877 + // Post Hidden 878 + // </div> 879 + // ) 880 + // } 881 + 882 + // todo respect the blur label def 883 + // todo scrap the verdict system and rename it into what it is (loading state) 884 + const redactWhileLoadingAuthor = authorModLoading || authorModError || authorModUnknown 885 + const redactWhileLoadingProfile = profileModLoading || profileModError || profileModUnknown 886 + const redactWhileLoadingPost = contentModLoading || contentModError || contentModUnknown 887 + const redactWhileLoadingBlock = userBlocksAuthor.isLoading || authorBlocksUser.isLoading 888 + const redactWhileLoadingSome = redactWhileLoadingAuthor || redactWhileLoadingProfile || redactWhileLoadingPost || redactWhileLoadingBlock 889 + /** 890 + * maybe rules: 891 + * if author is loading, hide everything 892 + * if post is loading, hide text and embeds 893 + * if profile is loading, hide pfp 894 + */ 895 + 896 + // the || !post.record?.createdAt is so that users cant imply theyre replying to a non existant post by a user 897 + // if the post doesnt exist, dont render the name or pfp 898 + 899 + 900 + const redactWhileLoading_name = redactWhileLoadingAuthor || !post.record?.createdAt || redactWhileLoadingBlock 901 + const redactWhileLoading_content = redactWhileLoadingAuthor || redactWhileLoadingPost || !post.record?.createdAt || redactWhileLoadingBlock 902 + const redactWhileLoading_pfp = redactWhileLoadingAuthor || redactWhileLoadingProfile || !post.record?.createdAt || redactWhileLoadingBlock 903 + 904 + 905 + const redactFinalBlock = userBlocksAuthor.uris.length > 0 || authorBlocksUser.uris.length > 0 692 906 907 + const redactFinalAuthor = hideAuthorLabels.length > 0 || isForceHiddenAuthor || redactFinalBlock 908 + const redactFinalProfile = hideProfileLabels.length > 0 || isForceHiddenProfile || redactFinalBlock 909 + const redactFinalPost = hideContentLabels.length > 0 || isForceHiddenPost || redactFinalBlock 693 910 694 - if (hideAuthorLabels.length > 0 || hideContentLabels.length > 0) { 695 - return null; 911 + const redactFinalSome = redactFinalAuthor || redactFinalProfile || redactFinalPost || redactFinalBlock 912 + 913 + // todo consider if adding an explicit "post removed" visible component is better for this 914 + //if (redactFinalSome) return null 915 + // todo preserve reply lines 916 + // todo share the component with the Missing post from above 917 + if (redactFinalSome) { 918 + if (feedviewpost) { 919 + return null // if feed view post then moderated post isnt important and just remove it from view 920 + } 921 + return ( 922 + <div 923 + className={`flex flex-col gap-0 border-gray-200 dark:border-gray-800 ${bottomReplyLine ? "" : "border-b"}`} 924 + onClick={ 925 + isMainItem 926 + ? onPostClick 927 + : setMainItem 928 + ? onPostClick 929 + ? (e) => { 930 + setMainItem({ post: post }); 931 + onPostClick(e); 932 + } 933 + : () => { 934 + setMainItem({ post: post }); 935 + } 936 + : undefined 937 + }> 938 + 939 + <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4"> 940 + <div 941 + style={{ 942 + width: 2, 943 + height: 16, 944 + opacity: 0.5, 945 + }} 946 + className={`${topReplyLine ? "bg-gray-500 dark:bg-gray-400" : "bg-transparent"}`} 947 + /> 948 + </div> 949 + 950 + <div className="flex flex-row px-4"> 951 + <div className="flex flex-col gap-1 flex-1 rounded-lg py-3 px-4 bg-gray-200 dark:bg-gray-800"> 952 + <div className="flex flex-row flex-1 gap-2 items-center"> 953 + <IconMdiShieldOutline width={18} height={18} /> 954 + <span className=" font-semibold text-[15px]">Moderated Post</span> 955 + </div> 956 + <ul className="flex flex-col gap-0.5 list-disc list-outside"> 957 + {userBlocksAuthor.uris.length > 0 && (<li className=" text-sm ml-[18px]">User Blocked by You</li>)} 958 + {authorBlocksUser.uris.length > 0 && (<li className=" text-sm ml-[18px]">User Blocking You</li>)} 959 + {hideAuthorLabels.length > 0 && (<>{hideAuthorLabels.map((label) => { return <li key={label.cid || label.exp} className=" text-sm ml-[18px]">Author Label: {getLocaleLabel(ghld(label.src, label.val))?.name || label.val}</li> })}</>)} 960 + {hideProfileLabels.length > 0 && (<>{hideProfileLabels.map((label) => { return <li key={label.cid || label.exp} className=" text-sm ml-[18px]">Profile Label: {getLocaleLabel(ghld(label.src, label.val))?.name || label.val}</li> })}</>)} 961 + {hideContentLabels.length > 0 && (<>{hideContentLabels.map((label) => { return <li key={label.cid || label.exp} className=" text-sm ml-[18px]">Post Label: {getLocaleLabel(ghld(label.src, label.val))?.name || label.val}</li> })}</>)} 962 + </ul> 963 + </div> 964 + </div> 965 + 966 + <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4"> 967 + <div 968 + style={{ 969 + width: 2, 970 + height: 16, 971 + opacity: 0.5, 972 + }} 973 + className={`${bottomReplyLine ? "bg-gray-500 dark:bg-gray-400" : "bg-transparent"}`} 974 + /> 975 + </div> 976 + </div> 977 + ) 696 978 } 697 979 980 + // ${redactWhileLoadingSome && "blur"} 698 981 return ( 699 - <div ref={ref} style={style} data-index={dataIndexPropPass}> 982 + <div ref={ref} style={style} data-index={dataIndexPropPass} className={` leading-normal `}> 983 + {/* <span>{JSON.stringify(post, null, 2)}</span> */} 700 984 <div 701 985 key={salt + "-" + (post.uri || emergencySalt)} 702 986 onClick={ ··· 740 1024 alignItems: "center", 741 1025 }} 742 1026 className="text-gray-500 dark:text-gray-400" 1027 + // todo moderate reposts (label, and record graph) 743 1028 > 744 1029 <IconMdiRepost /> Reposted by @{isRepost} 745 1030 </div> ··· 777 1062 }} 778 1063 onClick={onProfileClick} 779 1064 > 780 - <img 781 - src={post.author.avatar || defaultpfp} 782 - alt="avatar" 783 - className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} 784 - style={{ 785 - width: isQuote ? 16 : 42, 786 - height: isQuote ? 16 : 42, 787 - }} 788 - /> 1065 + {redactWhileLoading_pfp ? ( 1066 + <div 1067 + className="rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600 animate-pulse" 1068 + style={{ 1069 + width: isQuote ? 16 : 42, 1070 + height: isQuote ? 16 : 42, 1071 + }} 1072 + /> 1073 + ) : ( 1074 + <img 1075 + src={post.author.avatar || defaultpfp} 1076 + alt="avatar" 1077 + className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} 1078 + style={{ 1079 + width: isQuote ? 16 : 42, 1080 + height: isQuote ? 16 : 42, 1081 + }} 1082 + /> 1083 + ) 1084 + } 1085 + 789 1086 </div> 790 1087 </HoverCard.Trigger> 791 1088 <HoverCard.Portal> ··· 797 1094 > 798 1095 <div className="flex flex-col gap-2"> 799 1096 <div className="flex flex-row"> 800 - <img 801 - src={post.author.avatar || defaultpfp} 802 - alt="avatar" 803 - className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 804 - /> 1097 + {redactWhileLoading_pfp ? ( 1098 + <div className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600 animate-pulse" /> 1099 + ) : ( 1100 + <img 1101 + src={post.author.avatar || defaultpfp} 1102 + alt="avatar" 1103 + className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1104 + /> 1105 + ) 1106 + } 805 1107 <div className=" flex-1 flex flex-row align-middle justify-end"> 806 1108 <FollowButton targetdidorhandle={post.author.did} /> 807 1109 </div> 808 1110 </div> 809 1111 <div className="flex flex-col gap-3"> 810 1112 <div> 811 - <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 812 - {post.author.displayName || post.author.handle} 1113 + <div className={`text-gray-900 dark:text-gray-100 font-medium text-md ${redactWhileLoading_name && "animate-pulse blur"}`}> 1114 + {redactWhileLoading_name ? "Person Display Name" : (post.author.displayName || post.author.handle)} 813 1115 </div> 814 - <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1116 + <div className={`text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1 ${redactWhileLoading_name && "animate-pulse blur"}`}> 815 1117 <Mutual targetdidorhandle={post.author.did} />@ 816 - {post.author.handle} 1118 + {redactWhileLoading_name ? "person.placeholder" : post.author.handle} 817 1119 </div> 818 1120 </div> 819 1121 {uprrrsauthor?.description && ( ··· 892 1194 gap: 4, 893 1195 alignItems: "center", 894 1196 }} 895 - className="text-gray-900 dark:text-gray-100" 1197 + className={`text-gray-900 dark:text-gray-100 ${redactWhileLoading_name && "animate-pulse blur"}`} 896 1198 > 897 - {post.author.displayName || post.author.handle} 1199 + {redactWhileLoading_name ? "Person Display Name" : post.author.displayName || post.author.handle} 898 1200 {post.author.verification?.verifiedStatus == "valid" && ( 899 1201 <IconMdiVerified /> 900 1202 )} ··· 910 1212 flexGrow: 0, 911 1213 minWidth: 0, 912 1214 }} 913 - className="text-gray-500 dark:text-gray-400" 1215 + className={`text-gray-500 dark:text-gray-400 ${redactWhileLoading_name && "animate-pulse blur"}`} 914 1216 > 915 - @{post.author.handle} 1217 + @{redactWhileLoading_name ? "person.placeholder" : post.author.handle} 916 1218 </span> 917 1219 </div> 918 1220 <div ··· 939 1241 {/* <ModerationInner subject={post.author.did} /> */} 940 1242 {authorModLoading ? ( 941 1243 <div className="flex flex-wrap flex-row gap-1 my-1"> 942 - <div className="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded-full flex flex-row items-center gap-1"> 943 - {/* <img 1244 + {/* <div className="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded-full flex flex-row items-center gap-1"> 1245 + / <img 944 1246 src={resolvedpfp || defaultpfp} 945 1247 alt="avatar" 946 1248 className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} ··· 948 1250 width: 12, 949 1251 height: 12, 950 1252 }} 951 - /> */} 1253 + /> / 952 1254 <span className="font-medium">loading badges...</span> 953 - </div> 1255 + </div> */} 954 1256 </div> 955 1257 ) : ( 956 - <div className="flex flex-wrap flex-row gap-1 my-1"> 957 - {warnAuthorLabels.map((label, index) => ( 1258 + <div className={`flex flex-wrap flex-row gap-1 my-1 ${redactWhileLoading_name ? "animate-pulse blur" : ""}`}> 1259 + {informCombinedLabels.map((label, index) => ( 958 1260 <SmallAuthorLabelBadge 959 1261 label={label} 960 - key={label.cts + label.sourceDid + label.val} 1262 + key={label.cts + label.src + label.val} 961 1263 /> 962 1264 ))} 963 1265 </div> ··· 979 1281 opacity: 980 1282 !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0, 981 1283 }} 982 - className="text-gray-500 dark:text-gray-400" 1284 + className={`text-gray-500 dark:text-gray-400 ${redactWhileLoading_content && "animate-pulse blur"}`} 983 1285 > 984 1286 <IconMdiReply /> Reply to @{feedviewpostreplyhandle} 985 1287 </div> 986 1288 )} 987 1289 {/* <ModerationInner subject={post.uri} /> */} 1290 + {/* todo migrate cw stuff to the new useAutoLabels system */} 988 1291 {showContentWarning && ( 989 1292 <ContentWarning 990 1293 unauthedgate={hideWarnsWhenUnauthed} ··· 1015 1318 overflow: "hidden", 1016 1319 }), 1017 1320 }} 1018 - className="text-gray-900 dark:text-gray-100" 1321 + className={`text-gray-900 dark:text-gray-100 ${redactWhileLoading_content && "animate-pulse blur"}`} 1019 1322 > 1020 1323 {fedi ? ( 1021 1324 <> ··· 1038 1341 </div> 1039 1342 {post.embed && depth < 1 && !concise ? ( 1040 1343 <PostEmbeds 1344 + redactedLoading={redactWhileLoading_content} 1041 1345 embed={post.embed} 1042 1346 viewContext={PostEmbedViewContext.Feed} 1043 1347 salt={salt} ··· 1050 1354 ) : null} 1051 1355 {post.embed && depth > 0 && ( 1052 1356 <> 1053 - <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1357 + <div className={`border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px] ${redactWhileLoading_content && "animate-pulse blur"}`}> 1054 1358 (there is an embed here thats too deep to render) 1055 1359 </div> 1056 1360 </> ··· 1074 1378 borderBottomWidth: 1, 1075 1379 marginBottom: 8, 1076 1380 }} 1077 - className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7" 1381 + className={`text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7 ${redactWhileLoading_content && "animate-pulse blur"}`} 1078 1382 > 1079 1383 {fullDateTimeFormat(post.indexedAt)} 1080 1384 </div> ··· 1098 1402 style={{ 1099 1403 ...btnstyle, 1100 1404 }} 1405 + className={redactWhileLoading_content && "animate-pulse blur" || undefined} 1101 1406 > 1102 1407 <IconMdiCommentOutline /> 1103 1408 {post.replyCount} ··· 1110 1415 ...(hasRetweeted ? { color: "#5CEFAA" } : {}), 1111 1416 }} 1112 1417 aria-label="Repost or quote post" 1418 + className={redactWhileLoading_content && "animate-pulse blur" || undefined} 1113 1419 > 1114 1420 {hasRetweeted ? ( 1115 1421 <IconMdiRepeat color="#5CEFAA" /> ··· 1159 1465 ...btnstyle, 1160 1466 ...(liked ? { color: "#EC4899" } : {}), 1161 1467 }} 1468 + className={redactWhileLoading_content && "animate-pulse blur" || undefined} 1162 1469 > 1163 1470 {liked ? ( 1164 1471 <IconMdiCardsHeart /> ··· 1234 1541 onPress, 1235 1542 }: { 1236 1543 unauthedgate?: boolean; 1237 - labels: ContentLabel[]; 1544 + labels: ATPAPI.ComAtprotoLabelDefs.Label[]; 1238 1545 isOpen: boolean; 1239 1546 onPress: React.MouseEventHandler<HTMLDivElement>; 1240 1547 }) { ··· 1242 1549 1243 1550 // Pre-calculate text for cleaner JSX 1244 1551 const labelText = labels 1245 - .map((label) => getLabelInfo(label.sourceDid, label.val).name) 1552 + .map((label) => getLabelInfo(label.src, label.val).name) 1246 1553 .join(", "); 1247 1554 1248 1555 return ( ··· 1288 1595 label, 1289 1596 large, 1290 1597 }: { 1291 - label: ContentLabel; 1598 + label: LabelWithHydratedLocaleName; 1292 1599 large?: boolean; 1293 1600 }) { 1294 1601 /* 1295 1602 -{" "} 1296 - {label.preference} (from {label.sourceDid}) 1603 + {ghld(label.src,label.val)?.severity} (from {label.sourceDid}) 1297 1604 */ 1298 - const { getLabelInfo } = useLabelInfo(); 1299 - const info = getLabelInfo(label.sourceDid, label.val); 1605 + //const info = getLabelInfo(label.src, label.val); 1300 1606 1301 1607 const [imgcdn] = useAtom(imgCDNAtom); 1302 1608 1303 1609 const { data: opProfile } = useQueryProfile( 1304 - `at://${label.sourceDid}/app.bsky.actor.profile/self`, 1610 + `at://${label.src}/app.bsky.actor.profile/self`, 1305 1611 ); 1306 1612 1307 - const resolvedpfp = getAvatarUrl(opProfile, label.sourceDid, imgcdn); 1613 + const resolvedpfp = getAvatarUrl(opProfile, label.src, imgcdn); 1614 + 1615 + return ( 1616 + <SmallAuthorLabelBadgeInner 1617 + resolvedpfp={resolvedpfp || undefined} 1618 + text={label.name || label.val} 1619 + large={large} 1620 + /> 1621 + ); 1622 + } 1308 1623 1624 + // todo add click event to explain the label or soemthing 1625 + export function SmallAuthorLabelBadgeInner({ 1626 + resolvedpfp, 1627 + text, 1628 + large, 1629 + disablepfp = false, 1630 + }: { 1631 + resolvedpfp?: string; 1632 + text: string; 1633 + large?: boolean; 1634 + disablepfp?: boolean; 1635 + }) { 1309 1636 return ( 1310 1637 <div 1311 - className={`text-xs bg-gray-100 dark:bg-gray-800 ${large ? "px-2 py-1" : "px-1 py-0.5"} rounded-full flex flex-row items-center gap-1`} 1638 + className={`text-xs ${large ? "bg-gray-200" : "bg-gray-100"} dark:bg-gray-800 ${large ? "px-2 py-1" : "px-1 py-0.5"} rounded-full flex flex-row items-center gap-1`} 1312 1639 > 1313 - <img 1640 + {!disablepfp && (<img 1314 1641 src={resolvedpfp || defaultpfp} 1315 1642 alt="avatar" 1316 1643 className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} ··· 1318 1645 width: 12, 1319 1646 height: 12, 1320 1647 }} 1321 - /> 1322 - <span className="font-medium">{info.name || label.val}</span> 1648 + />)} 1649 + <span className="font-medium">{text}</span> 1323 1650 </div> 1324 1651 ); 1325 1652 }
+201
src/hooks/useAutoLabels.ts
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + 3 + import { 4 + type HydratedLabelValueDefinition, 5 + type labelpref, 6 + useAutoLabelConfig, 7 + } from "~/providers/AutoLabelProvider"; 8 + import { useQueryLabels } from "~/utils/useQuery"; 9 + 10 + export function normalizeLabels( 11 + labels: ATPAPI.ComAtprotoLabelDefs.Label[], 12 + ): ATPAPI.ComAtprotoLabelDefs.Label[] { 13 + const map = new Map<string, ATPAPI.ComAtprotoLabelDefs.Label>(); 14 + 15 + for (const label of labels) { 16 + const key = `${label.src}::${label.uri}::${label.val}`; 17 + 18 + if (label.neg) { 19 + map.delete(key); 20 + } else { 21 + map.set(key, label); 22 + } 23 + } 24 + 25 + return [...map.values()]; 26 + } 27 + 28 + function computeVerdict( 29 + prefs: labelpref[], 30 + labels: ATPAPI.ComAtprotoLabelDefs.Label[], 31 + ): ATPAPI.AppBskyActorDefs.ContentLabelPref["visibility"] { 32 + for (const pref of prefs) { 33 + // check if any label matches this pref 34 + const hit = labels.some((l) => l.src === pref.did && l.val === pref.label); 35 + 36 + if (!hit) continue; 37 + 38 + if (pref.visibility === "hide") { 39 + return "hide"; 40 + } 41 + 42 + if (pref.visibility === "warn") { 43 + return "warn"; 44 + } 45 + } 46 + 47 + return "ignore"; 48 + } 49 + 50 + export function useAutoLabels({ 51 + subjects, 52 + type, 53 + }: { 54 + subjects: string[]; 55 + type: "post" | "profile" | "account"; 56 + }): { 57 + results: Map< 58 + string, 59 + { 60 + labelVerdict: ATPAPI.AppBskyActorDefs.ContentLabelPref["visibility"]; 61 + labels: ATPAPI.ComAtprotoLabelDefs.Label[]; 62 + } 63 + >; 64 + hydratedLabelDefs: Map< 65 + string, 66 + HydratedLabelValueDefinition 67 + >; 68 + } { 69 + const { 70 + activeLabelerDids, 71 + mergedPrefContentLabels, 72 + hydratedLabelDefs, 73 + isLoading: configLoading, 74 + isError: configError, 75 + sendError, 76 + } = useAutoLabelConfig(); 77 + 78 + const results = new Map< 79 + string, 80 + { 81 + labelVerdict: ATPAPI.AppBskyActorDefs.ContentLabelPref["visibility"]; 82 + labels: ATPAPI.ComAtprotoLabelDefs.Label[]; 83 + } 84 + >(); 85 + 86 + // const res = useQueryLabelMerge({ 87 + // s: subjects, 88 + // l: activeLabelerDids, 89 + // strict: false, 90 + // }); 91 + const res = useQueryLabels(subjects,activeLabelerDids); 92 + if (configLoading || res.isLoading) { 93 + for (const subject of subjects) { 94 + const subjectLabels = [] as ATPAPI.ComAtprotoLabelDefs.Label[]; 95 + results.set(subject, { 96 + labelVerdict: "loading", 97 + labels: subjectLabels, 98 + }); 99 + } 100 + return { 101 + results, 102 + hydratedLabelDefs, 103 + }; 104 + } 105 + if (configError || res.isError || res.data?.error) { 106 + if (res.isError) { 107 + sendError({ 108 + did: "!all", 109 + message: "queryFailure" 110 + }) 111 + } else 112 + if (res.data?.error) { 113 + console.log("fuck shit !internal-IDK-unknown, ", res.data) 114 + res.data?.error?.forEach((err)=>{ 115 + sendError({ 116 + did: err.s, 117 + message: err.e || "!internal-uAL-unknown, len: " + res.data.error?.length + ". label-length" + res.data.labels.length 118 + }) 119 + }) 120 + } 121 + console.error("Error fetching auto-label configuration or subject labels",{ 122 + configError: configError, 123 + isLoading: res.isLoading, 124 + isError: res.isError, 125 + data: res.data, 126 + //error: res.error, 127 + }); 128 + 129 + for (const subject of subjects) { 130 + const subjectLabels = [] as ATPAPI.ComAtprotoLabelDefs.Label[]; 131 + results.set(subject, { 132 + labelVerdict: "error", 133 + labels: subjectLabels, 134 + }); 135 + } 136 + return { 137 + results, 138 + hydratedLabelDefs, 139 + }; 140 + } 141 + 142 + if (res.data && (res.data.labels.length === 0) ) { 143 + // early return because wow you did it theres no labels to be computed! wonderful! wow! 144 + // group labels by subject (uri) 145 + 146 + for (const subject of subjects) { 147 + const subjectLabels = [] as ATPAPI.ComAtprotoLabelDefs.Label[]; 148 + results.set(subject, { 149 + labelVerdict: "ignore", 150 + labels: subjectLabels, 151 + }); 152 + } 153 + return { 154 + results, 155 + hydratedLabelDefs, 156 + }; 157 + } 158 + 159 + const effectiveLabels = normalizeLabels(res.data?.labels ?? []); 160 + 161 + // group labels by subject (uri) 162 + const labelsBySubject = new Map< 163 + string, 164 + ATPAPI.ComAtprotoLabelDefs.Label[] 165 + >(); 166 + 167 + for (const label of effectiveLabels) { 168 + const arr = labelsBySubject.get(label.uri) ?? []; 169 + arr.push(label); 170 + labelsBySubject.set(label.uri, arr); 171 + } 172 + 173 + for (const subject of subjects) { 174 + const subjectLabels = labelsBySubject.get(subject) ?? []; 175 + const verdict = computeVerdict( 176 + mergedPrefContentLabels, 177 + subjectLabels, 178 + ); 179 + results.set(subject, { 180 + labelVerdict: verdict, 181 + labels: subjectLabels, 182 + }); 183 + } 184 + 185 + return { 186 + results, 187 + hydratedLabelDefs, 188 + }; 189 + } 190 + 191 + export function getGetHydratedLabelDefs( 192 + hydratedLabelDefs: Map< 193 + string, 194 + HydratedLabelValueDefinition 195 + >, 196 + ) { 197 + function getHydratedLabelDefs(did: string, label: string) { 198 + return hydratedLabelDefs.get(did + "::" + label); 199 + } 200 + return getHydratedLabelDefs; 201 + }
+14 -10
src/hooks/useModeration.ts
··· 8 8 pendingUriQueueAtom, 9 9 processingUriSetAtom, 10 10 } from "~/state/moderationAtoms"; 11 - 12 - export const useModeration = (uri: string) => { 11 + /** 12 + * Deprecated, left as is for potential future use 13 + * @deprecated please use the newer useAutoLabels() hook 14 + */ 15 + export const useModeration = (opaqueIdentifierStringToBeModerated: string) => { 13 16 const setQueue = useSetAtom(pendingUriQueueAtom); 14 17 15 18 // 1. Select ONLY this URI's cache entry 16 19 const entryAtom = useMemo( 17 - () => selectAtom(moderationCacheAtom, (cache) => cache.get(uri)), 18 - [uri], 20 + () => selectAtom(moderationCacheAtom, (cache) => cache.get(opaqueIdentifierStringToBeModerated)), 21 + [opaqueIdentifierStringToBeModerated], 19 22 ); 20 23 const [cachedEntry] = useAtom(entryAtom); 21 24 22 - // 2. Select ONLY this URI's processing state 25 + // 2. Select ONLY this opaqueIdentifierStringToBeModerated's processing state 23 26 const isProcessingAtom = useMemo( 24 - () => selectAtom(processingUriSetAtom, (set) => set.has(uri)), 25 - [uri], 27 + () => selectAtom(processingUriSetAtom, (set) => set.has(opaqueIdentifierStringToBeModerated)), 28 + [opaqueIdentifierStringToBeModerated], 26 29 ); 27 30 const [isProcessing] = useAtom(isProcessingAtom); 28 31 32 + // eslint-disable-next-line react-hooks/purity 29 33 const now = Date.now(); 30 34 const exists = cachedEntry !== undefined; 31 35 const isStale = exists && now - cachedEntry.timestamp > CACHE_TIMEOUT_MS; ··· 36 40 37 41 // Queue it 38 42 setQueue((prev) => { 39 - if (prev.has(uri)) return prev; 43 + if (prev.has(opaqueIdentifierStringToBeModerated)) return prev; 40 44 const next = new Set(prev); 41 - next.add(uri); 45 + next.add(opaqueIdentifierStringToBeModerated); 42 46 return next; 43 47 }); 44 - }, [uri, exists, isStale, isProcessing, setQueue]); 48 + }, [opaqueIdentifierStringToBeModerated, exists, isStale, isProcessing, setQueue]); 45 49 46 50 return { 47 51 // Show loading ONLY if we have absolutely no data (first load)
+5
src/main.tsx
··· 30 30 persistQueryClient({ 31 31 queryClient, 32 32 persister: localStoragePersister, 33 + dehydrateOptions: { 34 + shouldDehydrateQuery: (query) => { 35 + return !query.queryKey.includes('__volatile') 36 + }, 37 + }, 33 38 }); 34 39 35 40 // Create a new router instance
+341
src/providers/AutoLabelProvider.tsx
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { isContentLabelPref, isLabelersPref } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 3 + import { useQueries, useQueryClient } from "@tanstack/react-query"; 4 + import { useAtom } from "jotai"; 5 + import { Dialog } from "radix-ui"; 6 + import React, { createContext, useContext, useMemo } from "react"; 7 + 8 + import { FORCED_LABELER_DIDS } from "~/../policy"; 9 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 10 + import { NotificationItem } from "~/routes/notifications"; 11 + import { disabledLabelersAtom, slingshotURLAtom } from "~/utils/atoms"; 12 + import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; 13 + 14 + // higher prio overwrites lower prio 15 + export const LABEL_PRIO_DEFAULT = 0 16 + export const LABEL_PRIO_USER = 5 17 + // export const LABEL_PRIO_FORCED = 20 // Not used in pref merging 18 + 19 + export interface HydratedLabelValueDefinition extends ATPAPI.ComAtprotoLabelDefs.LabelValueDefinition { 20 + pref: ATPAPI.ComAtprotoLabelDefs.LabelValueDefinition["defaultSetting"] 21 + } 22 + 23 + export type labelpref = { 24 + did: string, 25 + label: string, 26 + visibility: ATPAPI.AppBskyActorDefs.ContentLabelPref["visibility"], 27 + priority: number 28 + } 29 + 30 + type labeldefpref = { 31 + did: string, 32 + labeldefs?: ATPAPI.ComAtprotoLabelDefs.LabelValueDefinition[], 33 + err?: string 34 + } 35 + 36 + type hydratedlabeldefpref = { 37 + did: string, 38 + labeldefs?: HydratedLabelValueDefinition[], 39 + err?: string 40 + } 41 + 42 + type AutoLabelConfig = { 43 + activeLabelerDids: string[]; 44 + mergedPrefContentLabels: labelpref[]; 45 + hydratedLabelDefs: Map<string, HydratedLabelValueDefinition>; 46 + isLoading: boolean; 47 + isError: boolean; 48 + sendError: (error: LabelerError) => void; 49 + }; 50 + 51 + export type LabelerError = { did: string; message: string }; 52 + 53 + const AutoLabelContext = createContext<AutoLabelConfig | undefined>(undefined); 54 + 55 + export function useAutoLabelConfig(): AutoLabelConfig { 56 + const context = useContext(AutoLabelContext); 57 + if (!context) { 58 + throw new Error('useAutoLabelConfig must be used within an AutoLabelProvider'); 59 + } 60 + return context; 61 + } 62 + 63 + export function AutoLabelProvider({ children }: { children: React.ReactNode }) { 64 + const { agent, status } = useAuth(); 65 + const [slingshoturl] = useAtom(slingshotURLAtom); 66 + const queryClient = useQueryClient(); 67 + 68 + const [disabledLabelers, setDisabledLabelers] = useAtom(disabledLabelersAtom); 69 + 70 + // Modal state 71 + const [errorModalOpen, setErrorModalOpen] = React.useState(false); 72 + const [labelerErrors, setLabelerErrors] = React.useState<LabelerError[]>([]); 73 + const erroredLabelerDids = new Set( 74 + labelerErrors.map(e => e.did) 75 + ); 76 + 77 + const sendError = React.useCallback((error: LabelerError) => { 78 + setLabelerErrors((prev) => { 79 + // Avoid duplicates 80 + if (prev.find(e => e.did === error.did)) return prev; 81 + return [...prev, error]; 82 + }); 83 + setErrorModalOpen(true); 84 + }, []); 85 + 86 + 87 + const isUnauthed = status === "signedOut" || !agent; 88 + 89 + // 1. Get User Identity & Preferences 90 + const { data: identity } = useQueryIdentity(agent?.did); 91 + const { 92 + data: prefs, 93 + isLoading: prefsLoading, 94 + isError: prefsError 95 + } = useQueryPreferences({ 96 + agent: agent ?? undefined, 97 + pdsUrl: identity?.pds, 98 + }); 99 + 100 + // 2. Identify Labeler DIDs & User Preferences (Use useMemo for stability) 101 + const userPrefLabelerDids = useMemo(() => 102 + prefs?.preferences?.find(isLabelersPref)?.labelers.map(l => l.did) ?? [] 103 + , [prefs]); 104 + 105 + const userPrefContentLabelsRaw = useMemo(() => 106 + prefs?.preferences?.filter(isContentLabelPref) ?? [] 107 + , [prefs]); 108 + 109 + const userPrefContentLabels: labelpref[] = useMemo(() => userPrefContentLabelsRaw.map((pref) => { 110 + const res: labelpref = { 111 + did: pref.labelerDid || "global", 112 + label: pref.label, 113 + visibility: pref.visibility, 114 + priority: LABEL_PRIO_USER 115 + } 116 + return res 117 + }), [userPrefContentLabelsRaw]); 118 + 119 + // 3. Force Bsky DID + User DIDs 120 + const userPrefLabelerDidsFiltered = React.useMemo( 121 + () => userPrefLabelerDids.filter(did => !disabledLabelers.includes(did)), 122 + [userPrefLabelerDids, disabledLabelers] 123 + ); 124 + 125 + const activeLabelerDids = useMemo(() => Array.from( 126 + new Set([...FORCED_LABELER_DIDS, ...userPrefLabelerDidsFiltered]) 127 + ), [userPrefLabelerDidsFiltered]); 128 + 129 + // 4. Parallel fetch Service Records (The expensive part) 130 + const labelerServiceQueries = useQueries({ 131 + queries: activeLabelerDids.map((did: string) => ({ 132 + queryKey: ["labelerServiceDefaultstestwhatever", did], 133 + queryFn: async () => { 134 + const host = slingshoturl; 135 + const response = await fetch( 136 + `https://${host}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.labeler.service&rkey=self`, 137 + ); 138 + if (!response.ok) throw new Error("Failed to fetch labeler service"); 139 + try { 140 + const res = await response.json() as { 141 + uri: string; 142 + cid: string; 143 + value: ATPAPI.AppBskyLabelerService.Record; 144 + }; 145 + const labeldefs = res.value.policies.labelValueDefinitions 146 + return { 147 + did: did, 148 + labeldefs: labeldefs 149 + } as labeldefpref 150 + } catch { 151 + sendError({ did, message: ".json()" }); 152 + return { 153 + did: did, 154 + err: ".json()" 155 + } as labeldefpref 156 + } 157 + }, 158 + staleTime: 1000 * 60 * 60, // Cache for 1 hour 159 + })), 160 + }); 161 + 162 + const defaultPrefContentLabels: labelpref[] = useMemo(() => 163 + labelerServiceQueries.flatMap(labeler => { 164 + if (labeler.error || labeler.data?.err || !labeler.data) { 165 + return [] 166 + } 167 + 168 + const labelerDid = labeler.data.did 169 + 170 + return labeler.data.labeldefs?.map(label => ({ 171 + did: labelerDid, 172 + label: label.identifier, 173 + visibility: label.defaultSetting, 174 + priority: LABEL_PRIO_DEFAULT, 175 + })) ?? [] 176 + }) 177 + , [labelerServiceQueries]); 178 + 179 + // 5. Merge Default and User Preferences 180 + const mergedPrefContentLabels: labelpref[] = useMemo(() => { 181 + const allrawPrefContentLabels = [...defaultPrefContentLabels, ...userPrefContentLabels] 182 + 183 + return Object.values( 184 + allrawPrefContentLabels.reduce( 185 + (acc, pref) => { 186 + const key = `${pref.did}::${pref.label}` 187 + const existing = acc[key] 188 + if (!existing || pref.priority > existing.priority) { 189 + acc[key] = pref 190 + } 191 + return acc 192 + }, 193 + {} as Record<string, labelpref> 194 + ) 195 + ) 196 + }, [defaultPrefContentLabels, userPrefContentLabels]); 197 + 198 + const hydratedLabelDefs = convertLabelDefsToMap( 199 + labelerServiceQueries 200 + .map(item => item.data) 201 + .filter((data): data is labeldefpref => data !== undefined), 202 + mergedPrefContentLabels 203 + ) 204 + 205 + const labelerServiceLoading = labelerServiceQueries.some(q => q.isLoading); 206 + const labelerServiceError = labelerServiceQueries.some(q => q.isError); 207 + 208 + const isLoading = prefsLoading || labelerServiceLoading; 209 + const isError = (!isUnauthed && prefsError) || labelerServiceError; 210 + 211 + const value = useMemo(() => ({ 212 + activeLabelerDids, 213 + mergedPrefContentLabels, 214 + hydratedLabelDefs, 215 + isLoading, 216 + isError, 217 + sendError 218 + }), [activeLabelerDids, mergedPrefContentLabels, hydratedLabelDefs, isLoading, isError, sendError]); 219 + 220 + // The provider must wrap the application root where useAutoLabels is called 221 + return ( 222 + <> 223 + <AutoLabelContext.Provider value={value}>{children}</AutoLabelContext.Provider> 224 + {errorModalOpen && ( 225 + <Dialog.Root open={errorModalOpen} onOpenChange={setErrorModalOpen}> 226 + <Dialog.Overlay className="z-70 fixed inset-0 bg-black/40" /> 227 + <Dialog.Content className="z-80 fixed inset-0 flex justify-center items-center"> 228 + <div className="max-w-md max-h-[calc(100dvh-80px)] flex flex-col py-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow-xl"> 229 + <div className="flex flex-col gap-2 mx-4"> 230 + <span className="text-[19px] font-semibold">Labeler Errors</span> 231 + <span className="text-gray-700 dark:text-gray-400">These labelers have returned an error. <br />You can disable them to continue using the app.</span> 232 + </div> 233 + <div className="mb-4 space-y-2 overflow-y-auto"> 234 + {labelerErrors.map(e => ( 235 + // <li key={e.did} className="flex justify-between items-center border-b pb-1"> 236 + // <span>{e.did}: {e.message}</span> 237 + // <button 238 + // className="text-red-500 text-sm" 239 + // onClick={() => { 240 + // setDisabledLabelers(prev => [...prev, e.did]); 241 + // setLabelerErrors(prev => prev.filter(err => err.did !== e.did)); 242 + // }} 243 + // > 244 + // Disable 245 + // </button> 246 + // </li> 247 + <NotificationItem 248 + key={e.did} 249 + notification={e.did} 250 + labeler={true} 251 + labelererror={e.message || "unknown error"} 252 + /> 253 + ))} 254 + </div> 255 + <div className="flex justify-end gap-2 mx-4"> 256 + {/* <button 257 + className="px-3 py-1 rounded bg-gray-200 dark:bg-gray-700" 258 + onClick={() => setErrorModalOpen(false)} 259 + > 260 + Close 261 + </button> */} 262 + <button 263 + className="rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700 p-4 flex-1 flex items-center justify-center" 264 + onClick={() => { 265 + queryClient.refetchQueries({ 266 + predicate: query => { 267 + const key = query.queryKey; 268 + 269 + // Defensive: ensure shape matches 270 + if ( 271 + !Array.isArray(key) || 272 + key.length < 4 273 + ) return false; 274 + 275 + const [, kind, , labelerDid] = key; 276 + 277 + return ( 278 + kind === "slq" && 279 + typeof labelerDid === "string" && 280 + erroredLabelerDids.has(labelerDid) 281 + ); 282 + }, 283 + }); 284 + setLabelerErrors([]); 285 + setErrorModalOpen(false); 286 + // optional: retry all 287 + }} 288 + > 289 + Retry 290 + </button> 291 + </div> 292 + </div> 293 + </Dialog.Content> 294 + </Dialog.Root> 295 + )} 296 + </> 297 + ); 298 + } 299 + 300 + function convertLabelDefsToMap( 301 + labelDefPrefs: labeldefpref[], 302 + mergedPrefContentLabels: labelpref[] 303 + ): Map<string, HydratedLabelValueDefinition> { 304 + 305 + const labelMap = new Map<string, HydratedLabelValueDefinition>(); 306 + 307 + for (const pref of labelDefPrefs) { 308 + const did = pref.did; 309 + 310 + // Only process if labeldefs array exists 311 + if (pref.labeldefs && pref.labeldefs.length > 0) { 312 + for (const def of pref.labeldefs) { 313 + // Construct the key: did + "::" + identifier 314 + 315 + const key = `${did}::${def.identifier}`; 316 + 317 + const prefdlabelvis = mergedPrefContentLabels.find(i => i.did === did && i.label === def.identifier)?.visibility 318 + const hydrateddef: HydratedLabelValueDefinition = { 319 + ...def, 320 + pref: collapsePrefs(prefdlabelvis ? prefdlabelvis : def.defaultSetting) 321 + }; 322 + 323 + // Set the key and the LabelValueDefinition as the value 324 + labelMap.set(key, hydrateddef); 325 + } 326 + } 327 + } 328 + 329 + return labelMap; 330 + } 331 + 332 + function collapsePrefs(def: "ignore" | "warn" | "hide" | "show" | (string & {})): "ignore" | "warn" | "hide" { 333 + if (def === "ignore") return "ignore" 334 + if (def === "warn") return "warn" 335 + if (def === "hide") return "hide" 336 + if (def === "show") return "ignore" 337 + if (def === "alert") return "warn" 338 + if (def === "inform") return "warn" 339 + if (def === "none") return "ignore" 340 + return "ignore" 341 + }
+4 -4
src/providers/PollMutationQueueProvider.tsx
··· 299 299 const { agent } = useAuth(); 300 300 const agentDid = agent?.did; 301 301 302 - const userVotesA = useGetOneToOneState( 302 + const { uris: userVotesA } = useGetOneToOneState( 303 303 agentDid 304 304 ? { 305 305 target: pollUri, ··· 309 309 } 310 310 : undefined, 311 311 ); 312 - const userVotesB = useGetOneToOneState( 312 + const { uris: userVotesB } = useGetOneToOneState( 313 313 agentDid 314 314 ? { 315 315 target: pollUri, ··· 319 319 } 320 320 : undefined, 321 321 ); 322 - const userVotesC = useGetOneToOneState( 322 + const { uris: userVotesC } = useGetOneToOneState( 323 323 agentDid 324 324 ? { 325 325 target: pollUri, ··· 329 329 } 330 330 : undefined, 331 331 ); 332 - const userVotesD = useGetOneToOneState( 332 + const { uris: userVotesD } = useGetOneToOneState( 333 333 agentDid 334 334 ? { 335 335 target: pollUri,
+92 -7
src/routes/moderation.tsx
··· 12 12 import { useAtom } from "jotai"; 13 13 import { Switch } from "radix-ui"; 14 14 15 + import { FORCED_LABELER_DIDS } from "~/../policy"; 15 16 import { Header } from "~/components/Header"; 16 17 import { useModeration } from "~/hooks/useModeration"; 17 18 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 22 23 import { NotificationItem } from "./notifications"; 23 24 import { SettingHeading } from "./settings"; 24 25 26 + const FOUR_GLOBAL_LABELS = [ 27 + "porn", 28 + "sexual", 29 + "graphic-media", 30 + "nudity", 31 + ] as const; 32 + 33 + const FOUR_GLOBAL_LABELS_TEXT: Record<FourGlobalLabel, {title: string, desc: string}> = { 34 + porn: { 35 + title: "Adult Content", 36 + desc: "Explicit sexual images." 37 + }, 38 + sexual: { 39 + title: "Sexually Suggestive", 40 + desc: "Does not include nudity." 41 + }, 42 + "graphic-media": { 43 + title: "Graphic Media", 44 + desc: "Explicit or potentially disturbing media." 45 + }, 46 + nudity: { 47 + title: "Non-sexual Nudity", 48 + desc: "E.g. artistic nudes." 49 + } 50 + }; 51 + 52 + type FourGlobalLabel = typeof FOUR_GLOBAL_LABELS[number]; 53 + 54 + // todo please make this part of labeler resolution process / policies.ts 55 + const DEFAULT_FOUR_GLOBAL_PREFS: Record<FourGlobalLabel, ATPAPI.ComAtprotoLabelDefs.LabelValueDefinition["defaultSetting"]> = { 56 + porn: "ignore", 57 + sexual: "ignore", 58 + "graphic-media": "ignore", 59 + nudity: "ignore", 60 + }; 61 + 62 + function normalizeFourGlobalPrefs( 63 + prefs: Record<string, string>, 64 + ): Record<FourGlobalLabel, string> { 65 + return Object.fromEntries( 66 + FOUR_GLOBAL_LABELS.map((label) => [ 67 + label, 68 + prefs[label] ?? DEFAULT_FOUR_GLOBAL_PREFS[label], 69 + ]), 70 + ) as Record<FourGlobalLabel, string>; 71 + } 72 + 25 73 export const Route = createFileRoute("/moderation")({ 26 74 component: RouteComponent, 27 75 }); ··· 47 95 48 96 const parsedPref = parsePreferences(rawprefs); 49 97 98 + const hostmandate = FORCED_LABELER_DIDS 99 + 100 + const fourGlobalPrefs = normalizeFourGlobalPrefs(parsedPref?.contentLabelPrefs ?? {}) 101 + 102 + console.log(parsedPref?.labelers?.map(l => `&l=${l}`).join("") ?? "") 103 + 50 104 return ( 51 105 <div> 52 106 <Header 53 - title={`Moderation (WIP)`} 107 + title={`Moderation`} 54 108 backButtonCallback={() => { 55 109 if (window.history.length > 1) { 56 110 window.history.back(); ··· 77 131 - Verification settings 78 132 <br /> 79 133 </p> */} 80 - <SettingHeading title="Content Filters" /> 134 + <SettingHeading title="Moderation Tools" /> 135 + <div> 136 + TODO: hello please add the entire bsky mod tools set including but not limited to: 137 + Interaction settings, 138 + Muted Words & Tags, 139 + Moderation lists, 140 + Muted accounts, 141 + Blocked accounts, 142 + Verification settings 143 + 144 + </div> 145 + <SettingHeading title="Global Content Filters" /> 81 146 <div> 82 147 <div className="flex items-center gap-4 px-4 py-2 border-b"> 83 148 <label ··· 115 180 <TestModeration subject="did:plc:ia76kvnndjutgedggx2ibrem" /> 116 181 <TestModeration subject="did:plc:w2wbinubagmo4hlxx2ik5rrp" /> */} 117 182 <div className=""> 118 - {Object.entries(parsedPref?.contentLabelPrefs ?? {}).map( 183 + {Object.entries(fourGlobalPrefs).map( 119 184 ([label, visibility]) => ( 120 185 <div 121 186 key={label} ··· 126 191 className="flex flex-row flex-1" 127 192 > 128 193 <div className="flex flex-col"> 129 - <span className="text-md">{label}</span> 194 + <span className="text-md">{FOUR_GLOBAL_LABELS_TEXT[label as FourGlobalLabel].title}</span> 130 195 <span className="text-sm text-gray-500 dark:text-gray-400"> 131 - {"uknown labeler"} 196 + {FOUR_GLOBAL_LABELS_TEXT[label as FourGlobalLabel].desc} 132 197 </span> 133 198 </div> 134 199 </label> ··· 143 208 )} 144 209 </div> 145 210 </div> 146 - <SettingHeading title="Advanced" /> 211 + {/* probably replace "Advanced" with "User Subscribed Moderation Labelers" or something */} 212 + {hostmandate && (<SettingHeading title="Host-Mandated Labelers" />)} 213 + {hostmandate?.map((labeler) => { 214 + return ( 215 + // todo this sucks 216 + <NotificationItem 217 + key={labeler} 218 + notification={labeler} 219 + labeler={true} 220 + disablefollow={true} 221 + /> 222 + ); 223 + })} 224 + <SettingHeading title="Subscribed Labelers" /> 147 225 {parsedPref?.labelers.map((labeler) => { 148 226 return ( 227 + // todo this sucks 149 228 <NotificationItem 150 229 key={labeler} 151 230 notification={labeler} ··· 157 236 ); 158 237 } 159 238 239 + function ignoreToShow(input:string):string{ 240 + if (input === "ignore") { 241 + return "show" 242 + } 243 + return input 244 + } 160 245 export function TripleToggle({ 161 246 value, 162 247 onChange, ··· 186 271 }`} 187 272 > 188 273 {" "} 189 - {opt.charAt(0).toUpperCase() + opt.slice(1)} 274 + {ignoreToShow(opt).charAt(0).toUpperCase() + ignoreToShow(opt).slice(1)} 190 275 </button> 191 276 ); 192 277 })}
+144 -30
src/routes/notifications.tsx
··· 2 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 3 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 4 4 import { useAtom } from "jotai"; 5 + import { Switch } from "radix-ui"; 5 6 import * as React from "react"; 6 7 8 + import { FORCED_LABELER_DIDS } from "~/../policy"; 7 9 import defaultpfp from "~/../public/defaultpfp.png"; 8 10 import { Header } from "~/components/Header"; 9 11 import { ··· 16 18 import { useAuth } from "~/providers/UnifiedAuthProvider"; 17 19 import { 18 20 constellationURLAtom, 21 + disabledLabelersAtom, 19 22 enableBitesAtom, 20 23 imgCDNAtom, 21 24 postInteractionsFiltersAtom, ··· 28 31 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 29 32 } from "~/utils/useQuery"; 30 33 34 + import { renderSnack } from "./__root"; 31 35 import { FollowButton, Mutual } from "./profile.$did"; 32 36 33 37 export function NotificationsComponent() { ··· 134 138 ); 135 139 } 136 140 137 - export function FollowsTab({did}:{did?:string}) { 141 + export function FollowsTab({ did }: { did?: string }) { 138 142 const { agent } = useAuth(); 139 143 const userdidunsafe = did ?? agent?.did; 140 - const { data: identity} = useQueryIdentity(userdidunsafe); 144 + const { data: identity } = useQueryIdentity(userdidunsafe); 141 145 const userdid = identity?.did; 142 - 146 + 143 147 const [constellationurl] = useAtom(constellationURLAtom); 144 148 const infinitequeryresults = useInfiniteQuery({ 145 149 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( ··· 203 207 } 204 208 205 209 206 - export function BitesTab({did}:{did?:string}) { 210 + export function BitesTab({ did }: { did?: string }) { 207 211 const { agent } = useAuth(); 208 212 const userdidunsafe = did ?? agent?.did; 209 - const { data: identity} = useQueryIdentity(userdidunsafe); 213 + const { data: identity } = useQueryIdentity(userdidunsafe); 210 214 const userdid = identity?.did; 211 - 215 + 212 216 const [constellationurl] = useAtom(constellationURLAtom); 213 217 const infinitequeryresults = useInfiniteQuery({ 214 218 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 215 219 { 216 220 constellation: constellationurl, 217 221 method: "/links", 218 - target: "at://"+userdid, 222 + target: "at://" + userdid, 219 223 collection: "net.wafrn.feed.bite", 220 224 path: ".subject", 221 225 staleMult: 0 // safe fun ··· 393 397 <button 394 398 onClick={onClick} 395 399 className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all 396 - ${ 397 - state 398 - ? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent" 399 - : "bg-surface-container-low text-on-surface-variant border border-outline" 400 + ${state 401 + ? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent" 402 + : "bg-surface-container-low text-on-surface-variant border border-outline" 400 403 } 401 404 hover:bg-primary/30 active:scale-[0.97] 402 405 dark:border-outline-variant ··· 480 483 concise={true} 481 484 /> 482 485 <div className="flex flex-col divide-x"> 483 - {showLikes &&(<InteractionsButton 486 + {showLikes && (<InteractionsButton 484 487 type={"like"} 485 488 uri={uri} 486 489 count={likes} ··· 569 572 ); 570 573 } 571 574 572 - export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { 575 + export function NotificationItem({ notification, labeler, blocking = undefined, disablefollow = false, labelererror }: { notification: string, labeler?: boolean | string, blocking?: "unblock" | "blocked"; disablefollow?: boolean, labelererror?: string }) { 573 576 const aturi = new AtUri(notification); 574 577 const bite = aturi.collection === "net.wafrn.feed.bite"; 575 578 const navigate = useNavigate(); ··· 593 596 594 597 return ( 595 598 <div 596 - className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row" 599 + className={`flex items-center p-4 ${blocking ? "" : "cursor-pointer"} gap-3 justify-around border-b flex-row`} 597 600 onClick={() => 598 - aturi && 601 + aturi && !labelererror && 599 602 navigate({ 600 603 to: "/profile/$did", 601 604 params: { did: aturi.host }, ··· 611 614 <></> 612 615 )} 613 616 </div> */} 614 - {profile ? ( 615 - <img 616 - src={avatar || defaultpfp} 617 - alt={identity?.handle} 618 - className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`} 619 - /> 620 - ) : ( 621 - <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" /> 622 - )} 617 + {profile ? 618 + labeler && !avatar ? ( 619 + <div 620 + className={`w-10 h-10 shrink-0 rounded-md items-center justify-center flex object-cover border-1 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 621 + > 622 + <IconMdiShieldOutline className="w-6 h-6" /> 623 + </div> 624 + ) : ( 625 + <img 626 + src={avatar || defaultpfp} 627 + alt={identity?.handle} 628 + className={`w-10 h-10 shrink-0 ${labeler ? "rounded-md" : "rounded-full"}`} 629 + /> 630 + ) : ( 631 + <div className="w-10 h-10 shrink-0 rounded-full bg-gray-300 dark:bg-gray-700" /> 632 + )} 623 633 <div className="flex flex-col min-w-0"> 624 - <div className="flex flex-row gap-2 overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> 625 - <span className="font-medium text-gray-900 dark:text-gray-100 truncate"> 626 - {profile?.displayName || identity?.handle || "Someone"} 634 + <div className={`flex ${labelererror ? "flex-col gap-1 " : "flex-row gap-2"} overflow-hidden text-ellipsis whitespace-nowrap min-w-0 truncate`}> 635 + <span className="font-medium text-gray-900 dark:text-gray-100 truncate min-w-0"> 636 + {profile?.displayName || identity?.handle || identity?.did || aturi.host} 627 637 </span> 628 - <span className="text-gray-700 dark:text-gray-400 truncate"> 629 - @{identity?.handle} 638 + <span className="text-gray-700 dark:text-gray-400 truncate min-w-0"> 639 + {identity?.handle ? "@" + identity.handle : identity?.did || aturi.host} 630 640 </span> 641 + {labelererror && ( 642 + <span className="text-gray-700 dark:text-gray-400 truncate min-w-0"> 643 + error: {labelererror} 644 + </span> 645 + )} 631 646 </div> 632 647 <div className="flex flex-row gap-2"> 633 648 {identity?.did && <Mutual targetdidorhandle={identity?.did} />} ··· 637 652 </div> 638 653 </div> 639 654 <div className="flex-1" /> 640 - {identity?.did && <FollowButton targetdidorhandle={identity?.did} />} 655 + {!disablefollow && !blocking && identity?.did && !labeler && <FollowButton targetdidorhandle={identity?.did} />} 656 + {blocking === "blocked" && ( 657 + <div className="flex items-center shrink-0 font-medium rounded-md h-8 bg-gray-200 dark:bg-gray-700 px-3 py-2 text-[14px]"> 658 + Blocking You 659 + </div> 660 + )} 661 + {typeof labeler === "string" && ( 662 + <div className="flex items-center shrink-0 font-medium rounded-md h-8 bg-gray-200 dark:bg-gray-700 px-3 py-2 text-[14px]"> 663 + {labeler} 664 + </div> 665 + )} 666 + {blocking === "unblock" && ( 667 + <button 668 + onClick={() => { 669 + renderSnack({ 670 + title: "Sorry... Unblocking is not implemented yet", 671 + description: "You can use another app to unblock", 672 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 673 + }); 674 + }} 675 + className="group relative flex items-center justify-center shrink-0 font-medium rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 text-[14px] hover:cursor-pointer" 676 + > 677 + {/* invisible spacer */} 678 + <span className="invisible">Blocked by You</span> 679 + 680 + {/* visible */} 681 + <span className="absolute opacity-100 group-hover:opacity-0 transition-opacity"> 682 + Blocked by You 683 + </span> 684 + <span className="absolute opacity-0 group-hover:opacity-100 transition-opacity"> 685 + Unblock 686 + </span> 687 + </button> 688 + )} 689 + {labeler && !disablefollow && <LabelerToggleLocalEnablementButton labeler={identity?.did || aturi.host} />} 641 690 </div> 642 691 ); 643 692 } 693 + 694 + export function LabelerToggleLocalEnablementButton({ 695 + labeler, 696 + }: { 697 + labeler: string; 698 + }) { 699 + const [disabledLabelers, setDisabledLabelers] = useAtom(disabledLabelersAtom); 700 + const labelerEnabledState = !disabledLabelers.includes(labeler) 701 + const isMandatory = FORCED_LABELER_DIDS.includes(labeler) 702 + 703 + function toggleLocalLabelerEnabledState() { 704 + if (labeler) { 705 + if (labelerEnabledState) { 706 + console.log("button clicked disabled it") 707 + setDisabledLabelers([...disabledLabelers, labeler]) 708 + } else { 709 + console.log("button clicked enabled it") 710 + setDisabledLabelers(disabledLabelers.filter(v => v !== labeler)) 711 + } 712 + } 713 + } 714 + 715 + if (isMandatory) { 716 + return ( 717 + <> 718 + <span className=" shrink-0 font-medium relative inline-flex items-center rounded-lg text-sm px-3 py-1.5 bg-gray-300 dark:bg-gray-600 border border-outline dark:border-outline-variant">mandated by host</span> 719 + </> 720 + /** 721 + * relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all 722 + bg-surface-container-low text-on-surface-variant border border-outline 723 + hover:bg-primary/30 active:scale-[0.97] 724 + dark:border-outline-variant 725 + 726 + */ 727 + ) 728 + } 729 + 730 + return ( 731 + <Switch.Root 732 + id={`switch-${"hardcoded"}`} 733 + checked={labelerEnabledState} 734 + onClick={(e) => { 735 + e.stopPropagation(); 736 + toggleLocalLabelerEnabledState(); 737 + }} 738 + onCheckedChange={() => { 739 + // e.stopPropagation(); 740 + // toggleLocalLabelerEnabledState(); 741 + }} 742 + className="m3switch root shrink-0" 743 + > 744 + <Switch.Thumb className="m3switch thumb " /> 745 + </Switch.Root> 746 + // <button 747 + // onClick={(e) => { 748 + // e.stopPropagation(); 749 + // toggleLocalLabelerEnabledState(); 750 + // }} 751 + // className=" font-medium rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 752 + // > 753 + // {labelerEnabledState ? "Enabled" : "Disabled"} 754 + // </button> 755 + ); 756 + } 757 + 644 758 645 759 export const EmptyState = ({ text }: { text: string }) => ( 646 760 <div className="py-10 text-center text-gray-500 dark:text-gray-400">
+222 -20
src/routes/profile.$did/index.tsx
··· 6 6 import { useAtom } from "jotai"; 7 7 import React, { type ReactNode, useEffect, useState } from "react"; 8 8 9 + import { FORCE_HIDE_LABELS, FORCE_HIDE_LABELS_WHITELISTED_SOURCE } from "~/../policy"; 9 10 import defaultpfp from "~/../public/defaultpfp.png"; 10 11 import { Header } from "~/components/Header"; 11 12 import { ··· 14 15 } from "~/components/ReusableTabRoute"; 15 16 import { 16 17 SmallAuthorLabelBadge, 18 + SmallAuthorLabelBadgeInner, 17 19 UniversalPostRendererATURILoader, 18 20 } from "~/components/UniversalPostRenderer"; 19 21 import { renderTextWithFacets } from "~/components/UtilityFunctions"; 20 - import { useModeration } from "~/hooks/useModeration"; 22 + import { getGetHydratedLabelDefs, useAutoLabels } from "~/hooks/useAutoLabels"; 23 + import type { HydratedLabelValueDefinition } from "~/providers/AutoLabelProvider"; 24 + //import { useModeration } from "~/hooks/useModeration"; 21 25 import { useAuth } from "~/providers/UnifiedAuthProvider"; 22 26 import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 23 27 import { ··· 34 38 useQueryIdentity, 35 39 useQueryProfile, 36 40 } from "~/utils/useQuery"; 41 + // README this weird manual import is required because icon auto imports in this file (and some other files) are broken 42 + // for some reason which i dont know. 43 + import IconMdiMoreHoriz from "~icons/mdi/more-horiz.jsx"; 37 44 import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; 38 45 39 46 import { renderSnack } from "../__root"; 40 - import { Chip } from "../notifications"; 47 + import { Chip, NotificationItem } from "../notifications"; 41 48 42 49 export const Route = createFileRoute("/profile/$did/")({ 43 50 component: ProfileComponent, 44 51 }); 45 52 53 + export interface LabelWithHydratedLocaleName extends ATPAPI.ComAtprotoLabelDefs.Label { 54 + name: string 55 + } 56 + 46 57 function ProfileComponent() { 47 58 // booo bad this is not always the did it might be a handle, use identity.did instead 48 59 const { did } = Route.useParams(); ··· 55 66 error: identityError, 56 67 } = useQueryIdentity(did); 57 68 69 + const agentDid = agent?.did; 70 + const authorDid = identity?.did; 58 71 59 - const { isLoading: authorModLoading, labels: authorLabels } = useModeration( 60 - did, 72 + const userBlocksAuthor = useGetOneToOneState( 73 + agentDid && authorDid 74 + ? { 75 + target: authorDid, 76 + user: agentDid, 77 + collection: "app.bsky.graph.block", 78 + path: ".subject", 79 + } 80 + : undefined, 61 81 ); 82 + const authorBlocksUser = useGetOneToOneState( 83 + agentDid && authorDid 84 + ? { 85 + target: agentDid, 86 + user: authorDid, 87 + collection: "app.bsky.graph.block", 88 + path: ".subject", 89 + } 90 + : undefined, 91 + ); 92 + 93 + const redactWhileLoadingBlock = userBlocksAuthor.isLoading || authorBlocksUser.isLoading 94 + const redactFinalBlock = userBlocksAuthor.uris.length > 0 || authorBlocksUser.uris.length > 0 95 + 96 + const subjects = identity ? [ 97 + identity.did, 98 + `at://${identity.did}/app.bsky.actor.profile/self`, 99 + ] : [] 100 + 101 + const { 102 + results: labelResults, 103 + hydratedLabelDefs, 104 + } = useAutoLabels({ 105 + subjects, 106 + type: "post", // or whatever you’re keying on for now 107 + }) 108 + 109 + const ghld = getGetHydratedLabelDefs(hydratedLabelDefs) 110 + const accountResult = labelResults.get(identity?.did || did) 111 + const profileResult = labelResults.get( 112 + `at://${identity?.did || did}/app.bsky.actor.profile/self`, 113 + ) 114 + 115 + const accountLabelVerdict = accountResult?.labelVerdict ?? "unknown" 116 + const authorLabels = accountResult?.labels ?? [] 117 + 118 + const profileLabelVerdict = profileResult?.labelVerdict ?? "unknown" 119 + const profileLabels = profileResult?.labels ?? [] 120 + 121 + const authorModUnknown = accountLabelVerdict === "unknown"; 122 + const profileModUnknown = profileLabelVerdict === "unknown"; 123 + 124 + const authorModLoading = accountLabelVerdict === "loading"; 125 + const profileModLoading = profileLabelVerdict === "loading"; 126 + 127 + const authorModError = accountLabelVerdict === "error"; 128 + const profileModError = profileLabelVerdict === "error"; 129 + 130 + const strictModerationUnknown = authorModUnknown || profileModUnknown 131 + const strictModerationLoading = authorModLoading || profileModLoading || redactWhileLoadingBlock 132 + const strictModerationError = authorModError || profileModError 133 + 134 + const strictModerationDontShow = strictModerationUnknown || strictModerationLoading || strictModerationError || redactFinalBlock 135 + 136 + const verdictDebugString = `accountLabelVerdict: ${accountLabelVerdict}, profileLabelVerdict: ${profileLabelVerdict}` 137 + 62 138 const hideAuthorLabels = authorLabels.filter( 63 - label => label.preference === 'hide' 139 + (label) => ghld(label.src,label.val)?.pref === "hide", 64 140 ); 65 141 const warnAuthorLabels = authorLabels.filter( 66 - label => label.preference === 'warn' 142 + (label) => ghld(label.src,label.val)?.severity === "warn" && ghld(label.src,label.val)?.pref === "warn", 143 + ); 144 + const informAuthorLabels: LabelWithHydratedLocaleName[] = authorLabels.flatMap( 145 + (label) => { 146 + if (ghld(label.src,label.val)?.severity === "inform" && ghld(label.src,label.val)?.pref === "warn") { 147 + return [{ 148 + ...label, 149 + name: getLocaleLabel(ghld(label.src,label.val))?.name || label.val 150 + }] 151 + } 152 + return [] 153 + }, 154 + ); 155 + const hideProfileLabels = profileLabels.filter( 156 + (label) => ghld(label.src,label.val)?.pref === "hide", 157 + ); 158 + const warnProfileLabels = profileLabels.filter( 159 + (label) => ghld(label.src,label.val)?.pref === "warn", 67 160 ); 68 161 69 162 // i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc) ··· 123 216 124 217 const followercount = resultwhateversure?.data?.total; 125 218 219 + const isForceHidden = [...[...authorLabels].filter((label) => { 220 + return ( 221 + FORCE_HIDE_LABELS.has(label.val) && 222 + FORCE_HIDE_LABELS_WHITELISTED_SOURCE.has(label.src) 223 + ); 224 + }), ...hideAuthorLabels]; 225 + 226 + // // todo remove this replace it with blurs 227 + // if (strictModerationLoading) { 228 + // return ( 229 + // <div className=""> 230 + // <Header 231 + // title={`Loading Profile`} 232 + // backButtonCallback={() => { 233 + // if (window.history.length > 1) { 234 + // window.history.back(); 235 + // } else { 236 + // window.location.assign("/"); 237 + // } 238 + // }} 239 + // bottomBorderDisabled={true} 240 + // /> 241 + // <div className=" leading-normal flex flex-col gap-4 p-4"> 242 + // <span>DEBUG LOADING LABELS</span> 243 + // <span>{identity?.did || did}</span> 244 + // <span>{verdictDebugString}</span> 245 + // </div> 246 + // </div> 247 + // ); 248 + // } 249 + 250 + console.log("HLLO HLLO HisForceHidden" + did + isForceHidden + authorLabels) 251 + if (isForceHidden.length > 0 || redactFinalBlock) { 252 + // todo pretify this please 253 + return ( 254 + <div className=""> 255 + <Header 256 + title={`Hidden Profile`} 257 + backButtonCallback={() => { 258 + if (window.history.length > 1) { 259 + window.history.back(); 260 + } else { 261 + window.location.assign("/"); 262 + } 263 + }} 264 + bottomBorderDisabled={true} 265 + /> 266 + <div className="p-4"> 267 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 flex flex-col gap-4"> 268 + {/* todo: separate host-mandated labelers from user-picked labelers. 269 + currently assumes all labelers that hides profiles are host-mandated */} 270 + <p>This profile is hidden due to these reason(s):</p> 271 + {isForceHidden.map((item) => { 272 + return ( 273 + // todo this sucks 274 + <NotificationItem 275 + key={item.src} 276 + notification={item.src} 277 + labeler={getLocaleLabel(ghld(item.src, item.val))?.name || item.val} 278 + disablefollow={true} 279 + /> 280 + ) 281 + })} 282 + {/* todo add unblock button duhhhhhh */} 283 + {/* {userBlocksAuthor.uris.length > 0 && (<div className="p-4">User Blocked by You</div>)} */} 284 + {userBlocksAuthor.uris.length > 0 && ( 285 + <NotificationItem 286 + notification={authorDid||did} 287 + blocking={"unblock"} 288 + /> 289 + )} 290 + {/* {authorBlocksUser.uris.length > 0 && (<div className="p-4">User Blocking You</div>)} */} 291 + {authorBlocksUser.uris.length > 0 && ( 292 + <NotificationItem 293 + notification={authorDid||did} 294 + blocking={"blocked"} 295 + /> 296 + )} 297 + <div className="flex flex-row gap-2"> 298 + <Link to="/moderation" className="flex-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700 p-4 flex items-center justify-center"> 299 + <span>Moderation Settings</span> 300 + </Link> 301 + <Link to="/about" className="flex-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700 p-4 flex items-center justify-center"> 302 + <span>Host instance's policies</span> 303 + </Link> 304 + </div> 305 + </div> 306 + </div> 307 + </div> 308 + ) 309 + } 126 310 return ( 127 311 <div className=""> 128 312 <Header 129 - title={`Profile`} 313 + title={`${strictModerationLoading ? "Loading " :" "}Profile`} 130 314 backButtonCallback={() => { 131 315 if (window.history.length > 1) { 132 316 window.history.back(); ··· 161 345 <div 162 346 className="w-full h-40 bg-gray-300 dark:bg-gray-700" 163 347 style={{ 164 - backgroundImage: `url(${getBannerUrl(profile)})`, 348 + backgroundImage: strictModerationLoading ? undefined : `url(${getBannerUrl(profile)})`, 349 + backgroundColor: strictModerationLoading ? "var(--color-placeholder)" : undefined, 165 350 backgroundSize: "cover", 166 351 backgroundPosition: "center", 167 352 }} ··· 169 354 170 355 {/* Avatar (PFP) */} 171 356 <div className="absolute left-[16px] top-[100px] "> 172 - {!getAvatarUrl(profile) && isLabeler ? ( 357 + {strictModerationLoading ? ( 358 + <div 359 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700 overflow-clip`} 360 + > 361 + <div className={`w-28 h-28 bg-gray-400 dark:bg-gray-600 animate-pulse`} 362 + /> 363 + </div> 364 + ) : !getAvatarUrl(profile) && isLabeler ? ( 173 365 <div 174 366 className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} items-center justify-center flex object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 175 367 > ··· 194 386 */} 195 387 <FollowButton targetdidorhandle={did} /> 196 388 <button 197 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 389 + className="rounded-full h-10 w-10 text-[15px] flex justify-center items-center bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors" 198 390 onClick={(e) => { 199 391 renderSnack({ 200 392 title: "Not Implemented Yet", ··· 203 395 }); 204 396 }} 205 397 > 206 - ... {/* todo: icon */} 398 + <IconMdiMoreHoriz /> 207 399 </button> 208 400 </div> 209 401 210 402 {/* Info Card */} 211 - <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 403 + <div className="mt-14 pb-2 px-4 text-gray-900 dark:text-gray-100"> 212 404 <div className="font-bold text-2xl">{displayName}</div> 213 405 <div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1"> 214 406 <Mutual targetdidorhandle={did} /> ··· 228 420 Follows 229 421 </Link> 230 422 </div> 231 - {description && ( 423 + {!strictModerationLoading && description && ( 232 424 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 233 425 {/* {description} */} 234 426 <RichTextRenderer key={did} description={description} /> ··· 257 449 : 258 450 ( 259 451 <div className="flex flex-wrap flex-row gap-1"> 260 - {warnAuthorLabels.map((label, index) => ( 261 - <SmallAuthorLabelBadge label={label} key={label.cts + label.sourceDid + label.val} large /> 452 + {/* authorLabels{JSON.stringify(authorLabels,null,2)} */} 453 + {informAuthorLabels.map((label, index) => ( 454 + <SmallAuthorLabelBadge label={label} key={label.cts + label.src + label.val} large /> 262 455 ))} 263 456 </div> 264 457 ) ··· 968 1161 queryClient: queryClient, 969 1162 }); 970 1163 }} 971 - className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 1164 + className=" shrink-0 font-medium rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 972 1165 > 973 1166 Follow 974 1167 </button> ··· 983 1176 queryClient: queryClient, 984 1177 }); 985 1178 }} 986 - className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 1179 + className=" shrink-0 font-medium rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 987 1180 > 988 1181 Unfollow 989 1182 </button> ··· 991 1184 </> 992 1185 ) : ( 993 1186 <button 994 - className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 1187 + className=" shrink-0 font-medium rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 995 1188 onClick={(e) => { 996 1189 renderSnack({ 997 1190 title: "Not Implemented Yet", ··· 1028 1221 targetDid: identity?.did, 1029 1222 }); 1030 1223 }} 1031 - className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 1224 + className=" shrink-0 font-medium rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 1032 1225 > 1033 1226 Bite 1034 1227 </button> ··· 1083 1276 const { agent } = useAuth(); 1084 1277 const { data: identity } = useQueryIdentity(targetdidorhandle); 1085 1278 1086 - const theyFollowYouRes = useGetOneToOneState( 1279 + const { uris: theyFollowYouRes } = useGetOneToOneState( 1087 1280 agent?.did 1088 1281 ? { 1089 1282 target: agent?.did, ··· 1177 1370 1178 1371 return <>{richDescription}</>; 1179 1372 } 1373 + 1374 + export function getLocaleLabel(hlvd?: HydratedLabelValueDefinition) { 1375 + if (!hlvd) return undefined 1376 + const userLang = "en"; 1377 + const locale = hlvd.locales.find((l) => l.lang === userLang) 1378 + || hlvd.locales.find((l) => l.lang === "en") 1379 + || hlvd.locales[0]; 1380 + return locale 1381 + }
+15
src/styles/app.css
··· 374 374 transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 375 375 } 376 376 } 377 + } 378 + 379 + /* todo: check this. might break everything */ 380 + html, :host { 381 + line-height: normal; 382 + } 383 + 384 + :root { 385 + /* bg-gray-300 dark:bg-gray-600 */ 386 + --color-placeholder: var(--color-gray-300); 387 + } 388 + @media (prefers-color-scheme: dark) { 389 + :root { 390 + --color-placeholder: var(--color-gray-600); 391 + } 377 392 }
+7 -1
src/utils/atoms.ts
··· 2 2 import { atomWithStorage } from "jotai/utils"; 3 3 import { useEffect } from "react"; 4 4 5 + import { HOST_DEFAULT_HUE } from "~/../policy"; 5 6 import { type ProfilePostsFilter } from "~/routes/profile.$did"; 6 7 7 8 export const store = createStore(); ··· 98 99 defaultLycanURL 99 100 ); 100 101 101 - export const defaulthue = 28; 102 + export const disabledLabelersAtom = atomWithStorage<string[]>( 103 + "disabledLabelers", 104 + [] 105 + ); 106 + 107 + export const defaulthue = HOST_DEFAULT_HUE; 102 108 export const hueAtom = atomWithStorage<number>("hue", defaulthue); 103 109 104 110 export const isAtTopAtom = atom<boolean>(true);
+26 -9
src/utils/followState.ts
··· 1 1 import { type Agent, AtUri } from "@atproto/api"; 2 2 import { TID } from "@atproto/common-web"; 3 - import type { QueryClient } from "@tanstack/react-query"; 3 + import type { QueryClient, UseQueryResult } from "@tanstack/react-query"; 4 4 5 5 import { type linksRecordsResponse, useQueryConstellation } from "./useQuery"; 6 6 ··· 134 134 user: string; 135 135 collection: string; 136 136 path: string; 137 - }): string[] | undefined { 138 - const { data: arbitrarydata } = useQueryConstellation( 137 + }): { 138 + uris: string[], 139 + isLoading: boolean; 140 + isError: boolean; 141 + } { 142 + const whatever = useQueryConstellation( 139 143 params && params.user 140 144 ? { 141 145 method: "/links", ··· 151 155 } 152 156 : { method: "undefined", target: "whatever" }, 153 157 // overloading sucks so much 154 - ) as { data: linksRecordsResponse | undefined }; 155 - if (!params || !params.user) return undefined; 158 + ) as UseQueryResult<linksRecordsResponse | undefined, Error>; 159 + if (!params || !params.user) return { 160 + uris: [], 161 + isError: true, 162 + isLoading: false, 163 + }; 164 + const arbitrarydata = whatever.data; 156 165 const data = arbitrarydata?.linking_records.slice(0, 50) ?? []; 157 166 158 167 if (data.length > 0) { 159 - return data.map((linksRecord) => { 160 - return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`; 161 - }); 168 + return { 169 + uris: data.map((linksRecord) => { 170 + return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`; 171 + }), 172 + isError: false, 173 + isLoading: false, 174 + }; 162 175 } 163 176 164 - return undefined; 177 + return { 178 + uris: [], 179 + isError: whatever.isLoading, 180 + isLoading: whatever.isError, 181 + }; 165 182 }
+21 -10
src/utils/useHydrated.ts
··· 84 84 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 85 85 cdn: string 86 86 ): $Typed<AppBskyEmbedRecord.View> | undefined { 87 + // if (!quotedPost || !quotedProfile || !quotedIdentity) { 88 + // return undefined; 89 + // } 87 90 if (!quotedPost || !quotedProfile || !quotedIdentity) { 88 - return undefined; 91 + const failureViewRecord: $Typed<AppBskyEmbedRecord.ViewNotFound> = asTyped({ 92 + $type: "app.bsky.embed.record#viewNotFound" as const, 93 + uri: embed.record.uri, 94 + notFound: true as const, 95 + }) 96 + 97 + return asTyped({ 98 + $type: "app.bsky.embed.record#view" as const, 99 + record: failureViewRecord, 100 + }); 89 101 } 90 102 91 103 const author: $Typed<AppBskyActorDefs.ProfileViewBasic> = asTyped({ ··· 194 206 const hydratedEmbed: HydratedEmbedView | undefined = (() => { 195 207 if (!embed || !postAuthorDid) return undefined; 196 208 197 - if ( 198 - isRecordType && 199 - (!usequerypostresults?.data || 200 - !quotedProfile || 201 - !queryidentityresult?.data) 202 - ) { 203 - return undefined; 204 - } 205 - 209 + // if ( 210 + // isRecordType && 211 + // (!usequerypostresults?.data || 212 + // !quotedProfile || 213 + // !queryidentityresult?.data) 214 + // ) { 215 + // return undefined; 216 + // } 206 217 try { 207 218 if (AppBskyEmbedImages.isMain(embed)) { 208 219 return hydrateEmbedImages(embed, postAuthorDid, imgcdn);
+411 -2
src/utils/useQuery.ts
··· 1 1 import * as ATPAPI from "@atproto/api"; 2 2 import { 3 3 infiniteQueryOptions, 4 + QueryClient, 4 5 type QueryFunctionContext, 5 6 queryOptions, 6 7 useInfiniteQuery, 8 + useQueries, 7 9 useQuery, 8 10 type UseQueryResult, 9 11 } from "@tanstack/react-query"; 12 + import { create, windowScheduler } from "@yornaath/batshit"; 10 13 import { useAtom } from "jotai"; 14 + import { useMemo } from "react"; 11 15 16 + import { HOST_LABELMERGE } from "~/../policy"; 17 + import type { 18 + Error as LabelMergeQueryLabelsOutputSchemaError, 19 + OutputSchema as LabelMergeQueryLabelsOutputSchema, 20 + QueryParams as LabelMergeQueryLabelsQueryParams, 21 + } from "~/api/labelmerge/types/app/reddwarf/labelmerge/queryLabels"; 12 22 import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 23 14 24 import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; ··· 387 397 | undefined { 388 398 //if (!query) return; 389 399 const [constellationurl] = useAtom(constellationURLAtom); 390 - return useQuery( 400 + const res = useQuery( 391 401 constructConstellationQuery( 392 402 query && { constellation: constellationurl, ...query }, 393 403 ), 394 404 ); 405 + return res 395 406 } 396 407 397 408 export type linksRecord = { ··· 532 543 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; 533 544 const res = await agent.fetchHandler(url, { method: "GET" }); 534 545 if (!res.ok) throw new Error("Failed to fetch preferences"); 535 - return res.json(); 546 + // todo, i just gave it real types (atproto api types) so theres gonna be a bunch of errors so pls fix thx 547 + return (await res.json()) as ATPAPI.AppBskyActorGetPreferences.OutputSchema; 536 548 }, 537 549 }); 538 550 } ··· 975 987 getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 976 988 }); 977 989 } 990 + 991 + // HOST_LABELMERGE 992 + 993 + export async function innerLabelMergeQueryFn(options: LabelMergeQueryLabelsQueryParams): Promise<LabelMergeQueryLabelsOutputSchema | undefined> { 994 + const { s, l, strict } = options; 995 + const params = new URLSearchParams(); 996 + s.forEach((v) => params.append("s", v)); 997 + l.forEach((v) => params.append("l", v)); 998 + if (strict) { 999 + params.append("strict", "true"); 1000 + } 1001 + const qs = params.toString(); 1002 + 1003 + const url = 1004 + `${HOST_LABELMERGE}/xrpc/app.reddwarf.labelmerge.queryLabels?` + qs; 1005 + console.log("LabelMerge URL", url); 1006 + const res = await fetch(url); 1007 + if (!res.ok) 1008 + throw new Error(`Labelmerge fetch failed: ${res.statusText}`); 1009 + return (await res.json()) as LabelMergeQueryLabelsOutputSchema; 1010 + } 1011 + 1012 + export function constructLabelMergeQuery( 1013 + options: LabelMergeQueryLabelsQueryParams, 1014 + ) { 1015 + const { s, l, strict } = options; 1016 + 1017 + return queryOptions({ 1018 + queryKey: [ 1019 + "LabelMergeQueryLabelsQuery", 1020 + [...s].sort().join(","), 1021 + [...l].sort().join(","), 1022 + strict, 1023 + ], 1024 + 1025 + enabled: 1026 + Array.isArray(s) && s.length > 0 && Array.isArray(l) && l.length > 0, 1027 + 1028 + queryFn: ()=>innerLabelMergeQueryFn(options), 1029 + }); 1030 + } 1031 + export function useQueryLabelMerge(options: LabelMergeQueryLabelsQueryParams) { 1032 + return useQuery(constructLabelMergeQuery(options)); 1033 + } 1034 + 1035 + export type PartialLabelQuery = { 1036 + s: string; 1037 + l: string[]; 1038 + }; 1039 + export type SingularLabelQuery = { 1040 + s: string; 1041 + l: string; 1042 + }; 1043 + 1044 + export type SingularLabelResult = { 1045 + labels?: ATPAPI.ComAtprotoLabelDefs.Label; 1046 + error?: LabelMergeQueryLabelsOutputSchemaError; 1047 + }; //ATPAPI.ComAtprotoLabelDefs.Label | LabelMergeQueryLabelsOutputSchemaError | null 1048 + export type PartialLabelResult = { 1049 + subject: string; 1050 + labels?: ATPAPI.ComAtprotoLabelDefs.Label[]; 1051 + error?: LabelMergeQueryLabelsOutputSchemaError[]; 1052 + }; 1053 + 1054 + function flattenLabelQueries( 1055 + partials: PartialLabelQuery[], 1056 + ): SingularLabelQuery[] { 1057 + return partials.flatMap((p) => p.l.map((label) => ({ s: p.s, l: label }))); 1058 + } 1059 + 1060 + // batShitQueryClient 1061 + export const unpersistedQueryClient = new QueryClient(/*{ 1062 + defaultOptions: { 1063 + queries: { 1064 + staleTime: 5 * 60 * 1000, // 5 minutes 1065 + //cacheTime: 10 * 60 * 1000, // 10 minutes 1066 + gcTime: 5 * 60 * 1000, 1067 + }, 1068 + }, 1069 + }*/); 1070 + 1071 + 1072 + interface MicroSingleResult { 1073 + l: string, 1074 + t: number, 1075 + } 1076 + 1077 + const labelmerge = create( 1078 + /*<Record<String,SingularLabelResult>[], SingularLabelQuery>*/ { 1079 + // The fetcher resolves the list of queries(here just a list of user ids as number) to one single api call. 1080 + fetcher: async (slqa: SingularLabelQuery[]) => { 1081 + // Use a shared QueryClient if possible; creating a new one per fetch is usually not needed 1082 + 1083 + // Deduplicate, but don’t sort 1084 + const sarr = Array.from(new Set(slqa.map((slq) => slq.s))); 1085 + const larr = Array.from(new Set(slqa.map((slq) => slq.l))); 1086 + 1087 + //const result = await batShitQueryClient.fetchQuery( 1088 + // constructLabelMergeQuery({ s: sarr, l: larr }), 1089 + //); 1090 + const result = await innerLabelMergeQueryFn({s:sarr, l: larr}) 1091 + //const qfn = constructLabelMergeQuery({ s: sarr, l: larr }).queryFn 1092 + //const result = await (qfn ? qfn() : ()=>{}) 1093 + if (!result) return []; 1094 + 1095 + // Build maps for quick lookup 1096 + const errmap = new Map<string, LabelMergeQueryLabelsOutputSchemaError>(); 1097 + const resmap = new Map<string, ATPAPI.ComAtprotoLabelDefs.Label>(); 1098 + 1099 + result.error?.forEach((err) => errmap.set(err.s, err)); 1100 + result.labels?.forEach((label) => resmap.set(`${label.src}::${label.uri}`, label)); 1101 + 1102 + // Map back to the original queries 1103 + const output: Record<string, SingularLabelResult>[] = slqa.map((slq) => { 1104 + const key = `${slq.l}::${slq.s}`; // or just slq.l if you prefer 1105 + 1106 + const err = errmap.get(slq.l); 1107 + const label = resmap.get(key); 1108 + 1109 + if (err) return { [key]: { error: err } }; 1110 + if (label) return { [key]: { labels: label } }; 1111 + 1112 + // if result is neither, it means the subject is free of labels 1113 + return { 1114 + [key]: { labels: undefined} 1115 + }; 1116 + // idiot 1117 + // return { 1118 + // [key]: { error: { 1119 + // s: slq.l, 1120 + // e: `!internal-bslm-unknown: ${slq.s}` 1121 + // }} 1122 + // }; 1123 + }); 1124 + 1125 + return output; 1126 + }, 1127 + // when we call users.fetch, this will resolve the correct user using the field `id` 1128 + resolver: (rslra, slq) => { 1129 + if (rslra.length < 1) { 1130 + return undefined; 1131 + } 1132 + // const result: SingularLabelResult | undefined = slra.find((slr, i) => { 1133 + // // find if error first 1134 + // const error = slr.error; 1135 + // const label = slr.labels; 1136 + // if (error) { 1137 + // if (slq.l === error.s) { 1138 + // return slq; 1139 + // } 1140 + // } else if (label) { 1141 + // // if not error 1142 + // if (slq.l === label.src && slq.s === label.uri) { 1143 + // return slq; 1144 + // } 1145 + // // else unhandled not found 1146 + // } else { 1147 + // return undefined; 1148 + // } 1149 + // return undefined; 1150 + // }); 1151 + const outputMap: Record<string, SingularLabelResult> = Object.assign({}, ...rslra) 1152 + const key = `${slq.l}::${slq.s}`; // or just slq.l if you prefer 1153 + const result: SingularLabelResult | undefined = outputMap[key] 1154 + return result; 1155 + }, 1156 + scheduler: windowScheduler(10 * 100), // 1 second 1157 + }, 1158 + ); 1159 + 1160 + // const labelmergepartial = create/*<PartialLabelResult[], PartialLabelQuery>*/({ 1161 + // // The fetcher resolves the list of queries(here just a list of user ids as number) to one single api call. 1162 + // fetcher: async (plqa: PartialLabelQuery[]) => { 1163 + // const singulars = flattenLabelQueries(plqa); // SingularLabelQuery[] 1164 + // const singularResults = await Promise.all(singulars.map(q => labelmerge.fetch(q))); 1165 + 1166 + // // Now we need to **group singularResults back by the original PartialLabelQuery.s** 1167 + // // so that each PartialLabelQuery gets a PartialLabelResult (LabelMergeQueryLabelsOutputSchema) 1168 + // const grouped: Record<string, SingularLabelResult[]> = {}; 1169 + // singulars.forEach((q, i) => { 1170 + // if (!grouped[q.s]) grouped[q.s] = []; 1171 + // if (singularResults[i]) { 1172 + // grouped[q.s].push(singularResults[i]); 1173 + // } else { 1174 + // grouped[q.s].push({}); 1175 + // } 1176 + // }); 1177 + 1178 + // // Convert grouped record to your PartialLabelResult format 1179 + // const result: PartialLabelResult[] = Object.entries(grouped).map(([s, labels]) => { 1180 + // const cleanLabels = labels 1181 + // .map(l => l?.labels) 1182 + // .filter((l): l is ATPAPI.ComAtprotoLabelDefs.Label => !!l?.val) 1183 + // const cleanErrors = labels 1184 + // .map(l => l?.error) 1185 + // .filter((e): e is LabelMergeQueryLabelsOutputSchemaError => !!e?.s) 1186 + // return { 1187 + // subject: s, 1188 + // labels: cleanLabels, 1189 + // error: cleanErrors ? cleanErrors : undefined 1190 + // } 1191 + // }); 1192 + // return result 1193 + // }, 1194 + // resolver: (plra, plq) => { 1195 + // if (plra.length < 1) { 1196 + // return undefined 1197 + // } 1198 + // const subject = plq.s; 1199 + // const result: PartialLabelResult | undefined = plra.find((plr,i)=>{ 1200 + // return plr.subject === subject; 1201 + // }) 1202 + // return result 1203 + // }, 1204 + // // this will batch all calls to users.fetch that are made within 10 milliseconds. 1205 + // scheduler: windowScheduler(10*100) // 1 second 1206 + // }) 1207 + 1208 + // export const useQueryLabel = (s: string, la: string[]) => { 1209 + // return useQuery({ 1210 + // queryKey: ["useQueryLabel (single) sla", s, la], 1211 + // queryFn: async () => { 1212 + // return labelmergepartial.fetch({ s, l: la }) 1213 + // }, 1214 + // }) 1215 + // } 1216 + 1217 + /** 1218 + * todo: 1219 + * - [x] switch from useQuery to normal custom hook and switch from Promise.All to useQueries 1220 + * - [ ] Move neg normalization to the batshit unmerging, and make the cache labels only (pre sorted) 1221 + * - [ ] Also do signature verification on the constructSingularQuery 1222 + */ 1223 + 1224 + // also the cache hits from constructSingularLabelQuery is not being sent fast because 1225 + // it waits for all of them first 1226 + // but also if we send it fast would it cause even worse synchronous traffic jams downstream ? 1227 + // export const useQueryLabels = (subjects: string[], labelers: string[]) => { 1228 + // const queryClient = useQueryClient(); 1229 + // return useQuery({ 1230 + // queryKey: ["useQueryLabelFull", subjects, labelers], 1231 + // queryFn: async (): Promise<LabelMergeQueryLabelsOutputSchema> => { 1232 + // // Build all singular queries 1233 + // const singulars: SingularLabelQuery[] = subjects.flatMap((s) => 1234 + // labelers.map((l) => ({ s, l })), 1235 + // ); 1236 + 1237 + // // Fetch all results in parallel 1238 + // const results = await Promise.all( 1239 + // // singulars.map((q) => 1240 + // // queryClient.fetchQuery(constructSingularLabelQuery(q)), 1241 + // // ), 1242 + // singulars.map((q) => 1243 + // queryClient.fetchQuery(constructSingularLabelQuery(q)).catch((e: Error)=>{ 1244 + // return { 1245 + // error: { 1246 + // s: q.l, 1247 + // e: e.message.toString(), 1248 + // } 1249 + // } as SingularLabelResult 1250 + // }), 1251 + // ), 1252 + // // singulars.map(q => queryClient.fetchQuery(constructSingularLabelQuery(q)).catch((err:SingularLabelResult)=>{ 1253 + // // return err 1254 + // // })) 1255 + // //singulars.map(q => labelmerge.fetch(q).catch(err => ({ error: err } as SingularLabelResult))) 1256 + // ); 1257 + 1258 + // const labels = Array.from( 1259 + // new Map( 1260 + // results 1261 + // .map(r => r?.labels) 1262 + // .filter((l): l is ATPAPI.ComAtprotoLabelDefs.Label => !!l?.src) 1263 + // .map(l => [`${l.src}::${l.uri}`, l]) 1264 + // ).values() 1265 + // ); 1266 + // const errors = Array.from( 1267 + // new Map( 1268 + // results 1269 + // .map(r => r?.error) 1270 + // .filter( 1271 + // (e): e is LabelMergeQueryLabelsOutputSchemaError => 1272 + // !!e && typeof e.s === "string" 1273 + // ) 1274 + // .map(e => [`${e.s}::${e.e ?? ""}`, e]) 1275 + // ).values() 1276 + // ); 1277 + 1278 + // const result: LabelMergeQueryLabelsOutputSchema = { 1279 + // labels: labels, 1280 + // error: errors.length < 1 ? undefined : errors, 1281 + // }; 1282 + 1283 + // return result; 1284 + // }, 1285 + // }); 1286 + // }; 1287 + 1288 + function buildSingularQueries(subjects: string[], labelers: string[]) { 1289 + return subjects.flatMap((s) => 1290 + labelers.map((l) => ({ 1291 + s, 1292 + l, 1293 + })), 1294 + ); 1295 + } 1296 + 1297 + export function useQueryLabels(subjects: string[], labelers: string[]) { 1298 + const singulars = useMemo( 1299 + () => buildSingularQueries(subjects, labelers), 1300 + [subjects, labelers], 1301 + ); 1302 + 1303 + const queries = useQueries({ 1304 + queries: singulars.map((q) => 1305 + constructSingularLabelQuery(q), 1306 + ), 1307 + }); 1308 + 1309 + // derive merged state synchronously 1310 + const labels = useMemo(() => { 1311 + return Array.from( 1312 + new Map( 1313 + queries 1314 + .map((q) => q.data?.labels) 1315 + .filter( 1316 + (l): l is ATPAPI.ComAtprotoLabelDefs.Label => 1317 + !!l?.src, 1318 + ) 1319 + .map((l) => [`${l.src}::${l.uri}`, l]), 1320 + ).values(), 1321 + ); 1322 + }, [queries]); 1323 + 1324 + const errors = useMemo(() => { 1325 + return Array.from( 1326 + new Map( 1327 + queries 1328 + .map((q) => q.data?.error) 1329 + .flat() 1330 + .filter( 1331 + (e): e is LabelMergeQueryLabelsOutputSchemaError => 1332 + !!e && typeof e.s === "string", 1333 + ) 1334 + .map((e) => [`${e.s}::${e.e ?? ""}`, e]), 1335 + ).values(), 1336 + ); 1337 + }, [queries]); 1338 + 1339 + const isLoading = queries.some((q) => q.isLoading); 1340 + const isError = queries.some((q) => q.isError); 1341 + const isFetching = queries.some((q) => q.isFetching); 1342 + 1343 + return { 1344 + data: { 1345 + labels, 1346 + error: errors.length ? errors : undefined, 1347 + }, 1348 + isLoading, 1349 + isError, 1350 + isFetching, 1351 + }; 1352 + } 1353 + 1354 + export function constructSingularLabelQuery(options: SingularLabelQuery) { 1355 + const { s, l } = options; 1356 + 1357 + return queryOptions({ 1358 + queryKey: ["__volatile","slq", s, l], 1359 + 1360 + enabled: !!s && !!l, 1361 + 1362 + queryFn: async (): Promise<SingularLabelResult | undefined> => { 1363 + // const result = (await labelmerge.fetch(options).catch(err => {throw { error: err } as SingularLabelResult})) as SingularLabelResult 1364 + // if (result.error) { 1365 + // throw result.error 1366 + // } 1367 + // return result; 1368 + const result = (await labelmerge 1369 + .fetch(options) 1370 + .catch( 1371 + (err) => ({ error: err }) as SingularLabelResult, 1372 + )) as SingularLabelResult; 1373 + 1374 + if (result === undefined) { 1375 + throw new Error("what the hell happened") 1376 + } 1377 + return result; 1378 + }, 1379 + 1380 + staleTime: 5 * 60 * 1000, // 5 minutes 1381 + gcTime: 5 * 60 * 1000, 1382 + }); 1383 + } 1384 + export function useQuerySingularLabelQuery(options: SingularLabelQuery) { 1385 + return useQuery(constructSingularLabelQuery(options)); 1386 + }