BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { asModerationLabels } from "$/lib/moderation";
2import type {
3 ConvoView,
4 DeletedMessageView,
5 GetConvoForMembersResponse,
6 GetMessagesResponse,
7 ListConvosResponse,
8 MessageView,
9 ProfileViewBasic,
10} from "$/lib/types";
11import { invoke } from "@tauri-apps/api/core";
12import { asArray, asRecord } from "../type-guards";
13
14function optionalNumber(value: unknown) {
15 return typeof value === "number" ? value : null;
16}
17
18function optionalString(value: unknown) {
19 return typeof value === "string" ? value : null;
20}
21
22function parseProfileBasic(value: unknown): ProfileViewBasic | null {
23 const record = asRecord(value);
24 if (!record || typeof record.did !== "string" || typeof record.handle !== "string") {
25 return null;
26 }
27
28 const viewer = asRecord(record.viewer);
29
30 return {
31 avatar: optionalString(record.avatar),
32 did: record.did,
33 displayName: optionalString(record.displayName),
34 handle: record.handle,
35 labels: asModerationLabels(record),
36 viewer: viewer ? { following: optionalString(viewer.following) } : null,
37 };
38}
39
40function parseMessageSender(value: unknown) {
41 const record = asRecord(value);
42 if (!record || typeof record.did !== "string") {
43 return null;
44 }
45
46 return { did: record.did };
47}
48
49function parseDeletedMessageView(value: unknown): DeletedMessageView | null {
50 const record = asRecord(value);
51 const sender = parseMessageSender(record?.sender);
52 if (
53 !record || !sender || typeof record.id !== "string" || typeof record.rev !== "string"
54 || typeof record.sentAt !== "string"
55 ) {
56 return null;
57 }
58
59 return {
60 $type: optionalString(record.$type) as DeletedMessageView["$type"],
61 id: record.id,
62 rev: record.rev,
63 sender,
64 sentAt: record.sentAt,
65 };
66}
67
68function parseMessageView(value: unknown): MessageView | null {
69 const record = asRecord(value);
70 const sender = parseMessageSender(record?.sender);
71 if (
72 !record
73 || !sender
74 || typeof record.id !== "string"
75 || typeof record.rev !== "string"
76 || typeof record.sentAt !== "string"
77 || typeof record.text !== "string"
78 ) {
79 return null;
80 }
81
82 return {
83 $type: optionalString(record.$type) as MessageView["$type"],
84 id: record.id,
85 rev: record.rev,
86 sender,
87 sentAt: record.sentAt,
88 text: record.text,
89 };
90}
91
92function parseConvoMessage(value: unknown) {
93 const record = asRecord(value);
94 if (!record) {
95 return null;
96 }
97
98 if (record.$type === "chat.bsky.convo.defs#deletedMessageView") {
99 return parseDeletedMessageView(record);
100 }
101
102 return parseMessageView(record);
103}
104
105function parseConvoView(value: unknown): ConvoView | null {
106 const record = asRecord(value);
107 const rawMembers = asArray(record?.members);
108 if (
109 !record
110 || !rawMembers
111 || typeof record.id !== "string"
112 || typeof record.rev !== "string"
113 || typeof record.muted !== "boolean"
114 ) {
115 return null;
116 }
117
118 const members = rawMembers.map((member) => parseProfileBasic(member));
119 if (members.some((member) => !member)) {
120 return null;
121 }
122
123 const lastMessage = record.lastMessage === null || record.lastMessage === undefined
124 ? null
125 : parseConvoMessage(record.lastMessage);
126 if (record.lastMessage !== null && record.lastMessage !== undefined && !lastMessage) {
127 return null;
128 }
129
130 return {
131 id: record.id,
132 lastMessage,
133 members: members as ProfileViewBasic[],
134 muted: record.muted,
135 rev: record.rev,
136 status: optionalString(record.status),
137 unreadCount: optionalNumber(record.unreadCount) ?? 0,
138 };
139}
140
141export function parseListConvosResponse(value: unknown): ListConvosResponse {
142 const record = asRecord(value);
143 const rawConvos = asArray(record?.convos);
144 if (!record || !rawConvos) {
145 throw new Error("conversations response payload is invalid");
146 }
147
148 const convos = rawConvos.map((convo) => parseConvoView(convo));
149 if (convos.some((convo) => !convo)) {
150 throw new Error("conversations response contains an invalid conversation");
151 }
152
153 if (record.cursor !== undefined && record.cursor !== null && typeof record.cursor !== "string") {
154 throw new Error("conversations response cursor is invalid");
155 }
156
157 return { convos: convos as ConvoView[], cursor: optionalString(record.cursor) };
158}
159
160export function parseGetConvoForMembersResponse(value: unknown): GetConvoForMembersResponse {
161 const record = asRecord(value);
162 const convo = parseConvoView(record?.convo);
163 if (!record || !convo) {
164 throw new Error("conversation payload is invalid");
165 }
166
167 return { convo };
168}
169
170export function parseGetMessagesResponse(value: unknown): GetMessagesResponse {
171 const record = asRecord(value);
172 const rawMessages = asArray(record?.messages);
173 if (!record || !rawMessages) {
174 throw new Error("messages response payload is invalid");
175 }
176
177 const messages = rawMessages.map((message) => parseConvoMessage(message));
178 if (messages.some((message) => !message)) {
179 throw new Error("messages response contains an invalid message");
180 }
181
182 if (record.cursor !== undefined && record.cursor !== null && typeof record.cursor !== "string") {
183 throw new Error("messages response cursor is invalid");
184 }
185
186 return { cursor: optionalString(record.cursor), messages: messages as Array<MessageView | DeletedMessageView> };
187}
188
189export function parseSendMessageResponse(value: unknown): MessageView {
190 const message = parseMessageView(value);
191 if (!message) {
192 throw new Error("sent message payload is invalid");
193 }
194
195 return message;
196}
197
198async function listConvos(cursor?: string | null, limit?: number): Promise<ListConvosResponse> {
199 return invoke("list_convos", { cursor: cursor ?? null, limit: limit ?? null }).then(parseListConvosResponse);
200}
201
202async function getConvoForMembers(members: string[]): Promise<GetConvoForMembersResponse> {
203 return invoke("get_convo_for_members", { members }).then(parseGetConvoForMembersResponse);
204}
205
206async function getMessages(convoId: string, cursor?: string | null, limit?: number): Promise<GetMessagesResponse> {
207 return invoke("get_messages", { convoId, cursor: cursor ?? null, limit: limit ?? null }).then(
208 parseGetMessagesResponse,
209 );
210}
211
212async function sendMessage(convoId: string, text: string): Promise<MessageView> {
213 return invoke("send_message", { convoId, text }).then(parseSendMessageResponse);
214}
215
216async function updateRead(convoId: string, messageId?: string | null): Promise<void> {
217 return invoke("update_read", { convoId, messageId: messageId ?? null });
218}
219
220export const ConvoController = { listConvos, getConvoForMembers, getMessages, sendMessage, updateRead };