Live video on the AT Protocol
79
fork

Configure Feed

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

at natb/command-errors 1189 lines 33 kB view raw
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});