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