Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

feat: sync Witchsky prefs to PDS (app.witchsky.preferences/self)

Save theme + Runes to the repo, show “Preferences saved” after upload,
and pull newer prefs on focus/poll for cross-device updates. Add native
persisted onUpdate + onPostWrite hooks.

+546 -7
+67
lexicons/app/witchsky/preferences.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.witchsky.preferences", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Witchsky client preferences (world-readable; do not store API keys). Stored at rkey `self`.", 8 + "key": "self", 9 + "record": { 10 + "type": "object", 11 + "required": ["$type"], 12 + "properties": { 13 + "$type": { 14 + "type": "string", 15 + "const": "app.witchsky.preferences" 16 + }, 17 + "updatedAt": { 18 + "type": "string", 19 + "format": "datetime" 20 + }, 21 + "colorMode": { "type": "string" }, 22 + "darkTheme": { "type": "string" }, 23 + "colorScheme": { "type": "string" }, 24 + "hue": { "type": "number" }, 25 + "kawaii": { "type": "boolean" }, 26 + "goLinksEnabled": { "type": "boolean" }, 27 + "constellationEnabled": { "type": "boolean" }, 28 + "directFetchRecords": { "type": "boolean" }, 29 + "noAppLabelers": { "type": "boolean" }, 30 + "noDiscoverFallback": { "type": "boolean" }, 31 + "repostCarouselEnabled": { "type": "boolean" }, 32 + "constellationInstance": { "type": "string" }, 33 + "showLinkInHandle": { "type": "boolean" }, 34 + "hideFeedsPromoTab": { "type": "boolean" }, 35 + "disableViaRepostNotification": { "type": "boolean" }, 36 + "disableComposerPrompt": { "type": "boolean" }, 37 + "disableLikesMetrics": { "type": "boolean" }, 38 + "disableRepostsMetrics": { "type": "boolean" }, 39 + "disableQuotesMetrics": { "type": "boolean" }, 40 + "disableSavesMetrics": { "type": "boolean" }, 41 + "disableReplyMetrics": { "type": "boolean" }, 42 + "disableFollowersMetrics": { "type": "boolean" }, 43 + "disableFollowingMetrics": { "type": "boolean" }, 44 + "disableFollowedByMetrics": { "type": "boolean" }, 45 + "disablePostsMetrics": { "type": "boolean" }, 46 + "showFollowsYouBadge": { "type": "boolean" }, 47 + "hideSimilarAccountsRecomm": { "type": "boolean" }, 48 + "discoverContextEnabled": { "type": "boolean" }, 49 + "enableSquareAvatars": { "type": "boolean" }, 50 + "enableSquareButtons": { "type": "boolean" }, 51 + "disableVerifyEmailReminder": { "type": "boolean" }, 52 + "highQualityImages": { "type": "boolean" }, 53 + "imageCdnHost": { "type": "string" }, 54 + "hideUnreplyablePosts": { "type": "boolean" }, 55 + "showExternalShareButtons": { "type": "boolean" }, 56 + "translationServicePreference": { "type": "string" }, 57 + "libreTranslateInstance": { "type": "string" }, 58 + "useHandleInLinks": { "type": "boolean" }, 59 + "autoLikeOnRepost": { "type": "boolean" }, 60 + "deerVerification": { "type": "object" }, 61 + "pdsLabel": { "type": "object" }, 62 + "postReplacement": { "type": "object" } 63 + } 64 + } 65 + } 66 + } 67 + }
+122
src/lib/witchsky-preferences/record.ts
··· 1 + import {z} from 'zod' 2 + 3 + import * as persisted from '#/state/persisted' 4 + import {type Schema,schema} from '#/state/persisted/schema' 5 + 6 + /** Repo collection NSID; record URI is `at://.../app.witchsky.preferences/self`. */ 7 + export const WITCHSKY_PREFERENCES_COLLECTION = 'app.witchsky.preferences' 8 + 9 + export const WITCHSKY_PREFERENCES_RKEY = 'self' 10 + 11 + export const WITCHSKY_PREFERENCES_TYPE = 'app.witchsky.preferences' as const 12 + 13 + const witchskySyncedPrefsSchema = schema.pick({ 14 + colorMode: true, 15 + darkTheme: true, 16 + colorScheme: true, 17 + hue: true, 18 + kawaii: true, 19 + goLinksEnabled: true, 20 + constellationEnabled: true, 21 + directFetchRecords: true, 22 + noAppLabelers: true, 23 + noDiscoverFallback: true, 24 + repostCarouselEnabled: true, 25 + constellationInstance: true, 26 + showLinkInHandle: true, 27 + hideFeedsPromoTab: true, 28 + disableViaRepostNotification: true, 29 + disableComposerPrompt: true, 30 + disableLikesMetrics: true, 31 + disableRepostsMetrics: true, 32 + disableQuotesMetrics: true, 33 + disableSavesMetrics: true, 34 + disableReplyMetrics: true, 35 + disableFollowersMetrics: true, 36 + disableFollowingMetrics: true, 37 + disableFollowedByMetrics: true, 38 + disablePostsMetrics: true, 39 + showFollowsYouBadge: true, 40 + hideSimilarAccountsRecomm: true, 41 + discoverContextEnabled: true, 42 + enableSquareAvatars: true, 43 + enableSquareButtons: true, 44 + disableVerifyEmailReminder: true, 45 + deerVerification: true, 46 + highQualityImages: true, 47 + imageCdnHost: true, 48 + hideUnreplyablePosts: true, 49 + pdsLabel: true, 50 + postReplacement: true, 51 + showExternalShareButtons: true, 52 + translationServicePreference: true, 53 + libreTranslateInstance: true, 54 + useHandleInLinks: true, 55 + autoLikeOnRepost: true, 56 + }) 57 + 58 + export const witchskyPreferencesSelfRecordSchema = z 59 + .object({ 60 + $type: z.literal(WITCHSKY_PREFERENCES_TYPE), 61 + updatedAt: z.string().optional(), 62 + }) 63 + .and(witchskySyncedPrefsSchema.partial()) 64 + 65 + export type WitchskyPreferencesSelfRecord = z.infer< 66 + typeof witchskyPreferencesSelfRecordSchema 67 + > 68 + 69 + export const WITCHSKY_SYNCED_PERSIST_KEYS = Object.keys( 70 + witchskySyncedPrefsSchema.shape, 71 + ) as (keyof Schema)[] 72 + 73 + const syncedKeySet = new Set<string>(WITCHSKY_SYNCED_PERSIST_KEYS) 74 + 75 + /** Compare payloads ignoring `updatedAt` (regenerated on each build). */ 76 + export function witchskyPreferencesStableKey( 77 + record: WitchskyPreferencesSelfRecord, 78 + ): string { 79 + const {updatedAt: _u, ...rest} = record 80 + return JSON.stringify(rest) 81 + } 82 + 83 + export function isWitchskySyncedPersistKey(key: string): key is keyof Schema { 84 + return syncedKeySet.has(key) 85 + } 86 + 87 + export function buildWitchskyPreferencesRecordFromStore(): WitchskyPreferencesSelfRecord { 88 + const prefs = witchskySyncedPrefsSchema.parse( 89 + Object.fromEntries( 90 + WITCHSKY_SYNCED_PERSIST_KEYS.map(k => [k, persisted.get(k)]), 91 + ), 92 + ) 93 + return { 94 + $type: WITCHSKY_PREFERENCES_TYPE, 95 + updatedAt: new Date().toISOString(), 96 + ...prefs, 97 + } 98 + } 99 + 100 + export function parseWitchskyPreferencesRecord( 101 + value: unknown, 102 + ): WitchskyPreferencesSelfRecord | null { 103 + const parsed = witchskyPreferencesSelfRecordSchema.safeParse(value) 104 + return parsed.success ? parsed.data : null 105 + } 106 + 107 + /** 108 + * Applies validated remote fields to local persisted storage. 109 + * Callers should set a guard so {@link onPostWrite} does not immediately re-upload. 110 + */ 111 + export async function applyWitchskyPreferencesRecord( 112 + record: WitchskyPreferencesSelfRecord, 113 + ): Promise<void> { 114 + const { $type: _t, updatedAt: _u, ...rest } = record 115 + const patch = rest as Partial<Schema> 116 + for (const key of WITCHSKY_SYNCED_PERSIST_KEYS) { 117 + const v = patch[key] 118 + if (v !== undefined) { 119 + await persisted.write(key, v) 120 + } 121 + } 122 + }
+30 -3
src/state/persisted/index.ts
··· 1 1 import AsyncStorage from '@react-native-async-storage/async-storage' 2 + import EventEmitter from 'eventemitter3' 2 3 3 4 import {logger} from '#/logger' 4 5 import { ··· 15 16 export {defaults} from '#/state/persisted/schema' 16 17 17 18 const BSKY_STORAGE = 'BSKY_STORAGE' 19 + 20 + const _emitter = new EventEmitter() 21 + const _postWriteListeners = new Set<(key: keyof Schema) => void>() 18 22 19 23 let _state: Schema = defaults 20 24 ··· 40 44 [key]: value, 41 45 }) 42 46 await writeToStorage(_state) 47 + _emitter.emit('update:' + String(key)) 48 + _emitter.emit('update') 49 + for (const cb of _postWriteListeners) { 50 + try { 51 + cb(key) 52 + } catch (e) { 53 + logger.error('persisted onPostWrite listener failed', {message: String(e)}) 54 + } 55 + } 43 56 } 44 57 write satisfies PersistedApi['write'] 45 58 46 59 export function onUpdate<K extends keyof Schema>( 47 - _key: K, 48 - _cb: (v: Schema[K]) => void, 60 + key: K, 61 + cb: (v: Schema[K]) => void, 49 62 ): () => void { 50 - return () => {} 63 + const listener = () => cb(get(key)) 64 + _emitter.addListener('update:' + String(key), listener) 65 + _emitter.addListener('update', listener) 66 + return () => { 67 + _emitter.removeListener('update:' + String(key), listener) 68 + _emitter.removeListener('update', listener) 69 + } 51 70 } 52 71 onUpdate satisfies PersistedApi['onUpdate'] 72 + 73 + export function onPostWrite(cb: (key: keyof Schema) => void): () => void { 74 + _postWriteListeners.add(cb) 75 + return () => { 76 + _postWriteListeners.delete(cb) 77 + } 78 + } 79 + onPostWrite satisfies PersistedApi['onPostWrite'] 53 80 54 81 export async function clearStorage() { 55 82 try {
+16
src/state/persisted/index.web.ts
··· 21 21 22 22 let _state: Schema = defaults 23 23 const _emitter = new EventEmitter() 24 + const _postWriteListeners = new Set<(key: keyof Schema) => void>() 24 25 25 26 // async, to match native implementation 26 27 // eslint-disable-next-line @typescript-eslint/require-await ··· 68 69 writeToStorage(_state) 69 70 broadcast.postMessage({event: {type: UPDATE_EVENT, key}}) 70 71 broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading 72 + for (const cb of _postWriteListeners) { 73 + try { 74 + cb(key) 75 + } catch (e) { 76 + logger.error('persisted onPostWrite listener failed', {message: String(e)}) 77 + } 78 + } 71 79 } 72 80 write satisfies PersistedApi['write'] 73 81 ··· 84 92 } 85 93 } 86 94 onUpdate satisfies PersistedApi['onUpdate'] 95 + 96 + export function onPostWrite(cb: (key: keyof Schema) => void): () => void { 97 + _postWriteListeners.add(cb) 98 + return () => { 99 + _postWriteListeners.delete(cb) 100 + } 101 + } 102 + onPostWrite satisfies PersistedApi['onPostWrite'] 87 103 88 104 // eslint-disable-next-line @typescript-eslint/require-await 89 105 export async function clearStorage() {
+1 -1
src/state/persisted/schema.ts
··· 47 47 }) 48 48 export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema> 49 49 50 - const schema = z.object({ 50 + export const schema = z.object({ 51 51 colorMode: z.enum(['system', 'light', 'dark']), 52 52 darkTheme: z.enum(['dim', 'dark']).optional(), 53 53 colorScheme: z.enum([
+1
src/state/persisted/types.ts
··· 8 8 key: K, 9 9 cb: (v: Schema[K]) => void, 10 10 ): () => void 11 + onPostWrite(cb: (key: keyof Schema) => void): () => void 11 12 clearStorage: () => Promise<void> 12 13 }
+6 -3
src/state/preferences/index.tsx
··· 47 47 import {Provider as TrendingSettingsProvider} from './trending' 48 48 import {Provider as UseHandleInLinksProvider} from './use-handle-in-links' 49 49 import {Provider as UsedStarterPacksProvider} from './used-starter-packs' 50 + import {WitchskyPreferencesSyncProvider} from './witchsky-preferences-sync' 50 51 51 52 export { 52 53 useRequireAltTextEnabled, ··· 139 140 <OpenRouterProvider> 140 141 <DisableComposerPromptProvider> 141 142 <DiscoverContextEnabledProvider> 142 - { 143 - children 144 - } 143 + <WitchskyPreferencesSyncProvider> 144 + { 145 + children 146 + } 147 + </WitchskyPreferencesSyncProvider> 145 148 </DiscoverContextEnabledProvider> 146 149 </DisableComposerPromptProvider> 147 150 </OpenRouterProvider>
+303
src/state/preferences/witchsky-preferences-sync.tsx
··· 1 + import {useCallback, useEffect, useRef} from 'react' 2 + import {AppState, type AppStateStatus} from 'react-native' 3 + import {ComAtprotoRepoPutRecord} from '@atproto/api' 4 + import {i18n} from '@lingui/core' 5 + import {msg} from '@lingui/core/macro' 6 + 7 + import {retry} from '#/lib/async/retry' 8 + import { 9 + applyWitchskyPreferencesRecord, 10 + buildWitchskyPreferencesRecordFromStore, 11 + isWitchskySyncedPersistKey, 12 + parseWitchskyPreferencesRecord, 13 + WITCHSKY_PREFERENCES_COLLECTION, 14 + WITCHSKY_PREFERENCES_RKEY, 15 + witchskyPreferencesStableKey, 16 + } from '#/lib/witchsky-preferences/record' 17 + import {logger} from '#/logger' 18 + import * as persisted from '#/state/persisted' 19 + import {useAgent, useSession} from '#/state/session' 20 + import * as Toast from '#/components/Toast' 21 + import {IS_WEB} from '#/env' 22 + 23 + const REMOTE_POLL_MS = 45_000 24 + 25 + function isRecordNotFoundError(e: unknown): boolean { 26 + const msgErr = e instanceof Error ? e.message : String(e) 27 + return msgErr.includes('Could not locate record') 28 + } 29 + 30 + function isRemoteRecordNewer( 31 + remoteUpdatedAt: string | undefined, 32 + lastKnownRemoteUpdatedAt: string | null, 33 + ): boolean { 34 + if (!remoteUpdatedAt) { 35 + return false 36 + } 37 + if (!lastKnownRemoteUpdatedAt) { 38 + return true 39 + } 40 + const remoteMs = Date.parse(remoteUpdatedAt) 41 + const lastMs = Date.parse(lastKnownRemoteUpdatedAt) 42 + if (Number.isNaN(remoteMs) || Number.isNaN(lastMs)) { 43 + return remoteUpdatedAt !== lastKnownRemoteUpdatedAt 44 + } 45 + return remoteMs > lastMs 46 + } 47 + 48 + export function WitchskyPreferencesSyncProvider({ 49 + children, 50 + }: { 51 + children: React.ReactNode 52 + }) { 53 + const {hasSession, currentAccount} = useSession() 54 + const agent = useAgent() 55 + const applyingRemoteRef = useRef(false) 56 + const lastPushedStableRef = useRef<string | null>(null) 57 + const lastKnownRemoteUpdatedAtRef = useRef<string | null>(null) 58 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) 59 + const pushInFlightRef = useRef(false) 60 + 61 + const pullRemoteIfNewer = useCallback(async () => { 62 + if (!hasSession || !currentAccount?.did) { 63 + return 64 + } 65 + if (applyingRemoteRef.current) { 66 + return 67 + } 68 + if (debounceRef.current !== null) { 69 + return 70 + } 71 + if (pushInFlightRef.current) { 72 + return 73 + } 74 + try { 75 + const {data} = await agent.com.atproto.repo.getRecord({ 76 + repo: currentAccount.did, 77 + collection: WITCHSKY_PREFERENCES_COLLECTION, 78 + rkey: WITCHSKY_PREFERENCES_RKEY, 79 + }) 80 + const parsed = parseWitchskyPreferencesRecord(data.value) 81 + if (!parsed) { 82 + return 83 + } 84 + if ( 85 + !isRemoteRecordNewer( 86 + parsed.updatedAt, 87 + lastKnownRemoteUpdatedAtRef.current, 88 + ) 89 + ) { 90 + return 91 + } 92 + applyingRemoteRef.current = true 93 + try { 94 + await applyWitchskyPreferencesRecord(parsed) 95 + lastPushedStableRef.current = witchskyPreferencesStableKey( 96 + buildWitchskyPreferencesRecordFromStore(), 97 + ) 98 + if (parsed.updatedAt) { 99 + lastKnownRemoteUpdatedAtRef.current = parsed.updatedAt 100 + } 101 + } finally { 102 + applyingRemoteRef.current = false 103 + } 104 + } catch (e) { 105 + if (isRecordNotFoundError(e)) { 106 + return 107 + } 108 + logger.info('Witchsky preferences: background fetch failed', { 109 + safeMessage: e, 110 + }) 111 + } 112 + }, [agent, currentAccount?.did, hasSession]) 113 + 114 + const flushPush = useCallback(async () => { 115 + if (!hasSession || !currentAccount?.did || applyingRemoteRef.current) { 116 + return 117 + } 118 + const record = buildWitchskyPreferencesRecordFromStore() 119 + const stable = witchskyPreferencesStableKey(record) 120 + if (stable === lastPushedStableRef.current) { 121 + return 122 + } 123 + 124 + pushInFlightRef.current = true 125 + try { 126 + const upsert = async () => { 127 + const existing = await agent.com.atproto.repo 128 + .getRecord({ 129 + repo: currentAccount.did, 130 + collection: WITCHSKY_PREFERENCES_COLLECTION, 131 + rkey: WITCHSKY_PREFERENCES_RKEY, 132 + }) 133 + .catch(e => { 134 + if (isRecordNotFoundError(e)) { 135 + return undefined 136 + } 137 + throw e 138 + }) 139 + 140 + await agent.com.atproto.repo.putRecord({ 141 + repo: currentAccount.did, 142 + collection: WITCHSKY_PREFERENCES_COLLECTION, 143 + rkey: WITCHSKY_PREFERENCES_RKEY, 144 + record, 145 + swapRecord: existing?.data.cid ?? null, 146 + }) 147 + } 148 + 149 + await retry( 150 + 5, 151 + e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError, 152 + upsert, 153 + ) 154 + 155 + lastPushedStableRef.current = witchskyPreferencesStableKey( 156 + buildWitchskyPreferencesRecordFromStore(), 157 + ) 158 + if (record.updatedAt) { 159 + lastKnownRemoteUpdatedAtRef.current = record.updatedAt 160 + } 161 + Toast.show(i18n._(msg`Preferences saved`)) 162 + } catch (e) { 163 + logger.warn('Witchsky preferences: failed to upload to PDS', { 164 + safeMessage: e, 165 + }) 166 + } finally { 167 + pushInFlightRef.current = false 168 + } 169 + }, [agent, currentAccount?.did, hasSession]) 170 + 171 + const schedulePush = useCallback(() => { 172 + if (!hasSession || !currentAccount?.did || applyingRemoteRef.current) { 173 + return 174 + } 175 + if (debounceRef.current) { 176 + clearTimeout(debounceRef.current) 177 + } 178 + debounceRef.current = setTimeout(() => { 179 + debounceRef.current = null 180 + void flushPush() 181 + }, 2500) 182 + }, [currentAccount?.did, flushPush, hasSession]) 183 + 184 + useEffect(() => { 185 + if (!hasSession) { 186 + if (debounceRef.current) { 187 + clearTimeout(debounceRef.current) 188 + debounceRef.current = null 189 + } 190 + lastPushedStableRef.current = null 191 + lastKnownRemoteUpdatedAtRef.current = null 192 + } 193 + }, [hasSession]) 194 + 195 + useEffect(() => { 196 + if (!hasSession || !currentAccount?.did) { 197 + return 198 + } 199 + let cancelled = false 200 + void (async () => { 201 + try { 202 + const {data} = await agent.com.atproto.repo.getRecord({ 203 + repo: currentAccount.did, 204 + collection: WITCHSKY_PREFERENCES_COLLECTION, 205 + rkey: WITCHSKY_PREFERENCES_RKEY, 206 + }) 207 + if (cancelled) { 208 + return 209 + } 210 + const parsed = parseWitchskyPreferencesRecord(data.value) 211 + if (!parsed) { 212 + return 213 + } 214 + applyingRemoteRef.current = true 215 + try { 216 + await applyWitchskyPreferencesRecord(parsed) 217 + lastPushedStableRef.current = witchskyPreferencesStableKey( 218 + buildWitchskyPreferencesRecordFromStore(), 219 + ) 220 + if (parsed.updatedAt) { 221 + lastKnownRemoteUpdatedAtRef.current = parsed.updatedAt 222 + } 223 + } finally { 224 + applyingRemoteRef.current = false 225 + } 226 + } catch (e) { 227 + if (cancelled) { 228 + return 229 + } 230 + if (isRecordNotFoundError(e)) { 231 + lastPushedStableRef.current = witchskyPreferencesStableKey( 232 + buildWitchskyPreferencesRecordFromStore(), 233 + ) 234 + return 235 + } 236 + logger.info('Witchsky preferences: fetch failed', { 237 + safeMessage: e, 238 + }) 239 + } 240 + })() 241 + return () => { 242 + cancelled = true 243 + } 244 + }, [agent, currentAccount?.did, hasSession]) 245 + 246 + useEffect(() => { 247 + return persisted.onPostWrite(key => { 248 + if (applyingRemoteRef.current) { 249 + return 250 + } 251 + if (!isWitchskySyncedPersistKey(key)) { 252 + return 253 + } 254 + schedulePush() 255 + }) 256 + }, [schedulePush]) 257 + 258 + useEffect(() => { 259 + if (!hasSession || !currentAccount?.did) { 260 + return 261 + } 262 + const id = setInterval(() => { 263 + void pullRemoteIfNewer() 264 + }, REMOTE_POLL_MS) 265 + return () => clearInterval(id) 266 + }, [currentAccount?.did, hasSession, pullRemoteIfNewer]) 267 + 268 + useEffect(() => { 269 + if (!hasSession || !currentAccount?.did) { 270 + return 271 + } 272 + if (IS_WEB) { 273 + const onBecameActive = () => { 274 + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { 275 + return 276 + } 277 + void pullRemoteIfNewer() 278 + } 279 + window.addEventListener('focus', onBecameActive) 280 + document.addEventListener('visibilitychange', onBecameActive) 281 + return () => { 282 + window.removeEventListener('focus', onBecameActive) 283 + document.removeEventListener('visibilitychange', onBecameActive) 284 + } 285 + } 286 + const sub = AppState.addEventListener('change', (next: AppStateStatus) => { 287 + if (next === 'active') { 288 + void pullRemoteIfNewer() 289 + } 290 + }) 291 + return () => sub.remove() 292 + }, [currentAccount?.did, hasSession, pullRemoteIfNewer]) 293 + 294 + useEffect(() => { 295 + return () => { 296 + if (debounceRef.current) { 297 + clearTimeout(debounceRef.current) 298 + } 299 + } 300 + }, []) 301 + 302 + return <>{children}</> 303 + }