···64646565 <View style={styles.pdsSwitchRow}>
6666 <Text style={styles.pdsSwitchLabel}>
6767- Also delete my watch history from my PDS
6767+ Also delete my OpnShelf data from my PDS
6868 </Text>
6969 <Switch
7070 value={deletePDSData}
···7676 {deletePDSData ? (
7777 <View style={styles.deleteWarningBox}>
7878 <Text style={styles.deleteWarningText}>
7979- Your watch history will be permanently deleted from your personal
7979+ Your OpnShelf data, including watch history, follows, lists,
8080+ and list items, will be permanently deleted from your personal
8081 data server. This cannot be recovered.
8182 </Text>
8283 </View>
8384 ) : (
8485 <View style={styles.deleteInfoBox}>
8586 <Text style={styles.deleteInfoText}>
8686- Your watch history will remain on your PDS. You can use another app
8787- or re-authorize OpnShelf later to access it.
8787+ Your OpnShelf data will remain on your PDS. You can use another
8888+ app or re-authorize OpnShelf later to access it.
8889 </Text>
8990 </View>
9091 )}
+24-6
apps/web/src/routes/profile.$handle.settings.tsx
···8787 component: SettingsPage,
8888});
89899090+function getErrorMessage(error: unknown, fallback: string): string {
9191+ if (error instanceof Error && error.message) {
9292+ return error.message;
9393+ }
9494+9595+ if (
9696+ error &&
9797+ typeof error === "object" &&
9898+ "message" in error &&
9999+ typeof error.message === "string"
100100+ ) {
101101+ return error.message;
102102+ }
103103+104104+ return fallback;
105105+}
106106+90107function SettingsPage() {
91108 const router = useRouter();
92109 const queryClient = useQueryClient();
···146163 queryClient.removeQueries({ queryKey: authControllerMeQueryKey() });
147164 router.navigate({ to: "/" });
148165 },
149149- onError: () => {
150150- toast.error("Failed to delete account");
166166+ onError: (error) => {
167167+ toast.error(getErrorMessage(error, "Failed to delete account"));
151168 },
152169 });
153170···419436 htmlFor={deletePdsId}
420437 className="md-body-medium cursor-pointer text-[var(--md-sys-color-on-surface)]"
421438 >
422422- Also delete my watch history from my PDS
439439+ Also delete my OpnShelf data from my PDS
423440 </Label>
424441 </div>
425442···432449 }}
433450 >
434451 <p className="md-body-medium text-[var(--md-sys-color-error)]">
435435- Your watch history will be permanently deleted from your
436436- personal data server. This cannot be recovered.
452452+ Your OpnShelf data, including watch history, follows, lists,
453453+ and list items, will be permanently deleted from your personal
454454+ data server. This cannot be recovered.
437455 </p>
438456 </div>
439457 ) : (
···444462 }}
445463 >
446464 <p className="md-body-medium text-[var(--md-sys-color-on-surface-variant)]">
447447- Your watch history will remain on your PDS. You can use
465465+ Your OpnShelf data will remain on your PDS. You can use
448466 another app or re-authorize OpnShelf later to access it.
449467 </p>
450468 </div>
···1212} from "@nestjs/common";
1313import { ConfigService } from "@nestjs/config";
1414import {
1515+ $nsid as FOLLOW_COLLECTION,
1616+ main as followSchema,
1717+} from "../lexicons/xyz/opnshelf/follow";
1818+import type { Main as FollowRecord } from "../lexicons/xyz/opnshelf/follow.defs";
1919+import {
1520 $nsid as LIST_COLLECTION,
1621 main as listSchema,
1722} from "../lexicons/xyz/opnshelf/list";
···3439import { ListsService } from "../lists/lists.service";
3540import { MoviesService } from "../movies/movies.service";
3641import { PrismaService } from "../prisma/prisma.service";
4242+import { SocialService } from "../social/social.service";
3743import { ShowsService } from "../shows/shows.service";
38443945@Injectable()
···5056 private readonly moviesService: MoviesService,
5157 private readonly showsService: ShowsService,
5258 private readonly listsService: ListsService,
5959+ private readonly socialService: SocialService,
5360 ) {
5461 this.tapUrl = this.config.get<string>("TAP_URL") ?? "http://localhost:2480";
5562 this.tapAdminPassword = this.config.get<string>("TAP_ADMIN_PASSWORD");
···203210 await this.handleMovieEvent(evt, uri);
204211 } else if (evt.collection === EPISODE_COLLECTION) {
205212 await this.handleEpisodeEvent(evt, uri);
213213+ } else if (evt.collection === FOLLOW_COLLECTION) {
214214+ await this.handleFollowEvent(evt, uri);
206215 } else if (evt.collection === LIST_COLLECTION) {
207216 await this.handleListEvent(evt, uri);
208217 } else if (evt.collection === LIST_ITEM_COLLECTION) {
209218 await this.handleListItemEvent(evt, uri);
210219 } else {
211220 this.logger.debug(`Skipping event for collection ${evt.collection}`);
221221+ }
222222+ }
223223+224224+ private async handleFollowEvent(evt: RecordEvent, uri: string) {
225225+ if (evt.action === "create" || evt.action === "update") {
226226+ if (!evt.record) {
227227+ this.logger.warn(`Record event missing record data: ${uri}`);
228228+ return;
229229+ }
230230+231231+ let followRecord: FollowRecord;
232232+ try {
233233+ followRecord = followSchema.parse(evt.record);
234234+ } catch {
235235+ this.logger.debug("Received invalid follow record, skipping");
236236+ return;
237237+ }
238238+239239+ const user = await this.prisma.user.findUnique({
240240+ where: { did: evt.did },
241241+ });
242242+243243+ if (!user) {
244244+ this.logger.debug(`User ${evt.did} not in database, skipping record`);
245245+ return;
246246+ }
247247+248248+ await this.socialService.indexFollowRecord(
249249+ evt.did,
250250+ evt.rkey,
251251+ evt.cid,
252252+ followRecord,
253253+ uri,
254254+ );
255255+ } else if (evt.action === "delete") {
256256+ await this.socialService.deleteFollowRecordIndex(evt.did, evt.rkey);
212257 }
213258 }
214259
+1
backend/src/lexicons/xyz/opnshelf.ts
···33 */
4455export * as episode from './opnshelf/episode.js'
66+export * as follow from './opnshelf/follow.js'
67export * as list from './opnshelf/list.js'
78export * as listItem from './opnshelf/listItem.js'
89export * as movie from './opnshelf/movie.js'
+51
backend/src/lexicons/xyz/opnshelf/follow.defs.ts
···11+/*
22+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
33+ */
44+55+import { l } from '@atproto/lex'
66+77+const $nsid = 'xyz.opnshelf.follow'
88+99+export { $nsid }
1010+1111+/** An OpnShelf follow relationship stored on a user's PDS */
1212+type Main = {
1313+ $type: 'xyz.opnshelf.follow'
1414+1515+ /**
1616+ * The DID of the OpnShelf user being followed
1717+ */
1818+ subjectDid: string
1919+2020+ /**
2121+ * When the follow record was created
2222+ */
2323+ createdAt: l.DatetimeString
2424+}
2525+2626+export type { Main }
2727+2828+/** An OpnShelf follow relationship stored on a user's PDS */
2929+const main = l.record<'tid', Main>(
3030+ 'tid',
3131+ $nsid,
3232+ l.object({
3333+ subjectDid: l.string(),
3434+ createdAt: l.string({ format: 'datetime' }),
3535+ }),
3636+)
3737+3838+export { main }
3939+4040+export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main),
4141+ $build = /*#__PURE__*/ main.build.bind(main),
4242+ $type = /*#__PURE__*/ main.$type
4343+export const $assert = /*#__PURE__*/ main.assert.bind(main),
4444+ $check = /*#__PURE__*/ main.check.bind(main),
4545+ $cast = /*#__PURE__*/ main.cast.bind(main),
4646+ $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main),
4747+ $matches = /*#__PURE__*/ main.matches.bind(main),
4848+ $parse = /*#__PURE__*/ main.parse.bind(main),
4949+ $safeParse = /*#__PURE__*/ main.safeParse.bind(main),
5050+ $validate = /*#__PURE__*/ main.validate.bind(main),
5151+ $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
+6
backend/src/lexicons/xyz/opnshelf/follow.ts
···11+/*
22+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
33+ */
44+55+export * from './follow.defs.js'
66+export * as $defs from './follow.defs.js'
···2424export class DeleteUserAccountDto {
2525 @ApiProperty({
2626 description:
2727- "Whether to delete the user's watch history from their PDS. If false, the data remains on their PDS.",
2727+ "Whether to delete the user's OpnShelf data from their PDS, including watch history, follows, lists, and list items. If false, the data remains on their PDS.",
2828 default: false,
2929 })
3030 @IsBoolean()