Live video on the AT Protocol
1import {
2 Agent,
3 AppBskyFeedPost,
4 AppBskyGraphBlock,
5 BlobRef,
6 RichText,
7} from "@atproto/api";
8import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
9import { OutputSchema } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords";
10import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto";
11import { OAuthSession } from "@atproto/oauth-client";
12import { storage } from "@streamplace/components";
13import { Platform } from "react-native";
14import { AppStore } from "store";
15import {
16 PlaceStreamChatProfile,
17 PlaceStreamKey,
18 PlaceStreamLivestream,
19 PlaceStreamServerSettings,
20 StreamplaceAgent,
21} from "streamplace";
22import clearQueryParams from "utils/clear-query-params";
23import { privateKeyToAccount } from "viem/accounts";
24import { StateCreator } from "zustand";
25import createOAuthClient, {
26 StreamplaceOAuthClient,
27} from "../../features/bluesky/oauthClient";
28import { DID_KEY, STORED_KEY_KEY, StreamKey } from "./baseSlice";
29
30type NewLivestream = {
31 loading: boolean;
32 error: string | null;
33 record: PlaceStreamLivestream.Record | null;
34};
35
36export interface BlueskySlice {
37 authStatus: "start" | "loggedIn" | "loggedOut";
38 oauthState: null | string;
39 oauthSession?: null | OAuthSession;
40 pdsAgent: null | StreamplaceAgent;
41 anonPDSAgent: null | StreamplaceAgent;
42 profiles: { [key: string]: ProfileViewDetailed };
43 profileCache: { [key: string]: ProfileViewDetailed };
44 client: null | StreamplaceOAuthClient;
45 loginState: {
46 loading: boolean;
47 error: null | string;
48 };
49 pds: {
50 url: string;
51 loading: boolean;
52 error: null | string;
53 };
54 newKey: null | StreamKey;
55 storedKey: null | StreamKey;
56 isDeletingKey: boolean;
57 streamKeysResponse: {
58 loading: boolean;
59 error: null | string;
60 records: null | OutputSchema;
61 };
62 newLivestream: null | NewLivestream;
63 chatProfile: {
64 loading: boolean;
65 error: null | string;
66 profile: null | PlaceStreamChatProfile.Record;
67 };
68 serverSettings: null | PlaceStreamServerSettings.Record;
69 returnRoute: null | { name: string; params?: any };
70 notification: {
71 message: string;
72 type: "error" | "success" | "info";
73 } | null;
74 // actions
75 clearNotification: () => void;
76 loadOAuthClient: () => Promise<void>;
77 oauthError: (error: string, description: string) => void;
78 login: (
79 handle: string,
80 openLoginLink: (url: string) => Promise<void>,
81 ) => Promise<void>;
82 logout: () => Promise<void>;
83 getProfile: (actor: string) => Promise<void>;
84 getProfiles: (actors: string[]) => Promise<void>;
85 oauthCallback: (url: string) => Promise<void>;
86 setReturnRoute: (route: { name: string; params?: any } | null) => void;
87 showLoginModal: boolean;
88 openLoginModal: (returnRoute?: { name: string; params?: any }) => void;
89 closeLoginModal: () => void;
90 showPdsModal: boolean;
91 openPdsModal: () => void;
92 closePdsModal: () => void;
93 golivePost: (
94 text: string,
95 now: Date,
96 thumbnail?: BlobRef,
97 ) => Promise<{ uri: string; cid: string }>;
98 createBlockRecord: (subjectDID: string) => Promise<void>;
99 createStreamKeyRecord: (store: boolean) => Promise<void>;
100 clearStreamKeyRecord: () => void;
101 getStreamKeyRecords: () => Promise<void>;
102 deleteStreamKeyRecord: (rkey: string) => Promise<void>;
103 setPDS: (pds: string) => Promise<void>;
104 createLivestreamRecord: (
105 title: string,
106 customThumbnail?: Blob,
107 ) => Promise<void>;
108 updateLivestreamRecord: (title: string, livestream: any) => Promise<void>;
109 getChatProfileRecordFromPDS: () => Promise<void>;
110 createChatProfileRecord: (
111 red: number,
112 green: number,
113 blue: number,
114 ) => Promise<void>;
115 followUser: (subjectDID: string) => Promise<void>;
116 unfollowUser: (subjectDID: string, followUri?: string) => Promise<void>;
117 getServerSettingsFromPDS: () => Promise<void>;
118 createServerSettingsRecord: (debugRecording: boolean) => Promise<void>;
119}
120
121const uploadThumbnail = async (
122 handle: string,
123 u: URL,
124 pdsAgent: StreamplaceAgent,
125 profile: ProfileViewDetailed,
126 customThumbnail?: Blob,
127) => {
128 if (customThumbnail) {
129 let tries = 0;
130 try {
131 let thumbnail = await pdsAgent.uploadBlob(customThumbnail);
132
133 while (
134 thumbnail.data.blob.size === 0 &&
135 customThumbnail.size !== 0 &&
136 tries < 3
137 ) {
138 console.warn(
139 "Reuploading blob as blob sizes don't match! Blob size recieved is",
140 thumbnail.data.blob.size,
141 "and sent blob size is",
142 customThumbnail.size,
143 );
144 thumbnail = await pdsAgent.uploadBlob(customThumbnail);
145 }
146
147 if (tries === 3) {
148 throw new Error("Could not successfully upload blob (tried thrice)");
149 }
150
151 if (thumbnail.success) {
152 console.log("Successfully uploaded thumbnail");
153 return thumbnail.data.blob;
154 }
155 } catch (e) {
156 throw new Error("Error uploading thumbnail: " + e);
157 }
158 }
159};
160
161export const createBlueskySlice: StateCreator<
162 AppStore,
163 [],
164 [],
165 BlueskySlice
166> = (set, get) => ({
167 authStatus: "start",
168 oauthState: null,
169 oauthSession: undefined,
170 pdsAgent: null,
171 anonPDSAgent: null,
172 profiles: {},
173 profileCache: {},
174 client: null,
175 loginState: {
176 loading: false,
177 error: null,
178 },
179 pds: {
180 url: "bsky.social",
181 loading: false,
182 error: null,
183 },
184 newKey: null,
185 storedKey: null,
186 isDeletingKey: false,
187 streamKeysResponse: {
188 loading: true,
189 error: null,
190 records: null,
191 },
192 newLivestream: null,
193 chatProfile: {
194 loading: false,
195 error: null,
196 profile: null,
197 },
198 serverSettings: null,
199 returnRoute: null,
200 showLoginModal: false,
201 showPdsModal: false,
202 notification: null,
203
204 clearNotification: () => {
205 clearQueryParams();
206 set({ notification: null });
207 },
208
209 setReturnRoute: async (route: { name: string; params?: any } | null) => {
210 console.log("setReturnRoute:", route);
211 if (route) {
212 await storage.setItem("returnRoute", JSON.stringify(route));
213 } else {
214 await storage.removeItem("returnRoute");
215 }
216 set({ returnRoute: route });
217 },
218
219 openLoginModal: async (returnRoute?: { name: string; params?: any }) => {
220 console.log("openLoginModal with returnRoute:", returnRoute);
221 if (returnRoute) {
222 await storage.setItem("returnRoute", JSON.stringify(returnRoute));
223 }
224 set({ showLoginModal: true, returnRoute: returnRoute || null });
225 },
226
227 closeLoginModal: () => {
228 console.log("closeLoginModal");
229 set({ showLoginModal: false });
230 },
231
232 openPdsModal: () => {
233 set({ showPdsModal: true });
234 },
235
236 closePdsModal: () => {
237 set({ showPdsModal: false });
238 },
239
240 loadOAuthClient: async () => {
241 set({ authStatus: "start" });
242 try {
243 const streamplaceUrl = get().url;
244 const client = await createOAuthClient(streamplaceUrl);
245 const anonPDSAgent = new StreamplaceAgent(streamplaceUrl);
246 const maybeDIDs = await Promise.all([
247 storage.getItem(DID_KEY),
248 storage.getItem("@@atproto/oauth-client-browser(sub)"),
249 storage.getItem("@@atproto/oauth-client-react-native:did:(sub)"),
250 ]);
251 const did = maybeDIDs.find((d) => d !== null) || null;
252 let session: OAuthSession | null = null;
253 if (did) {
254 try {
255 session = await client.restore(did);
256 } catch (e) {
257 console.error("Error restoring session", e);
258 // oh well, delete the session and start fresh
259 await storage.removeItem(DID_KEY);
260 await storage.removeItem("@@atproto/oauth-client-browser(sub)");
261 await storage.removeItem(
262 "@@atproto/oauth-client-react-native:did:(sub)",
263 );
264 }
265 }
266 console.log("loadOAuthClient fulfilled", {
267 client,
268 session,
269 anonPDSAgent,
270 });
271 console.log("session?", session);
272 if (session) {
273 storage.setItem(DID_KEY, session.did).catch((e) => {
274 console.error("Error setting did", e);
275 });
276 set({
277 client,
278 authStatus: "loggedIn",
279 oauthSession: session,
280 pdsAgent: new StreamplaceAgent(session),
281 anonPDSAgent,
282 });
283 } else {
284 set({
285 oauthSession: session,
286 authStatus: "loggedOut",
287 client,
288 anonPDSAgent,
289 });
290 }
291 } catch (error) {
292 console.error("loadOAuthClient error", error);
293 }
294 },
295
296 oauthError: (error: string, description: string) => {
297 const message = description || error || "authentication failed";
298 set({
299 loginState: {
300 loading: false,
301 error: message,
302 },
303 authStatus: "loggedOut",
304 notification: {
305 message,
306 type: "error",
307 },
308 });
309 },
310
311 login: async (
312 handle: string,
313 openLoginLink: (url: string) => Promise<void>,
314 ) => {
315 console.log("Logging in");
316 set({
317 loginState: {
318 loading: true,
319 error: null,
320 },
321 });
322 try {
323 const state = get() as BlueskySlice;
324 await state.loadOAuthClient();
325 const updatedState = get() as BlueskySlice;
326 if (!updatedState.client) {
327 throw new Error("No client");
328 }
329 console.log("Authorizing");
330 const u = await updatedState.client.authorize(handle, {});
331 if (
332 typeof document !== "undefined" &&
333 document.location.href.startsWith("http://127.0.0.1")
334 ) {
335 const hostUrl = new URL(document.location.href);
336 u.host = hostUrl.host;
337 u.protocol = hostUrl.protocol;
338 }
339 console.log("Opening link");
340 await openLoginLink(u.toString());
341 // cheeky 500ms delay so you don't see the text flash back
342 await new Promise((resolve) => setTimeout(resolve, 5000));
343 set({
344 loginState: {
345 loading: false,
346 error: null,
347 },
348 });
349 } catch (error) {
350 console.error("login rejected", error);
351 set({
352 loginState: {
353 loading: false,
354 error: error?.message ?? null,
355 },
356 notification: {
357 message: error?.message || "unknown error",
358 type: "error",
359 },
360 });
361 }
362 },
363
364 logout: async () => {
365 await storage.removeItem("did");
366 await storage.removeItem(STORED_KEY_KEY);
367 const state = get() as BlueskySlice;
368 if (!state.oauthSession) {
369 throw new Error("No oauth session");
370 }
371 await state.oauthSession.signOut();
372 set({
373 oauthSession: null,
374 pdsAgent: null,
375 authStatus: "loggedOut",
376 });
377 },
378
379 getProfile: async (actor: string) => {
380 try {
381 const state = get() as BlueskySlice;
382 if (!state.pdsAgent) {
383 throw new Error("No agent");
384 }
385 const result = await state.pdsAgent.getProfile({ actor });
386 clearQueryParams();
387 set((s) => ({
388 authStatus: "loggedIn",
389 profiles: {
390 ...(s as BlueskySlice).profiles,
391 [actor]: result.data,
392 },
393 }));
394 } catch (error) {
395 clearQueryParams();
396 set({ authStatus: "loggedOut" });
397 }
398 },
399
400 getProfiles: async (actors: string[]) => {
401 if (actors.length > 25) {
402 throw Error("Requested too many actors! (max 25 actors)");
403 }
404 try {
405 const bskyAgent = new Agent("https://public.api.bsky.app");
406 const payload = await bskyAgent.getProfiles({ actors });
407 let parsedProfiles = {};
408 console.log(payload);
409 payload.data.profiles.forEach((p) => {
410 parsedProfiles[p.did] = p;
411 });
412 set((s) => ({
413 profileCache: {
414 ...(s as BlueskySlice).profileCache,
415 ...parsedProfiles,
416 },
417 }));
418 } catch (error) {
419 console.error("getProfiles error", error);
420 }
421 },
422
423 oauthCallback: async (url: string) => {
424 set({ authStatus: "start" });
425 try {
426 console.log("oauthCallback", url);
427 if (!url.includes("?")) {
428 throw new Error("No query params");
429 }
430 const params = new URLSearchParams(url.split("?")[1]);
431 if (!(params.has("code") && params.has("state") && params.has("iss"))) {
432 if (params.has("error")) {
433 const blueskySlice = get() as BlueskySlice;
434 blueskySlice.oauthError(
435 params.get("error") ?? "",
436 params.get("error_description") ?? "",
437 );
438 }
439 throw new Error("Missing params, got: " + url);
440 }
441 const streamplaceUrl = get().url;
442 const client = await createOAuthClient(streamplaceUrl);
443 try {
444 const ret = await client.callback(params);
445 await storage.setItem(DID_KEY, ret.session.did);
446 console.log("oauthCallback fulfilled", {
447 session: ret.session,
448 client,
449 });
450 set({
451 client,
452 oauthSession: ret.session,
453 pdsAgent: new StreamplaceAgent(ret.session),
454 authStatus: "loggedIn",
455 });
456 } catch (e) {
457 let message = e.message;
458 while (e.cause) {
459 message = `${message}: ${e.cause.message}`;
460 e = e.cause;
461 }
462 console.error("oauthCallback error", message);
463 set({
464 authStatus: "loggedOut",
465 notification: {
466 message,
467 type: "error",
468 },
469 });
470 throw e;
471 }
472 } catch (error) {
473 console.error("oauthCallback rejected", error);
474 const message = error?.message || "authentication failed";
475 set({
476 authStatus: "loggedOut",
477 notification: {
478 message,
479 type: "error",
480 },
481 });
482 }
483 },
484
485 golivePost: async (text: string, now: Date, thumbnail?: BlobRef) => {
486 const state = get() as BlueskySlice;
487 if (!state.pdsAgent) {
488 throw new Error("No agent");
489 }
490 const did = state.oauthSession?.did;
491 if (!did) {
492 throw new Error("No DID");
493 }
494 const profile = state.profiles[did];
495 if (!profile) {
496 throw new Error("No profile");
497 }
498 const streamplaceUrl = get().url;
499 const u = new URL(streamplaceUrl);
500 const params = new URLSearchParams({
501 did: did,
502 time: new Date().toISOString(),
503 });
504
505 const linkUrl = `${u.protocol}//${u.host}/${profile.handle}?${params.toString()}`;
506 const prefix = `🔴 LIVE `;
507 const textUrl = `${u.protocol}//${u.host}/${profile.handle}`;
508 const suffix = ` ${text}`;
509 const content = prefix + textUrl + suffix;
510
511 const rt = new RichText({ text: content });
512 rt.detectFacetsWithoutResolution();
513
514 const record: AppBskyFeedPost.Record = {
515 $type: "app.bsky.feed.post",
516 text: content,
517 "place.stream.livestream": {
518 url: linkUrl,
519 title: text,
520 },
521 facets: rt.facets,
522 createdAt: now.toISOString(),
523 langs: ["en"],
524 };
525 record.embed = {
526 $type: "app.bsky.embed.external",
527 external: {
528 description: text,
529 thumb: thumbnail,
530 title: `@${profile.handle} is 🔴LIVE on ${u.host}!`,
531 uri: linkUrl,
532 },
533 };
534 return await state.pdsAgent.post(record);
535 },
536
537 createBlockRecord: async (subjectDID: string) => {
538 try {
539 const state = get() as BlueskySlice;
540 if (!state.pdsAgent) {
541 throw new Error("No agent");
542 }
543 const did = state.oauthSession?.did;
544 if (!did) {
545 throw new Error("No DID");
546 }
547 const profile = state.profiles[did];
548 if (!profile) {
549 throw new Error("No profile");
550 }
551 const record: AppBskyGraphBlock.Record = {
552 $type: "app.bsky.graph.block",
553 subject: subjectDID,
554 createdAt: new Date().toISOString(),
555 };
556 await state.pdsAgent.com.atproto.repo.createRecord({
557 repo: did,
558 collection: "app.bsky.graph.block",
559 record,
560 });
561 console.log("createBlockRecord fulfilled");
562 } catch (error) {
563 console.error("createBlockRecord rejected", error);
564 }
565 },
566
567 createStreamKeyRecord: async (store: boolean) => {
568 try {
569 const state = get() as BlueskySlice;
570 if (!state.pdsAgent) {
571 throw new Error("No agent");
572 }
573 const did = state.oauthSession?.did;
574 if (!did) {
575 throw new Error("No DID");
576 }
577 const profile = state.profiles[did];
578 if (!profile) {
579 throw new Error("No profile");
580 }
581 const keypair = await Secp256k1Keypair.create({ exportable: true });
582 const exportedKey = await keypair.export();
583 const didBytes = new TextEncoder().encode(did);
584 const combinedKey = new Uint8Array([...exportedKey, ...didBytes]);
585 const multibaseKey = bytesToMultibase(combinedKey, "base58btc");
586 const hexKey = Array.from(exportedKey)
587 .map((b) => b.toString(16).padStart(2, "0"))
588 .join("");
589 const account = await privateKeyToAccount(`0x${hexKey}`);
590 const newKey = {
591 privateKey: multibaseKey,
592 did: keypair.did(),
593 address: account.address.toLowerCase(),
594 };
595
596 let platform: string = Platform.OS;
597
598 if (Platform.OS === "web" && window && window.navigator) {
599 let splitUA = window.navigator.userAgent
600 .split(" ")
601 .pop()
602 ?.split("/")[0];
603 if (splitUA) {
604 platform = splitUA;
605 }
606 } else if (platform === "android") {
607 platform = "Android";
608 } else if (platform === "ios") {
609 platform = "iOS";
610 } else if (platform === "macos") {
611 platform = "macOS";
612 } else if (platform === "windows") {
613 platform = "Windows";
614 }
615
616 const record: PlaceStreamKey.Record = {
617 $type: "place.stream.key",
618 signingKey: keypair.did(),
619 createdAt: new Date().toISOString(),
620 createdBy: "Streamplace on " + platform,
621 };
622 await state.pdsAgent.com.atproto.repo.createRecord({
623 repo: did,
624 collection: "place.stream.key",
625 record,
626 });
627 if (store) {
628 await storage.setItem(STORED_KEY_KEY, JSON.stringify(newKey));
629 }
630 set({
631 newKey: newKey,
632 storedKey: store ? newKey : null,
633 });
634 } catch (error) {
635 console.error("createStreamKeyRecord rejected", error);
636 }
637 },
638
639 clearStreamKeyRecord: () => {
640 set({ newKey: null });
641 },
642
643 getStreamKeyRecords: async () => {
644 set({
645 streamKeysResponse: {
646 loading: true,
647 error: null,
648 records: null,
649 },
650 });
651 try {
652 const state = get() as BlueskySlice;
653 if (!state.pdsAgent) {
654 throw new Error("No agent");
655 }
656 const did = state.oauthSession?.did;
657 if (!did) {
658 throw new Error("No DID");
659 }
660 const profile = state.profiles[did];
661 if (!profile) {
662 throw new Error("No profile");
663 }
664 const result = await state.pdsAgent.com.atproto.repo.listRecords({
665 repo: did,
666 collection: "place.stream.key",
667 limit: 100,
668 });
669 console.log(result);
670 set({
671 streamKeysResponse: {
672 loading: false,
673 error: null,
674 records: result.data,
675 },
676 });
677 } catch (error) {
678 console.error("listStreamKeyRecords rejected", error);
679 set({
680 streamKeysResponse: {
681 loading: false,
682 error: error?.message ?? null,
683 records: null,
684 },
685 });
686 }
687 },
688
689 deleteStreamKeyRecord: async (rkey: string) => {
690 set({ isDeletingKey: true });
691 try {
692 const state = get() as BlueskySlice;
693 if (!state.pdsAgent) {
694 throw new Error("No agent");
695 }
696 const did = state.oauthSession?.did;
697 if (!did) {
698 throw new Error("No DID");
699 }
700 const profile = state.profiles[did];
701 if (!profile) {
702 throw new Error("No profile");
703 }
704 await state.pdsAgent.com.atproto.repo.deleteRecord({
705 repo: did,
706 collection: "place.stream.key",
707 rkey,
708 });
709 let records = state.streamKeysResponse.records
710 ? state.streamKeysResponse.records.records.filter(
711 (r) => r.uri.split("/").pop() !== rkey,
712 )
713 : [];
714 set({
715 isDeletingKey: false,
716 streamKeysResponse: {
717 ...state.streamKeysResponse,
718 records: {
719 ...state.streamKeysResponse.records!,
720 records,
721 },
722 },
723 });
724 } catch (error) {
725 console.error("deleteStreamKeyRecord rejected", error);
726 set({ isDeletingKey: false });
727 }
728 },
729
730 setPDS: async (pds: string) => {
731 set({
732 pds: {
733 ...(get() as BlueskySlice).pds,
734 loading: true,
735 },
736 });
737 try {
738 await storage.setItem("pdsURL", pds);
739 console.log("setPDS fulfilled", pds);
740 set({
741 pds: {
742 ...(get() as BlueskySlice).pds,
743 loading: false,
744 url: pds,
745 },
746 });
747 } catch (error) {
748 set({
749 pds: {
750 ...(get() as BlueskySlice).pds,
751 loading: false,
752 error: error?.message ?? null,
753 },
754 });
755 }
756 },
757
758 createLivestreamRecord: async (title: string, customThumbnail?: Blob) => {
759 set({
760 newLivestream: {
761 loading: true,
762 error: null,
763 record: null,
764 },
765 });
766 try {
767 const now = new Date();
768 const state = get() as BlueskySlice;
769 if (!state.pdsAgent) {
770 throw new Error("No agent");
771 }
772 const did = state.oauthSession?.did;
773 if (!did) {
774 throw new Error("No DID");
775 }
776 const profile = state.profiles[did];
777 if (!profile) {
778 throw new Error("No profile");
779 }
780
781 let thumbnail: BlobRef | undefined = undefined;
782 const streamplaceUrl = get().url;
783 const u = new URL(streamplaceUrl);
784
785 if (customThumbnail) {
786 try {
787 thumbnail = await uploadThumbnail(
788 profile.handle,
789 u,
790 state.pdsAgent,
791 profile,
792 customThumbnail,
793 );
794 } catch (e) {
795 throw new Error(`Custom thumbnail upload failed ${e}`);
796 }
797 } else {
798 let tries = 0;
799 try {
800 for (; tries < 3; tries++) {
801 try {
802 console.log(
803 `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`,
804 );
805 const thumbnailRes = await fetch(
806 `${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`,
807 );
808 if (!thumbnailRes.ok) {
809 throw new Error(
810 `Failed to fetch thumbnail: ${thumbnailRes.status})`,
811 );
812 }
813 const thumbnailBlob = await thumbnailRes.blob();
814 console.log(thumbnailBlob);
815 thumbnail = await uploadThumbnail(
816 profile.handle,
817 u,
818 state.pdsAgent,
819 profile,
820 thumbnailBlob,
821 );
822 } catch (e) {
823 console.warn(
824 `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`,
825 );
826 await new Promise((resolve) => setTimeout(resolve, 2000));
827 if (tries === 2) {
828 throw new Error(
829 `Failed to fetch thumbnail after 3 tries: ${e}`,
830 );
831 }
832 }
833 }
834 } catch (e) {
835 throw new Error(`Thumbnail upload failed ${e}`);
836 }
837 }
838
839 const newPost = await state.golivePost(title, now, thumbnail);
840
841 if (!newPost?.uri || !newPost?.cid) {
842 throw new Error(
843 "Cannot read properties of undefined (reading 'uri' or 'cid')",
844 );
845 }
846
847 const record: PlaceStreamLivestream.Record = {
848 $type: "place.stream.livestream",
849 title: title,
850 url: streamplaceUrl,
851 createdAt: new Date().toISOString(),
852 post: {
853 uri: newPost.uri,
854 cid: newPost.cid,
855 },
856 thumb: thumbnail,
857 };
858
859 await state.pdsAgent.com.atproto.repo.createRecord({
860 repo: did,
861 collection: "place.stream.livestream",
862 record,
863 });
864 set({
865 newLivestream: {
866 loading: false,
867 error: null,
868 record: record,
869 },
870 });
871 } catch (error) {
872 console.error("createLivestreamRecord rejected", error);
873 set({
874 newLivestream: {
875 loading: false,
876 error: error?.message ?? null,
877 record: null,
878 },
879 });
880 }
881 },
882
883 updateLivestreamRecord: async (title: string, livestream: any) => {
884 set({
885 newLivestream: {
886 loading: true,
887 error: null,
888 record: null,
889 },
890 });
891 try {
892 const now = new Date();
893 const state = get() as BlueskySlice;
894
895 if (!state.pdsAgent) {
896 throw new Error("No agent");
897 }
898 const did = state.oauthSession?.did;
899 if (!did) {
900 throw new Error("No DID");
901 }
902 const profile = state.profiles[did];
903 if (!profile) {
904 throw new Error("No profile");
905 }
906
907 let oldRecord = livestream;
908 if (!oldRecord) {
909 throw new Error("No latest record");
910 }
911
912 let rkey = oldRecord.uri.split("/").pop();
913 let oldRecordValue: PlaceStreamLivestream.Record = oldRecord.record;
914
915 if (!rkey) {
916 throw new Error("No rkey?");
917 }
918
919 console.log("Updating rkey", rkey);
920
921 const streamplaceUrl = get().url;
922 const record: PlaceStreamLivestream.Record = {
923 $type: "place.stream.livestream",
924 title: title,
925 url: streamplaceUrl,
926 createdAt: new Date().toISOString(),
927 post: oldRecordValue.post,
928 };
929
930 await state.pdsAgent.com.atproto.repo.putRecord({
931 repo: did,
932 collection: "place.stream.livestream",
933 rkey,
934 record,
935 });
936 set({
937 newLivestream: {
938 loading: false,
939 error: null,
940 record: record,
941 },
942 });
943 } catch (error) {
944 console.error("createLivestreamRecord rejected", error);
945 set({
946 newLivestream: {
947 loading: false,
948 error: error?.message ?? null,
949 record: null,
950 },
951 });
952 }
953 },
954
955 getChatProfileRecordFromPDS: async () => {
956 set({
957 chatProfile: {
958 loading: true,
959 error: null,
960 profile: null,
961 },
962 });
963 try {
964 const state = get() as BlueskySlice;
965 const did = state.oauthSession?.did;
966 if (!did) {
967 throw new Error("No DID");
968 }
969 const profile = state.profiles[did];
970 if (!profile) {
971 throw new Error("No profile");
972 }
973 if (!state.pdsAgent) {
974 throw new Error("No agent");
975 }
976 const res = await state.pdsAgent.com.atproto.repo.getRecord({
977 repo: did,
978 collection: "place.stream.chat.profile",
979 rkey: "self",
980 });
981 if (!res.success) {
982 throw new Error("Failed to get chat profile record");
983 }
984
985 if (PlaceStreamChatProfile.isRecord(res.data.value)) {
986 set({
987 chatProfile: {
988 loading: false,
989 error: null,
990 profile: res.data.value,
991 },
992 });
993 } else {
994 console.log("not a record", res.data.value);
995 }
996 } catch (error) {
997 console.error("getChatProfileRecordFromPDS error", error);
998 }
999 },
1000
1001 createChatProfileRecord: async (red: number, green: number, blue: number) => {
1002 set({
1003 chatProfile: {
1004 loading: true,
1005 error: null,
1006 profile: null,
1007 },
1008 });
1009 try {
1010 const state = get() as BlueskySlice;
1011 if (!state.pdsAgent) {
1012 throw new Error("No agent");
1013 }
1014 const did = state.oauthSession?.did;
1015 if (!did) {
1016 throw new Error("No DID");
1017 }
1018 const profile = state.profiles[did];
1019 if (!profile) {
1020 throw new Error("No profile");
1021 }
1022
1023 const chatProfile: PlaceStreamChatProfile.Record = {
1024 $type: "place.stream.chat.profile",
1025 color: {
1026 red: red,
1027 green: green,
1028 blue: blue,
1029 },
1030 };
1031
1032 const res = await state.pdsAgent.com.atproto.repo.putRecord({
1033 repo: did,
1034 collection: "place.stream.chat.profile",
1035 record: chatProfile,
1036 rkey: "self",
1037 });
1038 if (!res.success) {
1039 throw new Error("Failed to create chat profile record");
1040 }
1041 set({
1042 chatProfile: {
1043 loading: false,
1044 error: null,
1045 profile: chatProfile,
1046 },
1047 });
1048 } catch (error) {
1049 console.error("createChatProfileRecord rejected", error);
1050 set({
1051 chatProfile: {
1052 loading: false,
1053 error: error?.message ?? null,
1054 profile: null,
1055 },
1056 });
1057 }
1058 },
1059
1060 followUser: async (subjectDID: string) => {
1061 try {
1062 console.log("followUser pending");
1063 const state = get() as BlueskySlice;
1064 if (!state.pdsAgent) {
1065 throw new Error("No agent");
1066 }
1067 const did = state.oauthSession?.did;
1068 if (!did) {
1069 throw new Error("No DID");
1070 }
1071 await state.pdsAgent.follow(subjectDID);
1072 console.log("followUser fulfilled", { subjectDID });
1073 } catch (error) {
1074 console.error("followUser rejected", error);
1075 }
1076 },
1077
1078 unfollowUser: async (subjectDID: string, followUri?: string) => {
1079 try {
1080 console.log("unfollowUser pending");
1081 const state = get() as BlueskySlice;
1082 if (!state.pdsAgent) {
1083 throw new Error("No agent");
1084 }
1085 const did = state.oauthSession?.did;
1086 if (!did) {
1087 throw new Error("No DID");
1088 }
1089
1090 if (followUri) {
1091 await state.pdsAgent.deleteFollow(followUri);
1092 } else {
1093 const streamplaceUrl = get().url;
1094 const res = await fetch(
1095 `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(subjectDID)}&userDID=${encodeURIComponent(did)}`,
1096 {
1097 credentials: "include",
1098 },
1099 );
1100 const data = await res.json();
1101
1102 if (!data.follow || !data.follow.uri) {
1103 throw new Error("Follow record not found");
1104 }
1105
1106 await state.pdsAgent.deleteFollow(data.follow.uri);
1107 }
1108
1109 console.log("unfollowUser fulfilled", { subjectDID });
1110 } catch (error) {
1111 console.error("unfollowUser rejected", error);
1112 }
1113 },
1114
1115 getServerSettingsFromPDS: async () => {
1116 try {
1117 const state = get() as BlueskySlice;
1118 const did = state.oauthSession?.did;
1119 if (!did) {
1120 throw new Error("No DID");
1121 }
1122 const profile = state.profiles[did];
1123 if (!profile) {
1124 throw new Error("No profile");
1125 }
1126 if (!state.pdsAgent) {
1127 throw new Error("No agent");
1128 }
1129 const streamplaceUrl = get().url;
1130 const u = new URL(streamplaceUrl);
1131 const res = await state.pdsAgent.com.atproto.repo.getRecord({
1132 repo: did,
1133 collection: "place.stream.server.settings",
1134 rkey: u.host,
1135 });
1136 if (!res.success) {
1137 throw new Error("Failed to get chat profile record");
1138 }
1139
1140 if (PlaceStreamServerSettings.isRecord(res.data.value)) {
1141 set({
1142 serverSettings: res.data.value as PlaceStreamServerSettings.Record,
1143 });
1144 } else {
1145 console.log("not a record", res.data.value);
1146 }
1147 } catch (error) {
1148 console.error("getServerSettingsFromPDS rejected", error);
1149 }
1150 },
1151
1152 createServerSettingsRecord: async (debugRecording: boolean) => {
1153 try {
1154 const state = get() as BlueskySlice;
1155 if (!state.pdsAgent) {
1156 throw new Error("No agent");
1157 }
1158 const did = state.oauthSession?.did;
1159 if (!did) {
1160 throw new Error("No DID");
1161 }
1162 const profile = state.profiles[did];
1163 if (!profile) {
1164 throw new Error("No profile");
1165 }
1166 const streamplaceUrl = get().url;
1167 const u = new URL(streamplaceUrl);
1168 const serverSettings: PlaceStreamServerSettings.Record = {
1169 $type: "place.stream.server.settings",
1170 debugRecording: debugRecording,
1171 };
1172
1173 const res = await state.pdsAgent.com.atproto.repo.putRecord({
1174 repo: did,
1175 collection: "place.stream.server.settings",
1176 record: serverSettings,
1177 rkey: u.host,
1178 });
1179 if (!res.success) {
1180 throw new Error("Failed to create server settings record");
1181 }
1182 set({
1183 serverSettings: serverSettings,
1184 });
1185 } catch (error) {
1186 console.error("createServerSettingsRecord rejected", error);
1187 }
1188 },
1189});