handy online tools for AT Protocol boat.kelinci.net
atproto bluesky atcute typescript solidjs

feat: retroactive thread gating

mary.my.id b4509afb ad9d4328

verified
+1155 -6
+1
package.json
··· 14 14 "@atcute/crypto": "^2.2.0", 15 15 "@atcute/multibase": "^1.1.2", 16 16 "@badrap/valita": "^0.4.2", 17 + "@mary/array-fns": "npm:@jsr/mary__array-fns@^0.1.0", 17 18 "@mary/events": "npm:@jsr/mary__events@^0.1.0", 18 19 "@mary/solid-freeze": "npm:@externdefs/solid-freeze@^0.1.1", 19 20 "@mary/tar": "npm:@jsr/mary__tar@^0.2.4",
+8
pnpm-lock.yaml
··· 29 29 '@badrap/valita': 30 30 specifier: ^0.4.2 31 31 version: 0.4.2 32 + '@mary/array-fns': 33 + specifier: npm:@jsr/mary__array-fns@^0.1.0 34 + version: '@jsr/mary__array-fns@0.1.0' 32 35 '@mary/events': 33 36 specifier: npm:@jsr/mary__events@^0.1.0 34 37 version: '@jsr/mary__events@0.1.0' ··· 568 571 569 572 '@jridgewell/trace-mapping@0.3.9': 570 573 resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 574 + 575 + '@jsr/mary__array-fns@0.1.0': 576 + resolution: {integrity: sha512-rG8Ng1Arl86T1Lv4of4LC8OcdpGxcxG/j8K01K6lyG0lI9imLT7e6FknuMVs5MQ5jH6NUBYnPOfmVAv4sd/OkA==, tarball: https://npm.jsr.io/~/11/@jsr/mary__array-fns/0.1.0.tgz} 571 577 572 578 '@jsr/mary__events@0.1.0': 573 579 resolution: {integrity: sha512-oS6jVOaXTaNEa6avRncwrEtUYaBKrq/HEybPa9Z3aoeMs+RSly0vn0KcOj/fy2H6iTBkeh3wa8+/9nFjhKyKIg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__events/0.1.0.tgz} ··· 1933 1939 dependencies: 1934 1940 '@jridgewell/resolve-uri': 3.1.2 1935 1941 '@jridgewell/sourcemap-codec': 1.5.0 1942 + 1943 + '@jsr/mary__array-fns@0.1.0': {} 1936 1944 1937 1945 '@jsr/mary__events@0.1.0': {} 1938 1946
+5 -1
src/api/utils/strings.ts
··· 26 26 } 27 27 28 28 export const isDid = (value: string): value is At.DID => { 29 - return DID_RE.test(value); 29 + return value.length >= 7 && DID_RE.test(value); 30 + }; 31 + 32 + export const isHandle = (value: string): boolean => { 33 + return value.length >= 4 && HANDLE_RE.test(value); 30 34 }; 31 35 32 36 export const parseAtUri = (str: string): AtUri => {
+12
src/components/ic-icons/outline-mark-chat-read.tsx
··· 1 + import { createIcon } from './_icon'; 2 + 3 + const MarkChatReadOutlinedIcon = createIcon(() => ( 4 + <svg width="1em" height="1em" viewBox="0 0 24 24"> 5 + <path 6 + fill="currentColor" 7 + d="M12 18H6l-4 4V4c0-1.1.9-2 2-2h16c1.1 0 2 .9 2 2v7h-2V4H4v12h8zm11-3.66l-1.41-1.41l-4.24 4.24l-2.12-2.12l-1.41 1.41L17.34 20z" 8 + /> 9 + </svg> 10 + )); 11 + 12 + export default MarkChatReadOutlinedIcon;
+4 -1
src/components/inputs/radio-input.tsx
··· 6 6 7 7 interface RadioInputProps<T extends string> { 8 8 label: JSX.Element; 9 + blurb?: JSX.Element; 9 10 name?: string; 10 11 required?: boolean; 11 - value?: T | undefined; 12 + value?: T; 12 13 options: { value: NoInfer<T>; label: string }[]; 13 14 onChange?: (next: NoInfer<T>, event: BoundInputEvent<HTMLInputElement>) => void; 14 15 } ··· 47 48 </span> 48 49 ); 49 50 })} 51 + 52 + <p class="text-pretty text-[0.8125rem] leading-5 text-gray-500 empty:hidden">{props.blurb}</p> 50 53 </fieldset> 51 54 ); 52 55 };
+49
src/components/inputs/toggle-input.tsx
··· 1 + import { createEffect } from 'solid-js'; 2 + 3 + import { createId } from '~/lib/hooks/id'; 4 + 5 + import { BoundInputEvent } from './_types'; 6 + 7 + export interface ToggleInputProps { 8 + label: string; 9 + name?: string; 10 + required?: boolean; 11 + checked?: boolean; 12 + autofocus?: boolean; 13 + onChange?: (next: boolean, event: BoundInputEvent<HTMLInputElement>) => void; 14 + } 15 + 16 + const ToggleInput = (props: ToggleInputProps) => { 17 + const fieldId = createId(); 18 + 19 + const onChange = props.onChange; 20 + 21 + return ( 22 + <div class="flex items-center gap-3"> 23 + <input 24 + ref={(node) => { 25 + if ('autofocus' in props) { 26 + createEffect(() => { 27 + if (props.autofocus) { 28 + node.focus(); 29 + } 30 + }); 31 + } 32 + }} 33 + type="checkbox" 34 + id={fieldId} 35 + name={props.name} 36 + required={props.required} 37 + checked={props.checked} 38 + class="rounded border-gray-400 text-purple-800 focus:ring-purple-800" 39 + onInput={(event) => onChange?.(event.target.checked, event)} 40 + /> 41 + 42 + <label for={fieldId} class="text-sm"> 43 + {props.label} 44 + </label> 45 + </div> 46 + ); 47 + }; 48 + 49 + export default ToggleInput;
+216
src/components/wizards/bluesky-login-step.tsx
··· 1 + import { batch, createSignal, Match, Show, Switch } from 'solid-js'; 2 + 3 + import { CredentialManager, XRPCError } from '@atcute/client'; 4 + import { At } from '@atcute/client/lexicons'; 5 + 6 + import { getDidDocument } from '~/api/queries/did-doc'; 7 + import { resolveHandleViaAppView } from '~/api/queries/handle'; 8 + import { DidDocument, getPdsEndpoint } from '~/api/types/did-doc'; 9 + import { formatTotpCode, TOTP_RE } from '~/api/utils/auth'; 10 + import { isDid } from '~/api/utils/strings'; 11 + 12 + import { createMutation } from '~/lib/utils/mutation'; 13 + 14 + import Button from '../inputs/button'; 15 + import TextInput from '../inputs/text-input'; 16 + import { Stage, StageActions, StageErrorView } from '../wizard'; 17 + 18 + class InsufficientLoginError extends Error {} 19 + 20 + export interface BlueskyLoginSectionProps { 21 + manager: CredentialManager | undefined; 22 + didDocument: DidDocument; 23 + isActive: boolean; 24 + onAuthorize: (manager: CredentialManager) => void; 25 + onUnauthorize: () => void; 26 + onPrevious: () => void; 27 + } 28 + 29 + const BlueskyLoginStep = (props: BlueskyLoginSectionProps) => { 30 + const onAuthorize = props.onAuthorize; 31 + const onUnauthorize = props.onUnauthorize; 32 + const onPrevious = props.onPrevious; 33 + 34 + const [error, setError] = createSignal<string>(); 35 + const [isTotpRequired, setIsTotpRequired] = createSignal(false); 36 + 37 + const [serviceUrl, setServiceUrl] = createSignal(''); 38 + const [password, setPassword] = createSignal(''); 39 + const [otp, setOtp] = createSignal(''); 40 + 41 + const mutation = createMutation({ 42 + async mutationFn({ 43 + service, 44 + identifier, 45 + password, 46 + otp, 47 + }: { 48 + service: string | undefined; 49 + identifier: string; 50 + password: string; 51 + otp: string; 52 + }) { 53 + identifier = identifier.replace(/^\s*@?|\s+$/g, ''); 54 + service = service?.trim() || undefined; 55 + 56 + if (service === undefined) { 57 + let did: At.DID; 58 + if (!isDid(identifier)) { 59 + did = await resolveHandleViaAppView({ handle: identifier }); 60 + } else { 61 + did = identifier; 62 + } 63 + 64 + const didDoc = await getDidDocument({ did }); 65 + const pdsEndpoint = getPdsEndpoint(didDoc); 66 + 67 + if (pdsEndpoint === undefined) { 68 + throw new InsufficientLoginError(`Identity does not have a PDS configured`); 69 + } 70 + 71 + setServiceUrl((service = pdsEndpoint)); 72 + } 73 + 74 + const manager = new CredentialManager({ service }); 75 + await manager.login({ identifier, password, code: formatTotpCode(otp) }); 76 + 77 + return manager; 78 + }, 79 + onMutate() { 80 + setError(); 81 + }, 82 + onSuccess(manager) { 83 + batch(() => { 84 + onAuthorize(manager); 85 + 86 + setOtp(''); 87 + setPassword(''); 88 + setIsTotpRequired(false); 89 + }); 90 + }, 91 + onError(error) { 92 + let message: string | undefined; 93 + 94 + if (error instanceof XRPCError) { 95 + if (error.kind === 'AuthFactorTokenRequired') { 96 + setOtp(''); 97 + setIsTotpRequired(true); 98 + return; 99 + } 100 + 101 + if (error.kind === 'AuthenticationRequired') { 102 + message = `Invalid identifier or password`; 103 + } else if (error.kind === 'AccountTakedown') { 104 + message = `Account has been taken down`; 105 + } else if (error.message.includes('Token is invalid')) { 106 + message = `Invalid one-time confirmation code`; 107 + setIsTotpRequired(true); 108 + } 109 + } else if (error instanceof InsufficientLoginError) { 110 + message = error.message; 111 + } 112 + 113 + if (message !== undefined) { 114 + setError(message); 115 + } else { 116 + console.error(error); 117 + setError(`Something went wrong: ${error}`); 118 + } 119 + }, 120 + }); 121 + 122 + { 123 + const pdsEndpoint = getPdsEndpoint(props.didDocument); 124 + if (pdsEndpoint) { 125 + setServiceUrl(pdsEndpoint); 126 + } 127 + } 128 + 129 + return ( 130 + <Stage 131 + title="Sign in to your PDS" 132 + disabled={mutation.isPending} 133 + onSubmit={() => { 134 + const manager = props.manager; 135 + 136 + if (manager) { 137 + onAuthorize(manager); 138 + } else { 139 + mutation.mutate({ 140 + service: serviceUrl(), 141 + identifier: props.didDocument.id, 142 + password: password(), 143 + otp: otp(), 144 + }); 145 + } 146 + }} 147 + > 148 + <Switch> 149 + <Match when={props.manager}> 150 + {(manager) => ( 151 + <p class="break-words"> 152 + Signed in via <b>{manager().dispatchUrl}</b>.{' '} 153 + <button 154 + type="button" 155 + onClick={onUnauthorize} 156 + hidden={!props.isActive} 157 + class="text-purple-800 hover:underline disabled:pointer-events-none" 158 + > 159 + Sign out? 160 + </button> 161 + </p> 162 + )} 163 + </Match> 164 + 165 + <Match when> 166 + <TextInput 167 + label="PDS service URL" 168 + type="url" 169 + placeholder="Leave blank if unsure, e.g. https://pds.example.com" 170 + value={serviceUrl()} 171 + onChange={setServiceUrl} 172 + /> 173 + 174 + <TextInput 175 + label="Password" 176 + blurb="Generate an app password for use with this app. This app runs locally on your browser, your credentials stays entirely within your device." 177 + type="password" 178 + value={password()} 179 + required 180 + autofocus={props.isActive} 181 + onChange={setPassword} 182 + /> 183 + 184 + <Show when={isTotpRequired()}> 185 + <TextInput 186 + label="One-time confirmation code" 187 + blurb="A code has been sent to your email address, check your inbox." 188 + type="text" 189 + autocomplete="one-time-code" 190 + autocorrect="off" 191 + pattern={/* @once */ TOTP_RE.source} 192 + placeholder="AAAAA-BBBBB" 193 + value={otp()} 194 + required 195 + onChange={setOtp} 196 + monospace 197 + /> 198 + </Show> 199 + </Match> 200 + </Switch> 201 + 202 + <StageErrorView error={error()} /> 203 + 204 + <StageActions hidden={!props.isActive}> 205 + <StageActions.Divider /> 206 + 207 + <Button variant="secondary" onClick={onPrevious}> 208 + Previous 209 + </Button> 210 + <Button type="submit">Next</Button> 211 + </StageActions> 212 + </Stage> 213 + ); 214 + }; 215 + 216 + export default BlueskyLoginStep;
+8
src/lib/hooks/derived-signal.ts
··· 1 + import { type Accessor, type Signal, createMemo, createSignal } from 'solid-js'; 2 + 3 + export const createDerivedSignal = <T>(accessor: Accessor<T>): Signal<T> => { 4 + const computable = createMemo(() => createSignal(accessor())); 5 + 6 + // @ts-expect-error 7 + return [() => computable()[0](), (next) => computable()[1](next)] as Signal<T>; 8 + };
+5
src/routes.ts
··· 9 9 }, 10 10 11 11 { 12 + path: '/bsky-threadgate-applicator', 13 + component: lazy(() => import('./views/bluesky/threadgate-applicator/page')), 14 + }, 15 + 16 + { 12 17 path: '/blob-export', 13 18 component: lazy(() => import('./views/blob/blob-export')), 14 19 },
+92 -3
src/views/bluesky/threadgate-applicator/page.tsx
··· 1 + import { createEffect, createSignal, onCleanup } from 'solid-js'; 2 + 3 + import { CredentialManager } from '@atcute/client'; 4 + import { AppBskyFeedDefs, AppBskyFeedThreadgate } from '@atcute/client/lexicons'; 5 + 6 + import { DidDocument } from '~/api/types/did-doc'; 7 + import { UnwrapArray } from '~/api/utils/types'; 8 + 9 + import { history } from '~/globals/navigation'; 10 + 11 + import { Wizard } from '~/components/wizard'; 12 + 13 + import Step1_HandleInput from './steps/step1_handle-input'; 14 + import Step2_RulesInput from './steps/step2_rules-input'; 15 + import Step3_Authentication from './steps/step3_authentication'; 16 + import Step4_Confirmation from './steps/step4_confirmation'; 17 + import Step5_Finished from './steps/step5_finished'; 18 + 19 + export interface ThreadgateState 20 + extends Pick<AppBskyFeedThreadgate.Record, 'allow' | 'hiddenReplies' | 'createdAt'> { 21 + uri: string; 22 + } 23 + 24 + export type ThreadgateRule = UnwrapArray<AppBskyFeedThreadgate.Record['allow']>; 25 + 26 + export interface ThreadItem { 27 + post: AppBskyFeedDefs.PostView; 28 + threadgate: ThreadgateState | null; 29 + } 30 + 31 + export interface ProfileInfo { 32 + didDoc: DidDocument; 33 + } 34 + 1 35 export type ThreadgateApplicatorConstraints = { 2 36 Step1_HandleInput: {}; 3 37 4 - Step2_RulesInput: {}; 38 + Step2_RulesInput: { 39 + profile: ProfileInfo; 40 + threads: ThreadItem[]; 41 + }; 42 + 43 + Step3_Authentication: { 44 + profile: ProfileInfo; 45 + threads: ThreadItem[]; 46 + rules: ThreadgateRule[] | undefined; 47 + }; 48 + 49 + Step4_Confirmation: { 50 + profile: ProfileInfo; 51 + manager: CredentialManager; 52 + threads: ThreadItem[]; 53 + rules: ThreadgateRule[] | undefined; 54 + }; 5 55 6 - Step3_Summary: {}; 56 + Step5_Finished: {}; 57 + }; 58 + 59 + const ThreadgateApplicatorPage = () => { 60 + const [isActive, setIsActive] = createSignal(false); 61 + 62 + createEffect(() => { 63 + if (isActive()) { 64 + const cleanup = history.block((tx) => { 65 + if (window.confirm(`Abort this action?`)) { 66 + cleanup(); 67 + tx.retry(); 68 + } 69 + }); 70 + 71 + onCleanup(cleanup); 72 + } 73 + }); 74 + 75 + return ( 76 + <> 77 + <div class="p-4"> 78 + <h1 class="text-lg font-bold text-purple-800">Retroactive thread gating</h1> 79 + <p class="text-gray-600">Set reply permissions on all of your past Bluesky posts</p> 80 + </div> 81 + <hr class="mx-4 border-gray-300" /> 7 82 8 - Step4_Finished: {}; 83 + <Wizard<ThreadgateApplicatorConstraints> 84 + initialStep="Step1_HandleInput" 85 + components={{ 86 + Step1_HandleInput, 87 + Step2_RulesInput, 88 + Step3_Authentication, 89 + Step4_Confirmation, 90 + Step5_Finished, 91 + }} 92 + onStepChange={(step) => setIsActive(step > 1 && step < 5)} 93 + /> 94 + </> 95 + ); 9 96 }; 97 + 98 + export default ThreadgateApplicatorPage;
+175
src/views/bluesky/threadgate-applicator/steps/step1_handle-input.tsx
··· 1 + import { createSignal } from 'solid-js'; 2 + 3 + import type { AppBskyFeedThreadgate, At } from '@atcute/client/lexicons'; 4 + 5 + import { getDidDocument } from '~/api/queries/did-doc'; 6 + import { resolveHandleViaAppView } from '~/api/queries/handle'; 7 + import { DID_OR_HANDLE_RE, isDid } from '~/api/utils/strings'; 8 + 9 + import { appViewRpc } from '~/globals/rpc'; 10 + 11 + import { createMutation } from '~/lib/utils/mutation'; 12 + 13 + import Button from '~/components/inputs/button'; 14 + import TextInput from '~/components/inputs/text-input'; 15 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 16 + 17 + import { ThreadgateApplicatorConstraints, ThreadgateState, ThreadItem } from '../page'; 18 + import { sortThreadgateState } from '../utils'; 19 + 20 + class NoThreadsError extends Error {} 21 + 22 + const Step1_HandleInput = ({ 23 + isActive, 24 + onNext, 25 + }: WizardStepProps<ThreadgateApplicatorConstraints, 'Step1_HandleInput'>) => { 26 + const [identifier, setIdentifier] = createSignal(''); 27 + 28 + const [status, setStatus] = createSignal<string>(); 29 + const [error, setError] = createSignal<string>(); 30 + 31 + const mutation = createMutation({ 32 + async mutationFn({ identifier }: { identifier: string }, signal) { 33 + setStatus(`Resolving identity`); 34 + 35 + let did: At.DID; 36 + if (isDid(identifier)) { 37 + did = identifier; 38 + } else { 39 + did = await resolveHandleViaAppView({ handle: identifier, signal }); 40 + } 41 + 42 + const didDoc = await getDidDocument({ did, signal }); 43 + 44 + setStatus(`Looking up your posts`); 45 + 46 + const threads = new Map<string, ThreadItem>(); 47 + 48 + let cursor: string | undefined; 49 + do { 50 + const { data } = await appViewRpc.get('app.bsky.feed.getAuthorFeed', { 51 + signal, 52 + params: { 53 + actor: did, 54 + filter: 'posts_no_replies', 55 + limit: 100, 56 + cursor, 57 + }, 58 + }); 59 + 60 + cursor = data.cursor; 61 + 62 + for (const item of data.feed) { 63 + const post = item.post; 64 + 65 + // This is a reply, skip, we're only interested in root posts 66 + if (item.reply) { 67 + continue; 68 + } 69 + 70 + // This is a repost 71 + if (item.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { 72 + // This is a repost of another user's post, skip 73 + if (post.author.did !== did) { 74 + continue; 75 + } 76 + } 77 + 78 + const tg = post.threadgate; 79 + 80 + let threadgate: ThreadgateState | null = null; 81 + 82 + if (tg?.record) { 83 + const record = tg.record as AppBskyFeedThreadgate.Record; 84 + 85 + const allow = record?.allow; 86 + const hiddenReplies = record?.hiddenReplies; 87 + 88 + threadgate = { 89 + uri: tg.uri!, 90 + createdAt: record.createdAt, 91 + allow: allow, 92 + hiddenReplies: hiddenReplies?.length ? hiddenReplies : undefined, 93 + }; 94 + 95 + sortThreadgateState(threadgate); 96 + } 97 + 98 + threads.set(post.uri, { post, threadgate }); 99 + } 100 + 101 + setStatus(`Looking up your posts (found ${threads.size} threads)`); 102 + } while (cursor !== undefined); 103 + 104 + if (threads.size === 0) { 105 + throw new NoThreadsError(`You have no threads posted!`); 106 + } 107 + 108 + return { didDoc, threads }; 109 + }, 110 + onMutate() { 111 + setError(); 112 + }, 113 + onSuccess({ didDoc, threads }) { 114 + onNext('Step2_RulesInput', { 115 + profile: { didDoc }, 116 + threads: Array.from(threads.values()), 117 + }); 118 + }, 119 + onError(error) { 120 + let message: string | undefined; 121 + 122 + if (error instanceof NoThreadsError) { 123 + message = error.message; 124 + } 125 + 126 + if (message !== undefined) { 127 + setError(message); 128 + } else { 129 + console.error(error); 130 + setError(`Something went wrong: ${error}`); 131 + } 132 + }, 133 + onSettled() { 134 + setStatus(); 135 + }, 136 + }); 137 + 138 + return ( 139 + <Stage 140 + title="Enter your Bluesky handle" 141 + disabled={mutation.isPending} 142 + onSubmit={() => { 143 + mutation.mutate({ 144 + identifier: identifier(), 145 + }); 146 + }} 147 + > 148 + <TextInput 149 + label="Handle or DID identifier" 150 + placeholder="paul.bsky.social" 151 + value={identifier()} 152 + required 153 + pattern={/* @once */ DID_OR_HANDLE_RE.source} 154 + autofocus={isActive()} 155 + onChange={setIdentifier} 156 + /> 157 + 158 + <div 159 + hidden={status() === undefined} 160 + class="whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-gray-500" 161 + > 162 + {status()} 163 + </div> 164 + 165 + <StageErrorView error={error()} /> 166 + 167 + <StageActions hidden={!isActive()}> 168 + <StageActions.Divider /> 169 + <Button type="submit">Next</Button> 170 + </StageActions> 171 + </Stage> 172 + ); 173 + }; 174 + 175 + export default Step1_HandleInput;
+313
src/views/bluesky/threadgate-applicator/steps/step2_rules-input.tsx
··· 1 + import { batch, createMemo, createSignal, For, Show } from 'solid-js'; 2 + 3 + import { AppBskyFeedThreadgate, Brand } from '@atcute/client/lexicons'; 4 + 5 + import { UnwrapArray } from '~/api/utils/types'; 6 + 7 + import { appViewRpc } from '~/globals/rpc'; 8 + 9 + import { createDerivedSignal } from '~/lib/hooks/derived-signal'; 10 + import { dequal } from '~/lib/utils/dequal'; 11 + import { createQuery } from '~/lib/utils/query'; 12 + 13 + import RadioInput from '~/components/inputs/radio-input'; 14 + import { Stage, StageActions, WizardStepProps } from '~/components/wizard'; 15 + 16 + import CircularProgressView from '~/components/circular-progress-view'; 17 + import ToggleInput from '~/components/inputs/toggle-input'; 18 + 19 + import { ThreadgateApplicatorConstraints } from '../page'; 20 + import { sortThreadgateAllow } from '../utils'; 21 + import Button from '~/components/inputs/button'; 22 + 23 + const enum FilterType { 24 + ALL = 'all', 25 + MISSING_ONLY = 'missing_only', 26 + } 27 + 28 + const enum ThreadRulePreset { 29 + EVERYONE = 'everyone', 30 + NO_ONE = 'no_one', 31 + CUSTOM = 'custom', 32 + } 33 + 34 + type ThreadRule = UnwrapArray<AppBskyFeedThreadgate.Record['allow']>; 35 + 36 + const Step2_RulesInput = ({ 37 + data, 38 + isActive, 39 + onPrevious, 40 + onNext, 41 + }: WizardStepProps<ThreadgateApplicatorConstraints, 'Step2_RulesInput'>) => { 42 + const [filter, setFilter] = createSignal(FilterType.MISSING_ONLY); 43 + 44 + const [threadRules, _setThreadRules] = createSignal<ThreadRule[] | undefined>([ 45 + { $type: 'app.bsky.feed.threadgate#followingRule' }, 46 + { $type: 'app.bsky.feed.threadgate#mentionRule' }, 47 + ]); 48 + 49 + const [threadRulesPreset, setThreadRulesPreset] = createDerivedSignal(() => { 50 + const rules = threadRules(); 51 + 52 + if (rules === undefined) { 53 + return ThreadRulePreset.EVERYONE; 54 + } 55 + 56 + if (rules.length === 0) { 57 + return ThreadRulePreset.NO_ONE; 58 + } 59 + 60 + return ThreadRulePreset.CUSTOM; 61 + }); 62 + 63 + const lists = createQuery( 64 + () => data.profile.didDoc.id, 65 + async (did, signal) => { 66 + const lists = await accumulate(async (cursor) => { 67 + const { data } = await appViewRpc.get('app.bsky.graph.getLists', { 68 + signal, 69 + params: { 70 + actor: did, 71 + cursor, 72 + limit: 100, 73 + }, 74 + }); 75 + 76 + return { 77 + cursor: data.cursor, 78 + items: data.lists, 79 + }; 80 + }); 81 + 82 + const collator = new Intl.Collator('en'); 83 + 84 + return lists 85 + .filter((list) => list.purpose === 'app.bsky.graph.defs#curatelist') 86 + .sort((a, b) => collator.compare(a.name, b.name)); 87 + }, 88 + ); 89 + 90 + const filteredThreads = createMemo(() => { 91 + const $threads = data.threads; 92 + const $threadRules = threadRules(); 93 + 94 + // It's fine, let's just mutate the original array. 95 + sortThreadgateAllow($threadRules); 96 + 97 + switch (filter()) { 98 + case FilterType.ALL: { 99 + return $threads.filter(({ threadgate }) => !dequal(threadgate?.allow, $threadRules)); 100 + } 101 + case FilterType.MISSING_ONLY: { 102 + if ($threadRules === undefined) { 103 + return []; 104 + } 105 + 106 + return $threads.filter(({ threadgate }) => threadgate === null); 107 + } 108 + } 109 + }); 110 + 111 + const isDisabled = createMemo(() => { 112 + const $threads = filteredThreads(); 113 + 114 + const $threadRulesPreset = threadRulesPreset(); 115 + const $threadRules = threadRules(); 116 + 117 + return ( 118 + $threads.length !== 0 && 119 + $threadRulesPreset === ThreadRulePreset.CUSTOM && 120 + ($threadRules === undefined || $threadRules.length === 0) 121 + ); 122 + }); 123 + 124 + const hasThreadRule = (predicate: ThreadRule): boolean => { 125 + return !!threadRules()?.find((rule) => dequal(rule, predicate)); 126 + }; 127 + 128 + const setCustomThreadRules = (next: ThreadRule[] | undefined) => { 129 + batch(() => { 130 + _setThreadRules(next); 131 + setThreadRulesPreset(ThreadRulePreset.CUSTOM); 132 + }); 133 + }; 134 + 135 + return ( 136 + <Stage 137 + title="Configure thread gating options" 138 + onSubmit={() => { 139 + onNext('Step3_Authentication', { 140 + profile: data.profile, 141 + threads: filteredThreads(), 142 + rules: threadRules(), 143 + }); 144 + }} 145 + > 146 + <RadioInput 147 + label="Who can reply..." 148 + value={threadRulesPreset()} 149 + required 150 + options={[ 151 + { value: ThreadRulePreset.EVERYONE, label: `everyone can reply` }, 152 + { value: ThreadRulePreset.NO_ONE, label: `no one can reply` }, 153 + { value: ThreadRulePreset.CUSTOM, label: `custom` }, 154 + ]} 155 + onChange={(next) => { 156 + switch (next) { 157 + case ThreadRulePreset.CUSTOM: { 158 + setCustomThreadRules([]); 159 + break; 160 + } 161 + case ThreadRulePreset.EVERYONE: { 162 + _setThreadRules(undefined); 163 + break; 164 + } 165 + case ThreadRulePreset.NO_ONE: { 166 + _setThreadRules([]); 167 + break; 168 + } 169 + } 170 + }} 171 + /> 172 + 173 + <p class="text-[0.8125rem] font-medium">Alternatively, combine these options:</p> 174 + 175 + <fieldset class="flex flex-col gap-2"> 176 + <legend class="contents"> 177 + <span class="font-semibold text-gray-600">Allow replies from...</span> 178 + </legend> 179 + 180 + <ToggleInput 181 + label="followed users" 182 + checked={hasThreadRule({ $type: 'app.bsky.feed.threadgate#followingRule' })} 183 + onChange={(next) => { 184 + if (next) { 185 + setCustomThreadRules([ 186 + ...(threadRules() ?? []), 187 + { $type: 'app.bsky.feed.threadgate#followingRule' }, 188 + ]); 189 + } else { 190 + setCustomThreadRules( 191 + threadRules()?.filter((rule) => rule.$type !== 'app.bsky.feed.threadgate#followingRule'), 192 + ); 193 + } 194 + }} 195 + /> 196 + 197 + <ToggleInput 198 + label="users mentioned in the post" 199 + checked={hasThreadRule({ $type: 'app.bsky.feed.threadgate#mentionRule' })} 200 + onChange={(next) => { 201 + if (next) { 202 + setCustomThreadRules([ 203 + ...(threadRules() ?? []), 204 + { $type: 'app.bsky.feed.threadgate#mentionRule' }, 205 + ]); 206 + } else { 207 + setCustomThreadRules( 208 + threadRules()?.filter((rule) => rule.$type !== 'app.bsky.feed.threadgate#mentionRule'), 209 + ); 210 + } 211 + }} 212 + /> 213 + </fieldset> 214 + 215 + <fieldset class="flex flex-col gap-2"> 216 + <legend class="contents"> 217 + <span class="font-semibold text-gray-600">Allow replies from users in...</span> 218 + </legend> 219 + 220 + <For 221 + each={lists.data} 222 + fallback={ 223 + <Show when={!lists.isPending} fallback={<CircularProgressView />}> 224 + <p class="text-gray-500">You don't have any user lists created</p> 225 + </Show> 226 + } 227 + > 228 + {(list) => { 229 + const rule: Brand.Union<AppBskyFeedThreadgate.ListRule> = { 230 + $type: 'app.bsky.feed.threadgate#listRule', 231 + list: list.uri, 232 + }; 233 + 234 + return ( 235 + <ToggleInput 236 + label={/* @once */ list.name} 237 + checked={hasThreadRule(rule)} 238 + onChange={(next) => { 239 + if (next) { 240 + setCustomThreadRules([...(threadRules() ?? []), rule]); 241 + } else { 242 + setCustomThreadRules(threadRules()?.filter((r) => !dequal(r, rule))); 243 + } 244 + }} 245 + /> 246 + ); 247 + }} 248 + </For> 249 + </fieldset> 250 + 251 + <hr /> 252 + 253 + <RadioInput 254 + label="Apply to..." 255 + blurb={ 256 + <> 257 + <span>This will apply to {filteredThreads().length} threads. </span> 258 + {/* <button 259 + type="button" 260 + hidden={filteredThreads().length < 1} 261 + class="font-medium text-purple-800 hover:underline" 262 + > 263 + View 264 + </button> */} 265 + </> 266 + } 267 + value={filter()} 268 + required 269 + options={[ 270 + { value: FilterType.ALL, label: `all threads` }, 271 + { value: FilterType.MISSING_ONLY, label: `threads that are not gated` }, 272 + ]} 273 + onChange={setFilter} 274 + /> 275 + 276 + <StageActions hidden={!isActive()}> 277 + <StageActions.Divider /> 278 + 279 + <Button variant="secondary" onClick={onPrevious}> 280 + Previous 281 + </Button> 282 + <Button type="submit" disabled={isDisabled()}> 283 + Next 284 + </Button> 285 + </StageActions> 286 + </Stage> 287 + ); 288 + }; 289 + 290 + export default Step2_RulesInput; 291 + 292 + interface AccumulateResponse<T> { 293 + cursor?: string; 294 + items: T[]; 295 + } 296 + 297 + type AccumulateFetcher<T> = (cursor: string | undefined) => Promise<AccumulateResponse<T>>; 298 + 299 + const accumulate = async <T,>(fn: AccumulateFetcher<T>, limit = 100): Promise<T[]> => { 300 + let cursor: string | undefined; 301 + let acc: T[] = []; 302 + 303 + for (let i = 0; i < limit; i++) { 304 + const res = await fn(cursor); 305 + cursor = res.cursor; 306 + acc = acc.concat(res.items); 307 + if (!cursor) { 308 + break; 309 + } 310 + } 311 + 312 + return acc; 313 + };
+35
src/views/bluesky/threadgate-applicator/steps/step3_authentication.tsx
··· 1 + import { batch, createSignal } from 'solid-js'; 2 + 3 + import { CredentialManager } from '@atcute/client'; 4 + 5 + import { WizardStepProps } from '~/components/wizard'; 6 + import BlueskyLoginStep from '~/components/wizards/bluesky-login-step'; 7 + 8 + import { ThreadgateApplicatorConstraints } from '../page'; 9 + 10 + const Step3_Authentication = ({ 11 + data, 12 + isActive, 13 + onPrevious, 14 + onNext, 15 + }: WizardStepProps<ThreadgateApplicatorConstraints, 'Step3_Authentication'>) => { 16 + const [manager, setManager] = createSignal<CredentialManager>(); 17 + 18 + return ( 19 + <BlueskyLoginStep 20 + manager={manager()} 21 + didDocument={/* @once */ data.profile.didDoc} 22 + isActive={isActive()} 23 + onAuthorize={(manager) => { 24 + batch(() => { 25 + setManager(manager); 26 + onNext('Step4_Confirmation', { ...data, manager }); 27 + }); 28 + }} 29 + onUnauthorize={setManager} 30 + onPrevious={onPrevious} 31 + /> 32 + ); 33 + }; 34 + 35 + export default Step3_Authentication;
+177
src/views/bluesky/threadgate-applicator/steps/step4_confirmation.tsx
··· 1 + import { createSignal } from 'solid-js'; 2 + 3 + import { HeadersObject, XRPC } from '@atcute/client'; 4 + import { AppBskyFeedThreadgate, ComAtprotoRepoApplyWrites } from '@atcute/client/lexicons'; 5 + import { chunked } from '@mary/array-fns'; 6 + 7 + import { dequal } from '~/lib/utils/dequal'; 8 + import { createMutation } from '~/lib/utils/mutation'; 9 + 10 + import Button from '~/components/inputs/button'; 11 + import ToggleInput from '~/components/inputs/toggle-input'; 12 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 13 + 14 + import { parseAtUri } from '~/api/utils/strings'; 15 + import { ThreadgateApplicatorConstraints } from '../page'; 16 + 17 + const Step4_Confirmation = ({ 18 + data, 19 + isActive, 20 + onPrevious, 21 + onNext, 22 + }: WizardStepProps<ThreadgateApplicatorConstraints, 'Step4_Confirmation'>) => { 23 + const [checked, setChecked] = createSignal(false); 24 + 25 + const [status, setStatus] = createSignal<string>(); 26 + const [error, setError] = createSignal<string>(); 27 + 28 + const mutation = createMutation({ 29 + async mutationFn() { 30 + setStatus(`Preparing records`); 31 + 32 + const rules = data.rules; 33 + const writes: ComAtprotoRepoApplyWrites.Input['writes'] = []; 34 + 35 + const now = new Date().toISOString(); 36 + for (const { post, threadgate } of data.threads) { 37 + if (threadgate === null) { 38 + if (rules !== undefined) { 39 + const { rkey } = parseAtUri(post.uri); 40 + 41 + const record: AppBskyFeedThreadgate.Record = { 42 + $type: 'app.bsky.feed.threadgate', 43 + createdAt: now, 44 + post: post.uri, 45 + allow: rules, 46 + hiddenReplies: undefined, 47 + }; 48 + 49 + writes.push({ 50 + $type: 'com.atproto.repo.applyWrites#create', 51 + collection: 'app.bsky.feed.threadgate', 52 + rkey: rkey, 53 + value: record, 54 + }); 55 + } 56 + } else { 57 + if (rules === undefined && !threadgate.hiddenReplies?.length) { 58 + const { rkey } = parseAtUri(threadgate.uri); 59 + 60 + writes.push({ 61 + $type: 'com.atproto.repo.applyWrites#delete', 62 + collection: 'app.bsky.feed.threadgate', 63 + rkey: rkey, 64 + }); 65 + } else if (!dequal(threadgate.allow, rules)) { 66 + const { rkey } = parseAtUri(threadgate.uri); 67 + 68 + const record: AppBskyFeedThreadgate.Record = { 69 + $type: 'app.bsky.feed.threadgate', 70 + createdAt: threadgate.createdAt, 71 + post: post.uri, 72 + allow: rules, 73 + hiddenReplies: threadgate.hiddenReplies, 74 + }; 75 + 76 + writes.push({ 77 + $type: 'com.atproto.repo.applyWrites#update', 78 + collection: 'app.bsky.feed.threadgate', 79 + rkey: rkey, 80 + value: record, 81 + }); 82 + } 83 + } 84 + } 85 + 86 + const did = data.profile.didDoc.id; 87 + const rpc = new XRPC({ handler: data.manager }); 88 + 89 + const total = writes.length; 90 + let written = 0; 91 + for (const chunk of chunked(writes, 200)) { 92 + setStatus(`Writing records (${written}/${total})`); 93 + 94 + const { headers } = await rpc.call('com.atproto.repo.applyWrites', { 95 + data: { 96 + repo: did, 97 + writes: chunk, 98 + }, 99 + }); 100 + 101 + written += chunk.length; 102 + 103 + await waitForRatelimit(headers, 150 * 3); 104 + } 105 + }, 106 + onMutate() { 107 + setError(); 108 + }, 109 + onSettled() { 110 + setStatus(); 111 + }, 112 + onSuccess() { 113 + onNext('Step5_Finished', {}); 114 + }, 115 + onError(error) { 116 + let message: string | undefined; 117 + 118 + if (message !== undefined) { 119 + setError(message); 120 + } else { 121 + console.error(error); 122 + setError(`Something went wrong: ${error}`); 123 + } 124 + }, 125 + }); 126 + 127 + return ( 128 + <Stage 129 + title="One more step" 130 + disabled={mutation.isPending} 131 + onSubmit={() => { 132 + mutation.mutate(); 133 + }} 134 + > 135 + <p class="text-pretty text-red-800"> 136 + <b>Caution:</b> This action is irreversible. Proceed at your own risk, we assume no liability for any 137 + consequences. 138 + </p> 139 + 140 + <ToggleInput label="I understand" required checked={checked()} onChange={setChecked} /> 141 + 142 + <div 143 + hidden={status() === undefined} 144 + class="whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-gray-500" 145 + > 146 + {status()} 147 + </div> 148 + 149 + <StageErrorView error={error()} /> 150 + 151 + <StageActions hidden={!isActive()}> 152 + <StageActions.Divider /> 153 + 154 + <Button variant="secondary" onClick={onPrevious}> 155 + Previous 156 + </Button> 157 + <Button type="submit">Proceed</Button> 158 + </StageActions> 159 + </Stage> 160 + ); 161 + }; 162 + 163 + export default Step4_Confirmation; 164 + 165 + const waitForRatelimit = async (headers: HeadersObject, expected: number) => { 166 + if ('ratelimit-remaining' in headers) { 167 + const remaining = +headers['ratelimit-remaining']; 168 + const reset = +headers['ratelimit-reset'] * 1_000; 169 + 170 + if (remaining < expected) { 171 + // add some delay to be sure 172 + const delta = reset - Date.now() + 5_000; 173 + 174 + await new Promise((resolve) => setTimeout(resolve, delta)); 175 + } 176 + } 177 + };
+19
src/views/bluesky/threadgate-applicator/steps/step5_finished.tsx
··· 1 + import { Stage, WizardStepProps } from '~/components/wizard'; 2 + 3 + import { ThreadgateApplicatorConstraints } from '../page'; 4 + 5 + export const Step5_Finished = ({}: WizardStepProps<ThreadgateApplicatorConstraints, 'Step5_Finished'>) => { 6 + return ( 7 + <Stage title="All done!"> 8 + <div> 9 + <p class="text-pretty">Thread gating option has been applied.</p> 10 + 11 + <p class="mt-3 text-pretty"> 12 + You can close this page, or reload the page if you intend on doing another submission. 13 + </p> 14 + </div> 15 + </Stage> 16 + ); 17 + }; 18 + 19 + export default Step5_Finished;
+22
src/views/bluesky/threadgate-applicator/utils.ts
··· 1 + import { ThreadgateState } from './page'; 2 + 3 + const collator = new Intl.Collator('en'); 4 + 5 + export const sortThreadgateAllow = (allow: ThreadgateState['allow']) => { 6 + if (allow?.length) { 7 + allow.sort((a, b) => { 8 + const aType = a.$type; 9 + const bType = b.$type; 10 + 11 + if (aType === 'app.bsky.feed.threadgate#listRule' && aType === bType) { 12 + return collator.compare(a.list, b.list); 13 + } 14 + 15 + return collator.compare(aType, bType); 16 + }); 17 + } 18 + }; 19 + 20 + export const sortThreadgateState = ({ allow }: ThreadgateState) => { 21 + sortThreadgateAllow(allow); 22 + };
+12
src/views/frontpage.tsx
··· 10 10 import BookmarksOutlinedIcon from '~/components/ic-icons/outline-bookmarks'; 11 11 import DirectionsCarOutlinedIcon from '~/components/ic-icons/outline-directions-car'; 12 12 import ExploreOutlinedIcon from '~/components/ic-icons/outline-explore'; 13 + import MarkChatReadOutlinedIcon from '~/components/ic-icons/outline-mark-chat-read'; 13 14 import MoveUpOutlinedIcon from '~/components/ic-icons/outline-move-up'; 14 15 import VisibilityOutlinedIcon from '~/components/ic-icons/outline-visibility'; 15 16 ··· 120 121 description: `Show basic metadata about a public or private key`, 121 122 href: null, 122 123 icon: KeyVisualizerIcon, 124 + }, 125 + ], 126 + }, 127 + { 128 + name: `Bluesky`, 129 + items: [ 130 + { 131 + name: `Retroactive thread gating`, 132 + description: `Set reply permissions for all of your past Bluesky posts`, 133 + href: `/bsky-threadgate-applicator`, 134 + icon: MarkChatReadOutlinedIcon, 123 135 }, 124 136 ], 125 137 },
+2 -1
src/vite-env.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 - /// <reference types="@atcute/bluesky" /> 2 + 3 + /// <reference types="@atcute/bluesky/lexicons" /> 3 4 4 5 interface ImportMetaEnv { 5 6 VITE_PLC_DIRECTORY_URL: string;