BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { parseFeedResponse } from "$/lib/feeds";
2import { isReplyItem } from "$/lib/feeds/type-guards";
3import { asModerationLabels } from "$/lib/moderation";
4import type {
5 ActorListResponse,
6 FeedResponse,
7 FeedViewPost,
8 ProfileLookupResult,
9 ProfileUnavailableReason,
10 ProfileViewBasic,
11 ProfileViewDetailed,
12 RichTextFacet,
13} from "$/lib/types";
14import { asArray, asRecord, optionalNumber, optionalString } from "./type-guards";
15
16export type ProfileTab = "posts" | "replies" | "media" | "likes" | "context";
17
18export function buildProfileRoute(actor?: string | null) {
19 const trimmed = actor?.trim();
20 if (!trimmed) {
21 return "/profile";
22 }
23
24 return `/profile/${encodeURIComponent(trimmed)}`;
25}
26
27export function decodeProfileRouteActor(value?: string | null) {
28 if (!value) {
29 return null;
30 }
31
32 try {
33 return decodeURIComponent(value);
34 } catch {
35 return value;
36 }
37}
38
39export function getProfileRouteActor(actor: { did: string; handle?: string | null }) {
40 return actor.handle?.trim() || actor.did;
41}
42
43function parseProfile(value: unknown): ProfileViewDetailed {
44 const record = asRecord(value);
45 if (!record || typeof record.did !== "string" || typeof record.handle !== "string") {
46 throw new Error("profile payload is invalid");
47 }
48
49 const pinnedPost = asRecord(record.pinnedPost);
50
51 return {
52 avatar: optionalString(record.avatar),
53 banner: optionalString(record.banner),
54 createdAt: optionalString(record.createdAt),
55 description: optionalString(record.description),
56 descriptionFacets: parseRichTextFacets(record.descriptionFacets),
57 did: record.did,
58 displayName: optionalString(record.displayName),
59 followersCount: optionalNumber(record.followersCount),
60 followsCount: optionalNumber(record.followsCount),
61 handle: record.handle,
62 indexedAt: optionalString(record.indexedAt),
63 labels: asModerationLabels(record),
64 pinnedPost: pinnedPost && typeof pinnedPost.uri === "string"
65 ? { cid: optionalString(pinnedPost.cid), uri: pinnedPost.uri }
66 : null,
67 postsCount: optionalNumber(record.postsCount),
68 pronouns: optionalString(record.pronouns),
69 viewer: parseProfileViewer(record.viewer),
70 website: optionalString(record.website),
71 };
72}
73
74function parseRichTextFacets(value: unknown): RichTextFacet[] | null {
75 const facets = asArray(value);
76 return (facets as RichTextFacet[] | null) || null;
77}
78
79export function parseProfileResult(value: unknown): ProfileLookupResult {
80 const record = asRecord(value);
81 if (!record || record.status === "available" && !asRecord(record.profile)) {
82 throw new Error("profile result payload is invalid");
83 }
84
85 if (record.status === "available") {
86 return { status: "available", profile: parseProfile(record.profile) };
87 }
88
89 if (
90 record.status !== "unavailable"
91 || typeof record.requestedActor !== "string"
92 || typeof record.message !== "string"
93 || !isProfileUnavailableReason(record.reason)
94 ) {
95 throw new Error("profile result payload is invalid");
96 }
97
98 return {
99 status: "unavailable",
100 requestedActor: record.requestedActor,
101 did: optionalString(record.did),
102 handle: optionalString(record.handle),
103 reason: record.reason,
104 message: record.message,
105 };
106}
107
108export function parseProfileFeed(value: unknown): FeedResponse {
109 return parseFeedResponse(value);
110}
111
112export function parseActorList(value: unknown, listKey: "followers" | "follows"): ActorListResponse {
113 const record = asRecord(value);
114 if (!record) {
115 throw new Error("actor list payload is invalid");
116 }
117
118 const rawActors = asArray(record[listKey]) ?? [];
119 const actors = rawActors.map((item) => parseProfileBasic(item)).filter(Boolean) as ProfileViewBasic[];
120
121 return { cursor: optionalString(record.cursor), actors };
122}
123
124function parseProfileBasic(value: unknown): ProfileViewBasic | null {
125 const record = asRecord(value);
126 if (!record || typeof record.did !== "string" || typeof record.handle !== "string") {
127 return null;
128 }
129
130 return {
131 did: record.did,
132 handle: record.handle,
133 displayName: optionalString(record.displayName),
134 avatar: optionalString(record.avatar),
135 description: optionalString(record.description),
136 labels: asModerationLabels(record),
137 viewer: asRecord(record.viewer) ? { following: optionalString(asRecord(record.viewer)?.following) } : null,
138 };
139}
140
141export function filterProfileFeed(items: FeedViewPost[], tab: ProfileTab) {
142 switch (tab) {
143 case "posts": {
144 return items.filter((item) => !isReplyItem(item));
145 }
146 case "replies": {
147 return items.filter((item) => isReplyItem(item));
148 }
149 case "media": {
150 return items.filter((item) => !!item.post.embed);
151 }
152 case "context": {
153 return [];
154 }
155 default: {
156 return items;
157 }
158 }
159}
160
161function parseProfileViewer(value: unknown) {
162 const record = asRecord(value);
163 if (!record) {
164 return null;
165 }
166
167 return {
168 blockedBy: typeof record.blockedBy === "boolean" ? record.blockedBy : null,
169 followedBy: optionalString(record.followedBy),
170 following: optionalString(record.following),
171 muted: typeof record.muted === "boolean" ? record.muted : null,
172 };
173}
174
175function isProfileUnavailableReason(value: unknown): value is ProfileUnavailableReason {
176 return value === "notFound" || value === "suspended" || value === "deactivated" || value === "unavailable";
177}