Live video on the AT Protocol

Migrate Redux store to Zustand

Replace Redux Toolkit slices and react-redux hooks with a Zustand-based
store. Add new store/index and slices, and store/hooks selector
wrappers. Remove legacy Redux slices, listener and store wiring, update
components to use useStore and new selector hooks, and adjust
auth/Bluesky/Streamplace APIs accordingly. Update package deps and
lockfile to add zustand and remove Redux-related packages.

authored by

Natalie Bridgers and committed by
Natalie B.
8edf021e 08b7b53b

+2095 -3119
+9 -15
js/app/components/create-livestream.tsx
··· 1 1 import { zero } from "@streamplace/components"; 2 2 import ThumbnailSelector from "components/thumbnail-selector"; 3 - import { 4 - createLivestreamRecord, 5 - selectNewLivestream, 6 - selectUserProfile, 7 - } from "features/bluesky/blueskySlice"; 8 3 import { useCaptureVideoFrame } from "hooks/useCaptureVideoFrame"; 9 4 import { useLiveUser } from "hooks/useLiveUser"; 10 5 import { useEffect, useState } from "react"; ··· 17 12 useWindowDimensions, 18 13 View, 19 14 } from "react-native"; 20 - import { useAppDispatch, useAppSelector } from "store/hooks"; 15 + import { useStore } from "store"; 16 + import { useNewLivestream, useUserProfile } from "store/hooks"; 21 17 22 18 const isWeb = Platform.OS === "web"; 23 19 24 20 export default function CreateLivestream() { 25 - const dispatch = useAppDispatch(); 21 + const createLivestreamRecord = useStore( 22 + (state) => state.createLivestreamRecord, 23 + ); 24 + const streamplaceUrl = useStore((state) => state.url); 26 25 // Note: Toast functionality removed, would need simple alert replacement 27 26 const userIsLive = useLiveUser(); 28 27 const [title, setTitle] = useState(""); ··· 30 29 const [customThumbnail, setCustomThumbnail] = useState<Blob | undefined>( 31 30 undefined, 32 31 ); 33 - const profile = useAppSelector(selectUserProfile); 34 - const newLivestream = useAppSelector(selectNewLivestream); 32 + const profile = useUserProfile(); 33 + const newLivestream = useNewLivestream(); 35 34 const captureFrame = useCaptureVideoFrame(); 36 35 const { width } = useWindowDimensions(); 37 36 ··· 66 65 } 67 66 } 68 67 69 - await dispatch( 70 - createLivestreamRecord({ 71 - title, 72 - customThumbnail: thumbnailToUse, 73 - }), 74 - ); 68 + await createLivestreamRecord(title, thumbnailToUse, streamplaceUrl); 75 69 } catch (error) { 76 70 console.error("Error creating livestream:", error); 77 71 // Would show toast: "Error creating livestream"
+9 -15
js/app/components/edit-livestream.tsx
··· 1 1 import { Text, useLivestream, zero } from "@streamplace/components"; 2 - import { 3 - selectNewLivestream, 4 - selectUserProfile, 5 - updateLivestreamRecord, 6 - } from "features/bluesky/blueskySlice"; 7 2 import { useLiveUser } from "hooks/useLiveUser"; 8 3 import { useEffect, useState } from "react"; 9 4 import { Pressable, ScrollView, TextInput, View } from "react-native"; 10 - import { useAppDispatch, useAppSelector } from "store/hooks"; 5 + import { useStore } from "store"; 6 + import { useNewLivestream, useUserProfile } from "store/hooks"; 11 7 12 8 export default function UpdateLivestream() { 13 - const dispatch = useAppDispatch(); 9 + const updateLivestreamRecord = useStore( 10 + (state) => state.updateLivestreamRecord, 11 + ); 12 + const streamplaceUrl = useStore((state) => state.url); 14 13 // Note: Toast functionality removed, would need simple alert replacement 15 14 const userIsLive = useLiveUser(); 16 15 const [title, setTitle] = useState(""); 17 16 const [loading, setLoading] = useState(false); 18 - const profile = useAppSelector(selectUserProfile); 17 + const profile = useUserProfile(); 19 18 const livestream = useLivestream(); 20 - const newLivestream = useAppSelector(selectNewLivestream); 19 + const newLivestream = useNewLivestream(); 21 20 22 21 useEffect(() => { 23 22 if (newLivestream?.record) { ··· 37 36 const handleSubmit = async () => { 38 37 setLoading(true); 39 38 try { 40 - await dispatch( 41 - updateLivestreamRecord({ 42 - title, 43 - livestream, 44 - }), 45 - ); 39 + await updateLivestreamRecord(title, livestream, streamplaceUrl); 46 40 } catch (error) { 47 41 console.error("Error updating livestream:", error); 48 42 // Would show toast: "Error updating livestream"
+7 -12
js/app/components/follow-button.tsx
··· 2 2 import { Plus } from "lucide-react-native"; 3 3 import React, { useEffect, useState } from "react"; 4 4 import { View } from "react-native"; 5 - import { followUser, unfollowUser } from "../features/bluesky/blueskySlice"; 6 - import { selectStreamplace } from "../features/streamplace/streamplaceSlice"; 7 - import { useAppDispatch, useAppSelector } from "../store/hooks"; 5 + import { useStore } from "store"; 6 + import { useStreamplaceUrl } from "store/hooks"; 8 7 9 8 /** 10 9 * FollowButton component for following/unfollowing a streamer. ··· 28 27 const [isFollowing, setIsFollowing] = useState<boolean | null>(null); 29 28 const [error, setError] = useState<string | null>(null); 30 29 const [followUri, setFollowUri] = useState<string | null>(null); 31 - const { url: streamplaceUrl } = useAppSelector(selectStreamplace); 32 - const dispatch = useAppDispatch(); 30 + const streamplaceUrl = useStreamplaceUrl(); 31 + const followUser = useStore((state) => state.followUser); 32 + const unfollowUser = useStore((state) => state.unfollowUser); 33 33 34 34 // Hide button if not logged in or viewing own stream 35 35 if (!currentUserDID || currentUserDID === streamerDID) return null; ··· 80 80 setError(null); 81 81 setIsFollowing(true); // Optimistic 82 82 try { 83 - await dispatch(followUser(streamerDID)).unwrap(); 83 + await followUser(streamerDID); 84 84 setIsFollowing(true); 85 85 onFollowChange?.(true); 86 86 } catch (err) { ··· 95 95 setError(null); 96 96 setIsFollowing(false); // Optimistic 97 97 try { 98 - await dispatch( 99 - unfollowUser({ 100 - subjectDID: streamerDID, 101 - ...(followUri ? { followUri } : {}), 102 - }), 103 - ).unwrap(); 98 + await unfollowUser(streamerDID, followUri ?? undefined, streamplaceUrl); 104 99 setIsFollowing(false); 105 100 setFollowUri(null); 106 101 onFollowChange?.(false);
+3 -7
js/app/components/live-dashboard/live-selector.tsx
··· 3 3 import { flex } from "@streamplace/components/src/ui"; 4 4 import { Redirect } from "components/aqlink"; 5 5 import Loading from "components/loading/loading"; 6 - import { 7 - selectIsReady, 8 - selectUserProfile, 9 - } from "features/bluesky/blueskySlice"; 10 6 import { Camera, FerrisWheel } from "lucide-react-native"; 11 7 import React, { useState } from "react"; 12 - import { useAppSelector } from "store/hooks"; 8 + import { useIsReady, useUserProfile } from "store/hooks"; 13 9 import { StreamKeyScreen } from "./stream-key"; 14 10 15 11 const { layout, gap } = zero; ··· 29 25 30 26 export default function StreamScreen({ route }) { 31 27 const [selectedMode, setSelectedMode] = useState<string | null>(null); 32 - const isReady = useAppSelector(selectIsReady); 33 - const userProfile = useAppSelector(selectUserProfile); 28 + const isReady = useIsReady(); 29 + const userProfile = useUserProfile(); 34 30 const navigation = useNavigation(); 35 31 36 32 if (!isReady) {
+2 -3
js/app/components/live-dashboard/livestream-panel.tsx
··· 22 22 TouchableOpacity, 23 23 View, 24 24 } from "react-native"; 25 - import { selectUserProfile } from "../../features/bluesky/blueskySlice"; 25 + import { useUserProfile } from "store/hooks"; 26 26 import { useCaptureVideoFrame } from "../../hooks/useCaptureVideoFrame"; 27 27 import { useLiveUser } from "../../hooks/useLiveUser"; 28 - import { useAppSelector } from "../../store/hooks"; 29 28 30 29 const { flex, p, px, py, gap, layout, bg, borders, text, r, w, typography } = 31 30 zero; ··· 165 164 const toast = useToast(); 166 165 const userIsLive = useLiveUser(); 167 166 const captureFrame = useCaptureVideoFrame(); 168 - const profile = useAppSelector(selectUserProfile); 167 + const profile = useUserProfile(); 169 168 const livestream = useLivestream(); 170 169 const createStreamRecord = useCreateStreamRecord(); 171 170 const updateStreamRecord = useUpdateStreamRecord();
+13 -15
js/app/components/live-dashboard/stream-key.tsx
··· 9 9 } from "@streamplace/components"; 10 10 import { Redirect } from "components/aqlink"; 11 11 import Loading from "components/loading/loading"; 12 - import { 13 - clearStreamKeyRecord, 14 - createStreamKeyRecord, 15 - selectIsReady, 16 - selectUserProfile, 17 - } from "features/bluesky/blueskySlice"; 18 12 import { Clipboard, ClipboardCheck } from "lucide-react-native"; 19 13 import { useEffect, useState } from "react"; 20 14 import { ScrollView, TextInput } from "react-native"; 21 - import { useAppDispatch, useAppSelector } from "store/hooks"; 15 + import { useStore } from "store"; 16 + import { useIsReady, useUserProfile } from "store/hooks"; 22 17 23 18 const FormRow = ({ children }: { children: React.ReactNode }) => { 24 19 return ( ··· 46 41 47 42 export function StreamKeyScreen() { 48 43 const [protocol, setProtocol] = useState<"whip" | "rtmp">("rtmp"); 49 - const isReady = useAppSelector(selectIsReady); 44 + const isReady = useIsReady(); 50 45 51 46 if (!isReady) { 52 47 return <Loading />; 53 48 } 54 49 55 - const userProfile = useAppSelector(selectUserProfile); 50 + const userProfile = useUserProfile(); 56 51 if (!userProfile) { 57 52 return <Redirect to={{ screen: "Login" }} />; 58 53 } 59 54 60 - const url = useAppSelector((state) => state.streamplace.url); 55 + const url = useStore((state) => state.url); 61 56 62 57 return ( 63 58 <ScrollView> ··· 190 185 const theme = useTheme(); 191 186 const toast = useToast(); 192 187 193 - const dispatch = useAppDispatch(); 188 + const createStreamKeyRecord = useStore( 189 + (state) => state.createStreamKeyRecord, 190 + ); 191 + const clearStreamKeyRecord = useStore((state) => state.clearStreamKeyRecord); 194 192 const [generating, setGenerating] = useState(false); 195 193 const [hidekey, setHidekey] = useState(true); 196 194 const [didcopy, setDidcopy] = useState(false); 197 - const newKey = useAppSelector((state) => state.bluesky.newKey); 195 + const newKey = useStore((state) => state.newKey); 198 196 199 197 let foregroundColor = theme.theme.colors.text || "#fff"; 200 198 ··· 204 202 } 205 203 206 204 return () => { 207 - dispatch(clearStreamKeyRecord()); 205 + clearStreamKeyRecord(); 208 206 }; 209 - }, [newKey, dispatch]); 207 + }, [newKey]); 210 208 211 209 const handleCopy = async () => { 212 210 if (!newKey) { ··· 287 285 try { 288 286 setGenerating(true); 289 287 setDidcopy(false); 290 - await dispatch(createStreamKeyRecord({ store: false })); 288 + await createStreamKeyRecord(false); 291 289 } catch (e) { 292 290 console.error("failed to generate stream key", e); 293 291 } finally {
+18 -17
js/app/components/login/login.tsx
··· 2 2 import { Button, Text, useTheme, zero } from "@streamplace/components"; 3 3 import Loading from "components/loading/loading"; 4 4 import NameColorPicker from "components/name-color-picker/name-color-picker"; 5 - import { 6 - login, 7 - logout, 8 - selectChatProfile, 9 - selectIsReady, 10 - selectLogin, 11 - selectUserProfile, 12 - } from "features/bluesky/blueskySlice"; 13 5 import { Info, LogOut, UserRoundPen } from "lucide-react-native"; 14 6 import { useEffect, useState } from "react"; 15 7 import { ··· 23 15 TextInput, 24 16 View, 25 17 } from "react-native"; 26 - import { useAppDispatch, useAppSelector } from "store/hooks"; 18 + import { useStore } from "store"; 19 + import { 20 + useChatProfile, 21 + useIsReady, 22 + useLogin, 23 + useUserProfile, 24 + } from "store/hooks"; 27 25 28 26 export default function Login() { 29 27 const { theme } = useTheme(); 30 - const dispatch = useAppDispatch(); 31 - const chatProfile = useAppSelector(selectChatProfile); 32 - const userProfile = useAppSelector(selectUserProfile); 33 - const loginState = useAppSelector(selectLogin); 28 + const loginAction = useStore((state) => state.login); 29 + const logout = useStore((state) => state.logout); 30 + const openLoginLink = useStore((state) => state.openLoginLink); 31 + const streamplaceUrl = useStore((state) => state.url); 32 + const chatProfile = useChatProfile(); 33 + const userProfile = useUserProfile(); 34 + const loginState = useLogin(); 34 35 const [handle, setHandle] = useState(""); 35 - const isReady = useAppSelector(selectIsReady); 36 + const isReady = useIsReady(); 36 37 const navigation = useNavigation(); 37 38 38 39 const submit = () => { 39 40 let clean = handle; 40 41 if (handle.startsWith("@")) clean = handle.slice(1); 41 - dispatch(login(clean)); 42 + loginAction(clean, streamplaceUrl, openLoginLink); 42 43 }; 43 44 const onSignup = () => { 44 - dispatch(login("https://bsky.social")); 45 + loginAction("https://bsky.social", streamplaceUrl, openLoginLink); 45 46 }; 46 47 const onEnterPress = (e: any) => { 47 48 if (e.nativeEvent.key === "Enter") { ··· 98 99 ]} 99 100 > 100 101 <Button 101 - onPress={() => dispatch(logout())} 102 + onPress={() => logout()} 102 103 variant="secondary" 103 104 leftIcon={<LogOut color={theme.colors.text} />} 104 105 style={[
+10 -14
js/app/components/mobile/player.tsx
··· 15 15 View, 16 16 } from "@streamplace/components"; 17 17 import { gap, h, pt, w } from "@streamplace/components/src/lib/theme/atoms"; 18 - import { selectUserProfile } from "features/bluesky/blueskySlice"; 19 18 import { useLiveUser } from "hooks/useLiveUser"; 20 19 import { useSidebarControl } from "hooks/useSidebarControl"; 21 20 import { ArrowLeft, ArrowRight } from "lucide-react-native"; 22 21 import { ComponentRef, useEffect, useRef, useState } from "react"; 23 22 import { Animated, Platform, ScrollView, StatusBar } from "react-native"; 24 - import { useAppSelector } from "store/hooks"; 23 + import { useStore } from "store"; 24 + import { useUserProfile } from "store/hooks"; 25 25 import { BottomMetadata } from "./bottom-metadata"; 26 26 import { DesktopChatPanel } from "./chat"; 27 27 import { DesktopUi } from "./desktop-ui"; ··· 29 29 import { MobileUi } from "./ui"; 30 30 import { useResponsiveLayout } from "./useResponsiveLayout"; 31 31 32 - import { 33 - setSidebarHidden, 34 - setSidebarUnhidden, 35 - } from "features/base/sidebarSlice"; 36 - import { useDispatch } from "react-redux"; 37 - 38 32 export function Player( 39 33 props: Partial<PlayerProps> & { 40 34 setFullscreen?: (fullscreen: boolean) => void; ··· 50 44 >(null); 51 45 // are we currently streaming on another device? 52 46 const userIsLive = useLiveUser(); 53 - const userProfile = useAppSelector(selectUserProfile); 47 + const userProfile = useUserProfile(); 54 48 55 49 useEffect(() => { 56 50 if (props.ingest && userIsLive && isStreamingElsewhere === null) { ··· 61 55 }, [userIsLive]); 62 56 63 57 const navigation = useNavigation(); 58 + const setSidebarHidden = useStore((state) => state.setSidebarHidden); 59 + const setSidebarUnhidden = useStore((state) => state.setSidebarUnhidden); 64 60 65 61 useEffect(() => { 66 62 return () => { ··· 185 181 showChatSidePanelOnLandscape: props.showChat, 186 182 }); 187 183 188 - // for hiding sidebar 189 - const dispatch = useDispatch(); 184 + const setSidebarHidden = useStore((state) => state.setSidebarHidden); 185 + const setSidebarUnhidden = useStore((state) => state.setSidebarUnhidden); 190 186 191 187 // content info 192 188 const { width, height } = usePlayerDimensions(); ··· 200 196 useEffect(() => { 201 197 if (Platform.OS !== "web" && width > height) { 202 198 console.log("hiding sb"); 203 - dispatch(setSidebarHidden()); 199 + setSidebarHidden(); 204 200 } else { 205 - dispatch(setSidebarUnhidden()); 201 + setSidebarUnhidden(); 206 202 } 207 203 return () => { 208 - dispatch(setSidebarUnhidden()); 204 + setSidebarUnhidden(); 209 205 }; 210 206 }, [width, height]); 211 207 // should cover full width on mobile?
+13 -12
js/app/components/name-color-picker/name-color-picker.tsx
··· 1 1 import { Button, zero } from "@streamplace/components"; 2 - import { 3 - createChatProfileRecord, 4 - getChatProfileRecordFromPDS, 5 - selectChatProfile, 6 - selectUserProfile, 7 - } from "features/bluesky/blueskySlice"; 8 2 import { Palette, SwatchBook, X } from "lucide-react-native"; 9 3 import { useEffect, useState } from "react"; 10 4 import { ··· 22 16 Preview, 23 17 Swatches, 24 18 } from "reanimated-color-picker"; 25 - import { useAppDispatch, useAppSelector } from "store/hooks"; 19 + import { useStore } from "store"; 20 + import { useChatProfile, useUserProfile } from "store/hooks"; 26 21 import { PlaceStreamChatProfile } from "streamplace"; 27 22 28 23 /** ··· 61 56 }) { 62 57 const [modalVisible, setModalVisible] = useState(false); 63 58 const [tempColor, setTempColor] = useState("#bd6e86"); 64 - const dispatch = useAppDispatch(); 65 - const chatProfile = useAppSelector(selectChatProfile); 66 - const profile = useAppSelector(selectUserProfile); 59 + const createChatProfileRecord = useStore( 60 + (state) => state.createChatProfileRecord, 61 + ); 62 + const getChatProfileRecordFromPDS = useStore( 63 + (state) => state.getChatProfileRecordFromPDS, 64 + ); 65 + const chatProfile = useChatProfile(); 66 + const profile = useUserProfile(); 67 67 const isWeb = Platform.OS === "web"; 68 68 69 69 const currentColor = chatProfile?.profile?.color ··· 72 72 73 73 useEffect(() => { 74 74 if (profile?.did && !chatProfile?.profile) { 75 - dispatch(getChatProfileRecordFromPDS()); 75 + getChatProfileRecordFromPDS(); 76 76 } 77 77 setTempColor(currentColor); 78 78 }, [profile?.did, chatProfile?.profile?.color, currentColor]); ··· 92 92 93 93 const handleSaveColor = () => { 94 94 setModalVisible(false); 95 - dispatch(createChatProfileRecord(parseRgbString(tempColor))); 95 + const parsed = parseRgbString(tempColor); 96 + createChatProfileRecord(parsed.red, parsed.green, parsed.blue); 96 97 }; 97 98 98 99 return (
+9 -14
js/app/components/provider/provider.shared.tsx
··· 11 11 } from "@streamplace/components"; 12 12 import { useFonts } from "expo-font"; 13 13 import BlueskyProvider from "features/bluesky/blueskyProvider"; 14 - import { selectOAuthSession } from "features/bluesky/blueskySlice"; 15 14 import StreamplaceProvider from "features/streamplace/streamplaceProvider"; 16 15 import useStreamplaceNode from "hooks/useStreamplaceNode"; 17 16 import React from "react"; 18 - import { Provider as ReduxProvider } from "react-redux"; 19 - import { useAppSelector } from "store/hooks"; 20 - import { store } from "store/store"; 17 + import { useOAuthSession } from "store/hooks"; 21 18 22 19 export default Sentry.wrap(ProviderInner); 23 20 ··· 91 88 <ThemeProvider forcedTheme="dark"> 92 89 <I18nProvider i18n={i18n}> 93 90 <NavigationContainer theme={SPDarkTheme} linking={linking}> 94 - <ReduxProvider store={store}> 95 91 <StreamplaceProvider> 96 - <BlueskyProvider> 97 - <NewStreamplaceProvider> 98 - <FontProvider>{children}</FontProvider> 99 - </NewStreamplaceProvider> 100 - </BlueskyProvider> 101 - </StreamplaceProvider> 102 - </ReduxProvider> 103 - </NavigationContainer> 92 + <BlueskyProvider> 93 + <NewStreamplaceProvider> 94 + <FontProvider>{children}</FontProvider> 95 + </NewStreamplaceProvider> 96 + </BlueskyProvider> 97 + </StreamplaceProvider> 98 + </NavigationContainer> 104 99 </I18nProvider> 105 100 </ThemeProvider> 106 101 </SafeAreaProvider> ··· 113 108 children: React.ReactNode; 114 109 }) => { 115 110 const { url } = useStreamplaceNode(); 116 - const oauthSession = useAppSelector(selectOAuthSession); 111 + const oauthSession = useOAuthSession(); 117 112 return ( 118 113 <ZustandStreamplaceProvider url={url} oauthSession={oauthSession}> 119 114 {children}
+10 -11
js/app/components/settings/key-manager.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 2 import AQLink from "components/aqlink"; 3 3 import Loading from "components/loading/loading"; 4 - import { 5 - deleteStreamKeyRecord, 6 - getStreamKeyRecords, 7 - selectKeyRecords, 8 - } from "features/bluesky/blueskySlice"; 9 4 import { useEffect, useState } from "react"; 10 5 import { 11 6 ActivityIndicator, ··· 14 9 TouchableOpacity, 15 10 View, 16 11 } from "react-native"; 17 - import { useAppDispatch, useAppSelector } from "store/hooks"; 12 + import { useStore } from "store"; 13 + import { useKeyRecords } from "store/hooks"; 18 14 import { PlaceStreamKey } from "streamplace"; 19 15 import { timeAgo } from "utils/timeAgo"; 20 16 ··· 98 94 } 99 95 100 96 export default function KeyManager() { 101 - const dispatch = useAppDispatch(); 102 - const keyObj = useAppSelector(selectKeyRecords); 97 + const deleteStreamKeyRecord = useStore( 98 + (state) => state.deleteStreamKeyRecord, 99 + ); 100 + const getStreamKeyRecords = useStore((state) => state.getStreamKeyRecords); 101 + const keyObj = useKeyRecords(); 103 102 const keyRecords = keyObj?.records || null; 104 103 const navigation = useNavigation(); 105 104 ··· 107 106 const deleteKeyRecord = (rkey: string) => { 108 107 if (deletingKeys.has(rkey)) return; // Prevent double deletes 109 108 setDeletingKeys((prev) => new Set(prev).add(rkey)); 110 - dispatch(deleteStreamKeyRecord({ rkey })).finally(() => { 109 + deleteStreamKeyRecord(rkey).finally(() => { 111 110 setDeletingKeys((prev) => { 112 111 const newSet = new Set(prev); 113 112 newSet.delete(rkey); ··· 119 118 useEffect(() => { 120 119 // delay 500ms to allow the screen to render 121 120 setTimeout(() => { 122 - dispatch(getStreamKeyRecords()); 121 + getStreamKeyRecords(); 123 122 }, 500); 124 123 }, []); 125 124 ··· 157 156 justifyContent: "center", 158 157 }, 159 158 ]} 160 - onPress={() => dispatch(getStreamKeyRecords())} 159 + onPress={() => getStreamKeyRecords()} 161 160 > 162 161 <Text style={[{ fontSize: 16, color: "#fff" }]}>↻</Text> 163 162 </TouchableOpacity>
+19 -30
js/app/components/settings/settings.tsx
··· 8 8 zero, 9 9 } from "@streamplace/components"; 10 10 import AQLink from "components/aqlink"; 11 - import { 12 - createServerSettingsRecord, 13 - getServerSettingsFromPDS, 14 - selectIsReady, 15 - selectServerSettings, 16 - } from "features/bluesky/blueskySlice"; 17 - import { DEFAULT_URL, setURL } from "features/streamplace/streamplaceSlice"; 18 11 import useStreamplaceNode from "hooks/useStreamplaceNode"; 19 12 import { useEffect, useState } from "react"; 20 13 import { useTranslation } from "react-i18next"; ··· 24 17 Switch, 25 18 useWindowDimensions, 26 19 } from "react-native"; 27 - import { useAppDispatch, useAppSelector } from "store/hooks"; 20 + import { useStore } from "store"; 21 + import { useIsReady, useServerSettings } from "store/hooks"; 22 + import { DEFAULT_URL } from "store/slices/streamplaceSlice"; 28 23 import { Updates } from "./updates"; 29 24 import WebhookManager from "./webhook-manager"; 30 25 31 26 export function Settings() { 32 - const dispatch = useAppDispatch(); 27 + const setURL = useStore((state) => state.setURL); 33 28 const { url } = useStreamplaceNode(); 34 29 const defaultUrl = DEFAULT_URL; 35 30 const [newUrl, setNewUrl] = useState(""); ··· 37 32 const { t } = useTranslation("settings"); 38 33 39 34 // are we logged in? 40 - const loggedIn = useAppSelector( 41 - (state) => state.bluesky.status === "loggedIn", 42 - ); 35 + const loggedIn = useStore((state) => state.authStatus === "loggedIn"); 43 36 44 37 // Initialize the override state based on current URL 45 38 useEffect(() => { ··· 49 42 const onSubmitUrl = () => { 50 43 if (newUrl) { 51 44 let trimmedUrl = newUrl.endsWith("/") ? newUrl.slice(0, -1) : newUrl; 52 - dispatch(setURL(trimmedUrl)); 45 + setURL(trimmedUrl); 53 46 setNewUrl(""); 54 47 } 55 48 }; ··· 57 50 const handleToggleOverride = (enabled: boolean) => { 58 51 setOverrideEnabled(enabled); 59 52 if (!enabled) { 60 - dispatch(setURL(defaultUrl)); 53 + setURL(defaultUrl); 61 54 } 62 55 }; 63 56 ··· 215 208 } 216 209 217 210 const DebugRecording = () => { 218 - const dispatch = useAppDispatch(); 219 - const isReady = useAppSelector(selectIsReady); 220 - const serverSettings = useAppSelector(selectServerSettings); 211 + const getServerSettingsFromPDS = useStore( 212 + (state) => state.getServerSettingsFromPDS, 213 + ); 214 + const createServerSettingsRecord = useStore( 215 + (state) => state.createServerSettingsRecord, 216 + ); 217 + const streamplaceUrl = useStore((state) => state.url); 218 + const isReady = useIsReady(); 219 + const serverSettings = useServerSettings(); 221 220 const { url } = useStreamplaceNode(); 222 221 const { t } = useTranslation(); 223 222 const debugRecordingOn = serverSettings?.debugRecording === true; 224 223 225 224 useEffect(() => { 226 225 if (isReady) { 227 - dispatch(getServerSettingsFromPDS()); 226 + getServerSettingsFromPDS(streamplaceUrl); 228 227 } 229 228 }, [isReady]); 230 229 ··· 254 253 value={debugRecordingOn} 255 254 onValueChange={(value) => { 256 255 if (value === true) { 257 - dispatch( 258 - createServerSettingsRecord({ 259 - ...serverSettings, 260 - debugRecording: true, 261 - }), 262 - ); 256 + createServerSettingsRecord(true, streamplaceUrl); 263 257 } else { 264 - dispatch( 265 - createServerSettingsRecord({ 266 - ...serverSettings, 267 - debugRecording: false, 268 - }), 269 - ); 258 + createServerSettingsRecord(false, streamplaceUrl); 270 259 } 271 260 }} 272 261 />
+5 -9
js/app/contexts/FullscreenContext.tsx
··· 1 - import { 2 - setSidebarHidden, 3 - setSidebarUnhidden, 4 - } from "features/base/sidebarSlice"; 5 1 import { 6 2 createContext, 7 3 ReactNode, ··· 9 5 useEffect, 10 6 useState, 11 7 } from "react"; 12 - import { useDispatch } from "react-redux"; 8 + import { useStore } from "store"; 13 9 14 10 interface FullscreenContextValue { 15 11 fullscreen: boolean; ··· 23 19 export const FullscreenProvider = ({ children }: { children: ReactNode }) => { 24 20 const [fullscreen, setFullscreen] = useState(false); 25 21 26 - // for hiding sidebar 27 - const dispatch = useDispatch(); 22 + const setSidebarHidden = useStore((state) => state.setSidebarHidden); 23 + const setSidebarUnhidden = useStore((state) => state.setSidebarUnhidden); 28 24 29 25 useEffect(() => { 30 26 if (fullscreen) { 31 - dispatch(setSidebarHidden()); 27 + setSidebarHidden(); 32 28 } else { 33 - dispatch(setSidebarUnhidden()); 29 + setSidebarUnhidden(); 34 30 } 35 31 }, [fullscreen]); 36 32
-57
js/app/features/base/baseSlice.tsx
··· 1 - import { storage } from "@streamplace/components"; 2 - import { createAppSlice } from "../../hooks/createSlice"; 3 - export const STORED_KEY_KEY = "storedKey"; 4 - export const DID_KEY = "did"; 5 - 6 - export interface StreamKey { 7 - privateKey: string; 8 - did: string; 9 - address: string; 10 - } 11 - 12 - export interface BaseState { 13 - hydrated: boolean; 14 - } 15 - 16 - const initialState: BaseState = { 17 - hydrated: false, 18 - }; 19 - 20 - export const baseSlice = createAppSlice({ 21 - name: "base", 22 - initialState, 23 - reducers: (create) => ({ 24 - hydrate: create.asyncThunk( 25 - async () => { 26 - let storedKey: StreamKey | null = null; 27 - // Async operation would go here 28 - try { 29 - const storedKeyStr = await storage.getItem(STORED_KEY_KEY); 30 - if (storedKeyStr) { 31 - storedKey = JSON.parse(storedKeyStr); 32 - } 33 - } catch (e) { 34 - // we don't have one i guess 35 - } 36 - return { storedKey }; 37 - }, 38 - { 39 - pending: (state) => { 40 - state.hydrated = false; 41 - }, 42 - fulfilled: (state) => { 43 - state.hydrated = true; 44 - }, 45 - rejected: (state) => { 46 - state.hydrated = false; 47 - }, 48 - }, 49 - ), 50 - }), 51 - selectors: { 52 - selectHydrated: (state) => state.hydrated, 53 - }, 54 - }); 55 - 56 - export const { hydrate } = baseSlice.actions; 57 - export const { selectHydrated } = baseSlice.selectors;
-124
js/app/features/base/sidebarSlice.tsx
··· 1 - import { storage } from "@streamplace/components"; 2 - import { createAppSlice } from "../../hooks/createSlice"; 3 - export const SIDEBAR_STORAGE_KEY = "sidebarState"; 4 - 5 - export interface SidebarState { 6 - isCollapsed: boolean; 7 - // should only be used in fullscreen 8 - isHidden: boolean; 9 - targetWidth: number; 10 - isLoaded: boolean; 11 - } 12 - 13 - const initialState: SidebarState = { 14 - isCollapsed: false, 15 - isHidden: false, 16 - targetWidth: 250, 17 - isLoaded: false, 18 - }; 19 - 20 - function verifySidebarState(state: any): SidebarState { 21 - const verifiedState: SidebarState = { 22 - isCollapsed: 23 - typeof state.isCollapsed === "boolean" ? state.isCollapsed : false, 24 - isHidden: typeof state.isHidden === "boolean" ? state.isHidden : false, 25 - targetWidth: 26 - typeof state.targetWidth === "number" ? state.targetWidth : 250, 27 - isLoaded: false, 28 - }; 29 - 30 - if (!verifiedState.isHidden) { 31 - if (verifiedState.targetWidth < 64) { 32 - verifiedState.targetWidth = 64; 33 - } else if (verifiedState.targetWidth > 250) { 34 - verifiedState.targetWidth = 250; 35 - } 36 - } else { 37 - verifiedState.targetWidth = 0; 38 - } 39 - 40 - return verifiedState; 41 - } 42 - 43 - export const sidebarSlice = createAppSlice({ 44 - name: "sidebar", 45 - initialState, 46 - reducers: (create) => ({ 47 - setSidebarHidden: create.reducer((state) => { 48 - state.isHidden = true; 49 - state.targetWidth = 50 - state.isCollapsed || state.isHidden ? (state.isHidden ? 0 : 64) : 250; 51 - }), 52 - setSidebarUnhidden: create.reducer((state) => { 53 - state.isHidden = false; 54 - state.targetWidth = 55 - state.isCollapsed || state.isHidden ? (state.isHidden ? 0 : 64) : 250; 56 - }), 57 - toggleSidebar: create.reducer((state) => { 58 - state.isCollapsed = !state.isCollapsed; 59 - state.targetWidth = 60 - state.isCollapsed || state.isHidden ? (state.isHidden ? 0 : 64) : 250; 61 - }), 62 - loadStateFromStorage: create.asyncThunk( 63 - async () => { 64 - const storedStateString = await storage.getItem(SIDEBAR_STORAGE_KEY); 65 - if (storedStateString) { 66 - let state = JSON.parse(storedStateString); 67 - // should never be 'true' on load, component should ALWAYS request to hide sidebar when loaded 68 - state.isHidden = false; 69 - return verifySidebarState(state) as SidebarState; 70 - } 71 - return null; 72 - }, 73 - { 74 - pending: (state) => { 75 - // unlikely that this will hang for a noticeable duration 76 - }, 77 - fulfilled: (state, action) => { 78 - if (action.payload) { 79 - state.isCollapsed = action.payload.isCollapsed; 80 - state.targetWidth = action.payload.targetWidth; 81 - console.log( 82 - "Sidebar state loaded from localStorage:", 83 - action.payload, 84 - ); 85 - } else { 86 - console.log( 87 - "No sidebar state found in localStorage, using defaults.", 88 - ); 89 - } 90 - state.isLoaded = true; 91 - }, 92 - rejected: (state, action) => { 93 - state.isLoaded = true; 94 - console.error( 95 - "Failed to load sidebar state from storage, using defaults:", 96 - action.error, 97 - ); 98 - // use defaults 99 - state.isCollapsed = false; 100 - state.targetWidth = 250; 101 - }, 102 - }, 103 - ), 104 - }), 105 - selectors: { 106 - selectIsSidebarCollapsed: (state) => state.isCollapsed, 107 - selectSidebarTargetWidth: (state) => state.targetWidth, 108 - selectIsSidebarLoaded: (state) => state.isLoaded, 109 - selectIsSidebarHidden: (state) => state.isHidden, 110 - }, 111 - }); 112 - 113 - export const { 114 - toggleSidebar, 115 - loadStateFromStorage, 116 - setSidebarHidden, 117 - setSidebarUnhidden, 118 - } = sidebarSlice.actions; 119 - export const { 120 - selectIsSidebarCollapsed, 121 - selectSidebarTargetWidth, 122 - selectIsSidebarLoaded, 123 - selectIsSidebarHidden, 124 - } = sidebarSlice.selectors;
+20 -20
js/app/features/bluesky/blueskyProvider.tsx
··· 1 1 import { useURL } from "expo-linking"; 2 2 import { useEffect, useState } from "react"; 3 - import { useAppDispatch, useAppSelector } from "store/hooks"; 4 - import { 5 - getProfile, 6 - loadOAuthClient, 7 - oauthCallback, 8 - selectIsReady, 9 - selectOAuthSession, 10 - selectUserProfile, 11 - } from "./blueskySlice"; 3 + import { useStore } from "store"; 4 + import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 12 5 13 6 export default function BlueskyProvider({ 14 7 children, 15 8 }: { 16 9 children: React.ReactNode; 17 10 }) { 18 - const dispatch = useAppDispatch(); 19 - const isReady = useAppSelector(selectIsReady); 11 + const loadOAuthClient = useStore((state) => state.loadOAuthClient); 12 + const oauthCallback = useStore((state) => state.oauthCallback); 13 + const getProfile = useStore((state) => state.getProfile); 14 + const streamplaceUrl = useStore((state) => state.url); 15 + const isReady = useIsReady(); 16 + 20 17 useEffect(() => { 21 - dispatch(loadOAuthClient()); 22 - }, []); 18 + loadOAuthClient(streamplaceUrl); 19 + }, [streamplaceUrl]); 20 + 23 21 useEffect(() => { 24 22 if (!isReady) { 25 23 const handle = setInterval(() => { 26 - dispatch(loadOAuthClient()); 24 + loadOAuthClient(streamplaceUrl); 27 25 }, 5000); 28 26 return () => clearInterval(handle); 29 27 } 30 - }, [isReady]); 31 - const oauthSession = useAppSelector(selectOAuthSession); 32 - const userProfile = useAppSelector(selectUserProfile); 28 + }, [isReady, streamplaceUrl]); 29 + 30 + const oauthSession = useOAuthSession(); 31 + const userProfile = useUserProfile(); 33 32 34 33 const [lastLink, setLastLink] = useState<string | null>(null); 35 34 const url = useURL(); ··· 40 39 if (url.includes("?")) { 41 40 const params = new URLSearchParams(url.split("?")[1]); 42 41 if (params.has("error") || params.has("code")) { 43 - dispatch(oauthCallback(url)); 42 + oauthCallback(url, streamplaceUrl); 44 43 } 45 44 } 46 45 } 47 - }, [url, lastLink]); 46 + }, [url, lastLink, streamplaceUrl]); 48 47 49 48 useEffect(() => { 50 49 if (oauthSession && !userProfile) { 51 - dispatch(getProfile(oauthSession.did)); 50 + getProfile(oauthSession.did); 52 51 } 53 52 }, [oauthSession, userProfile]); 53 + 54 54 return <>{children}</>; 55 55 }
-1515
js/app/features/bluesky/blueskySlice.tsx
··· 1 - import { 2 - Agent, 3 - AppBskyActorGetProfiles, 4 - AppBskyFeedPost, 5 - AppBskyGraphBlock, 6 - BlobRef, 7 - RichText, 8 - } from "@atproto/api"; 9 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 - import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; 11 - import { OAuthSession } from "@streamplace/atproto-oauth-client-react-native"; 12 - import { storage } from "@streamplace/components"; 13 - import { DID_KEY, hydrate, STORED_KEY_KEY } from "features/base/baseSlice"; 14 - import { openLoginLink } from "features/platform/platformSlice"; 15 - import { 16 - setURL, 17 - StreamplaceState, 18 - } from "features/streamplace/streamplaceSlice"; 19 - import { Platform } from "react-native"; 20 - import { 21 - LivestreamViewHydrated, 22 - PlaceStreamChatProfile, 23 - PlaceStreamKey, 24 - PlaceStreamLivestream, 25 - PlaceStreamServerSettings, 26 - StreamplaceAgent, 27 - } from "streamplace"; 28 - import { privateKeyToAccount } from "viem/accounts"; 29 - import { createAppSlice } from "../../hooks/createSlice"; 30 - import { BlueskyState } from "./blueskyTypes"; 31 - import createOAuthClient from "./oauthClient"; 32 - 33 - const initialState: BlueskyState = { 34 - status: "start", 35 - oauthState: null, 36 - oauthSession: undefined, 37 - pdsAgent: null, 38 - anonPDSAgent: null, 39 - profiles: {}, 40 - profileCache: {}, 41 - client: null, 42 - login: { 43 - loading: false, 44 - error: null, 45 - }, 46 - chatProfile: { 47 - loading: false, 48 - error: null, 49 - profile: null, 50 - }, 51 - pds: { 52 - url: "bsky.social", 53 - loading: false, 54 - error: null, 55 - }, 56 - newKey: null, 57 - storedKey: null, 58 - isDeletingKey: false, 59 - streamKeysResponse: { 60 - loading: true, 61 - error: null, 62 - records: null, 63 - }, 64 - newLivestream: null, 65 - serverSettings: null, 66 - }; 67 - 68 - const uploadThumbnail = async ( 69 - handle: string, 70 - u: URL, 71 - pdsAgent: StreamplaceAgent, 72 - profile: ProfileViewDetailed, 73 - customThumbnail?: Blob, 74 - ) => { 75 - if (customThumbnail) { 76 - let tries = 0; 77 - try { 78 - let thumbnail = await pdsAgent.uploadBlob(customThumbnail); 79 - 80 - while ( 81 - thumbnail.data.blob.size === 0 && 82 - customThumbnail.size !== 0 && 83 - tries < 3 84 - ) { 85 - console.warn( 86 - "Reuploading blob as blob sizes don't match! Blob size recieved is", 87 - thumbnail.data.blob.size, 88 - "and sent blob size is", 89 - customThumbnail.size, 90 - ); 91 - thumbnail = await pdsAgent.uploadBlob(customThumbnail); 92 - } 93 - 94 - if (tries === 3) { 95 - throw new Error("Could not successfully upload blob (tried thrice)"); 96 - } 97 - 98 - if (thumbnail.success) { 99 - console.log("Successfully uploaded thumbnail"); 100 - return thumbnail.data.blob; 101 - } 102 - } catch (e) { 103 - throw new Error("Error uploading thumbnail: " + e); 104 - } 105 - } 106 - }; 107 - 108 - // clear atproto login query params from url 109 - const clearQueryParams = () => { 110 - if (Platform.OS !== "web") { 111 - return; 112 - } 113 - const u = new URL(document.location.href); 114 - const params = new URLSearchParams(u.search); 115 - if (u.search === "") { 116 - return; 117 - } 118 - params.delete("iss"); 119 - params.delete("state"); 120 - params.delete("code"); 121 - u.search = params.toString(); 122 - window.history.replaceState(null, "", u.toString()); 123 - }; 124 - 125 - export const blueskySlice = createAppSlice({ 126 - name: "bluesky", 127 - initialState, 128 - extraReducers: (builder) => { 129 - builder.addCase(hydrate.fulfilled, (state, action) => { 130 - return { 131 - ...state, 132 - storedKey: action.payload.storedKey, 133 - }; 134 - }); 135 - builder.addCase(setURL, (state, action) => { 136 - return { 137 - ...state, 138 - anonPDSAgent: new StreamplaceAgent(action.payload) as any, 139 - }; 140 - }); 141 - builder.addDefaultCase((state, action) => { 142 - const err = (action as any).error as { message: string }; 143 - if ( 144 - typeof err === "object" && 145 - typeof err?.message === "string" && 146 - err.message.includes("oauth session revoked") 147 - ) { 148 - storage.removeItem("did").catch((e) => { 149 - console.error("Error removing did", e); 150 - }); 151 - storage.removeItem(STORED_KEY_KEY).catch((e) => { 152 - console.error("Error removing stored key", e); 153 - }); 154 - const u = new URL(document.location.href); 155 - return { 156 - ...state, 157 - oauthSession: null, 158 - status: "loggedOut", 159 - pdsAgent: null, 160 - }; 161 - } 162 - return state; 163 - }); 164 - }, 165 - reducers: (create) => ({ 166 - loadOAuthClient: create.asyncThunk( 167 - async (_, { getState }) => { 168 - const { streamplace } = getState() as { streamplace: StreamplaceState }; 169 - const client = await createOAuthClient(streamplace.url); 170 - const anonPDSAgent = new StreamplaceAgent(streamplace.url); 171 - const maybeDIDs = await Promise.all([ 172 - storage.getItem(DID_KEY), 173 - storage.getItem("@@atproto/oauth-client-browser(sub)"), 174 - storage.getItem("@@atproto/oauth-client-react-native:did:(sub)"), 175 - ]); 176 - const did = maybeDIDs.find((d) => d !== null) || null; 177 - let session: OAuthSession | null = null; 178 - if (did) { 179 - try { 180 - session = await client.restore(did); 181 - } catch (e) { 182 - console.error("Error restoring session", e); 183 - } 184 - } 185 - // let initResult = await client.init(); 186 - return { client, session, anonPDSAgent }; 187 - }, 188 - { 189 - pending: (state) => { 190 - return { 191 - ...state, 192 - status: "start", 193 - }; 194 - }, 195 - fulfilled: (state, action) => { 196 - const { client, session, anonPDSAgent } = action.payload; 197 - console.log("loadOAuthClient fulfilled", action.payload); 198 - if (session) { 199 - storage.setItem(DID_KEY, session.did).catch((e) => { 200 - console.error("Error setting did", e); 201 - }); 202 - return { 203 - ...state, 204 - client: client as any, 205 - status: "loggedIn", 206 - oauthSession: session as any, 207 - pdsAgent: new StreamplaceAgent(session) as any, // idk why this is needed 208 - anonPDSAgent: anonPDSAgent as any, 209 - } as any; 210 - } 211 - return { 212 - ...state, 213 - oauthSession: session, 214 - status: "loggedOut", 215 - client: client, 216 - anonPDSAgent: anonPDSAgent, 217 - }; 218 - }, 219 - rejected: (state, { error }) => { 220 - return { 221 - ...state, 222 - // status: "loggedOut", 223 - }; 224 - }, 225 - }, 226 - ), 227 - oauthError: create.reducer( 228 - ( 229 - state, 230 - { payload }: { payload: { error: string; description: string } }, 231 - ) => { 232 - return { 233 - ...state, 234 - login: { 235 - loading: false, 236 - error: payload.description || payload.error, 237 - }, 238 - status: "loggedOut", 239 - }; 240 - }, 241 - ), 242 - 243 - login: create.asyncThunk( 244 - async (handle: string, thunkAPI) => { 245 - let { bluesky } = thunkAPI.getState() as { 246 - bluesky: BlueskyState; 247 - }; 248 - await thunkAPI.dispatch(loadOAuthClient()); 249 - ({ bluesky } = thunkAPI.getState() as { 250 - bluesky: BlueskyState; 251 - }); 252 - if (!bluesky.client) { 253 - throw new Error("No client"); 254 - } 255 - const u = await bluesky.client.authorize(handle, {}); 256 - 257 - // if we're native, we can't access document 258 - if ( 259 - typeof document !== "undefined" && 260 - document.location.href.startsWith("http://127.0.0.1") 261 - ) { 262 - const hostUrl = new URL(document.location.href); 263 - u.host = hostUrl.host; 264 - u.protocol = hostUrl.protocol; 265 - } 266 - thunkAPI.dispatch(openLoginLink(u.toString())); 267 - // cheeky 500ms delay so you don't see the text flash back 268 - await new Promise((resolve) => setTimeout(resolve, 5000)); 269 - }, 270 - { 271 - pending: (state) => { 272 - return { 273 - ...state, 274 - login: { 275 - loading: true, 276 - error: null, 277 - }, 278 - }; 279 - }, 280 - fulfilled: (state, action) => { 281 - // document.location.href = action.payload.toString(); 282 - return { 283 - ...state, 284 - login: { 285 - loading: false, 286 - error: null, 287 - }, 288 - }; 289 - }, 290 - rejected: (state, action) => { 291 - console.error("login rejected", action.error); 292 - return { 293 - ...state, 294 - login: { 295 - loading: false, 296 - error: action.error?.message ?? null, 297 - }, 298 - }; 299 - // state.status = "failed"; 300 - }, 301 - }, 302 - ), 303 - 304 - logout: create.asyncThunk( 305 - async (_, thunkAPI) => { 306 - await storage.removeItem("did"); 307 - await storage.removeItem(STORED_KEY_KEY); 308 - const { bluesky } = thunkAPI.getState() as { 309 - bluesky: BlueskyState; 310 - }; 311 - if (!bluesky.oauthSession) { 312 - throw new Error("No oauth session"); 313 - } 314 - return bluesky.oauthSession.signOut(); 315 - }, 316 - { 317 - pending: (state) => { 318 - // state.status = "loading"; 319 - }, 320 - fulfilled: (state, action) => { 321 - return { 322 - ...state, 323 - oauthSession: undefined, 324 - pdsAgent: null, 325 - status: "loggedOut", 326 - }; 327 - }, 328 - rejected: (state) => { 329 - console.error("logout rejected"); 330 - // state.status = "failed"; 331 - }, 332 - }, 333 - ), 334 - 335 - getProfile: create.asyncThunk( 336 - async (actor: string, thunkAPI) => { 337 - const { bluesky } = thunkAPI.getState() as { 338 - bluesky: BlueskyState; 339 - }; 340 - if (!bluesky.pdsAgent) { 341 - throw new Error("No agent"); 342 - } 343 - return await bluesky.pdsAgent.getProfile({ 344 - actor: actor, 345 - }); 346 - }, 347 - { 348 - pending: (state) => { 349 - // state.status = "loading"; 350 - }, 351 - fulfilled: (state, action) => { 352 - clearQueryParams(); 353 - return { 354 - ...state, 355 - status: "loggedIn", 356 - profiles: { 357 - ...state.profiles, 358 - [action.meta.arg]: action.payload.data, 359 - }, 360 - }; 361 - }, 362 - rejected: (state, action) => { 363 - clearQueryParams(); 364 - return { 365 - ...state, 366 - status: "loggedOut", 367 - }; 368 - }, 369 - }, 370 - ), 371 - 372 - getProfiles: create.asyncThunk( 373 - async (actors: string[], thunkAPI) => { 374 - if (actors.length > 25) { 375 - throw Error("Requested too many actors! (max 25 actors)"); 376 - } 377 - const { bluesky } = thunkAPI.getState() as { 378 - bluesky: BlueskyState; 379 - }; 380 - // unauthed request to Bluesky Appview 381 - const bskyAgent = new Agent("https://public.api.bsky.app"); 382 - 383 - return await bskyAgent.getProfiles({ 384 - actors: actors, 385 - }); 386 - }, 387 - { 388 - pending: (state) => { 389 - // state.status = "loading"; 390 - }, 391 - fulfilled: (state, action) => { 392 - let payload: AppBskyActorGetProfiles.Response = action.payload; 393 - let parsedProfiles = {}; 394 - console.log(payload); 395 - payload.data.profiles.forEach((p) => { 396 - parsedProfiles[p.did] = p; 397 - }); 398 - 399 - return { 400 - ...state, 401 - profileCache: { 402 - ...state.profileCache, 403 - ...parsedProfiles, 404 - }, 405 - }; 406 - }, 407 - rejected: (state, action) => { 408 - // state.status = "failed"; 409 - }, 410 - }, 411 - ), 412 - 413 - oauthCallback: create.asyncThunk( 414 - async (url: string, thunkAPI) => { 415 - console.log("oauthCallback", url); 416 - if (!url.includes("?")) { 417 - throw new Error("No query params"); 418 - } 419 - const params = new URLSearchParams(url.split("?")[1]); 420 - if (!(params.has("code") && params.has("state") && params.has("iss"))) { 421 - if (params.has("error")) { 422 - thunkAPI.dispatch( 423 - oauthError({ 424 - error: params.get("error") ?? "", 425 - description: params.get("error_description") ?? "", 426 - }), 427 - ); 428 - } 429 - throw new Error("Missing params, got: " + url); 430 - } 431 - const { streamplace } = thunkAPI.getState() as { 432 - streamplace: StreamplaceState; 433 - }; 434 - const client = await createOAuthClient(streamplace.url); 435 - try { 436 - const ret = await client.callback(params); 437 - await storage.setItem(DID_KEY, ret.session.did); 438 - return { session: ret.session as any, client }; 439 - } catch (e) { 440 - let message = e.message; 441 - while (e.cause) { 442 - message = `${message}: ${e.cause.message}`; 443 - e = e.cause; 444 - } 445 - console.error("oauthCallback error", message); 446 - throw e; 447 - } 448 - }, 449 - 450 - { 451 - pending: (state) => { 452 - return { 453 - ...state, 454 - status: "start", 455 - }; 456 - }, 457 - fulfilled: (state, action) => { 458 - console.log("oauthCallback fulfilled", action.payload); 459 - return { 460 - ...state, 461 - client: action.payload.client as any, 462 - oauthSession: action.payload.session as any, 463 - pdsAgent: new StreamplaceAgent(action.payload.session) as any, 464 - status: "loggedIn", 465 - }; 466 - }, 467 - rejected: (state, action) => { 468 - console.error("oauthCallback rejected", action.error); 469 - return { 470 - ...state, 471 - status: "loggedOut", 472 - }; 473 - }, 474 - }, 475 - ), 476 - 477 - golivePost: create.asyncThunk( 478 - async ( 479 - { 480 - text, 481 - now, 482 - thumbnail, 483 - }: { text: string; now: Date; thumbnail?: BlobRef }, 484 - thunkAPI, 485 - ): Promise<{ 486 - uri: string; 487 - cid: string; 488 - }> => { 489 - const { bluesky, streamplace } = thunkAPI.getState() as { 490 - bluesky: BlueskyState; 491 - streamplace: StreamplaceState; 492 - }; 493 - if (!bluesky.pdsAgent) { 494 - throw new Error("No agent"); 495 - } 496 - const did = bluesky.oauthSession?.did; 497 - if (!did) { 498 - throw new Error("No DID"); 499 - } 500 - const profile = bluesky.profiles[did]; 501 - if (!profile) { 502 - throw new Error("No profile"); 503 - } 504 - const u = new URL(streamplace.url); 505 - const params = new URLSearchParams({ 506 - did: did, 507 - time: new Date().toISOString(), 508 - }); 509 - 510 - const linkUrl = `${u.protocol}//${u.host}/${profile.handle}?${params.toString()}`; 511 - const prefix = `🔴 LIVE `; 512 - const textUrl = `${u.protocol}//${u.host}/${profile.handle}`; 513 - const suffix = ` ${text}`; 514 - const content = prefix + textUrl + suffix; 515 - 516 - const rt = new RichText({ text: content }); 517 - rt.detectFacetsWithoutResolution(); 518 - 519 - const record: AppBskyFeedPost.Record = { 520 - $type: "app.bsky.feed.post", 521 - text: content, 522 - "place.stream.livestream": { 523 - url: linkUrl, 524 - title: text, 525 - }, 526 - facets: rt.facets, 527 - createdAt: now.toISOString(), 528 - }; 529 - record.embed = { 530 - $type: "app.bsky.embed.external", 531 - external: { 532 - description: text, 533 - thumb: thumbnail, 534 - title: `@${profile.handle} is 🔴LIVE on ${u.host}!`, 535 - uri: linkUrl, 536 - }, 537 - }; 538 - console.log("golivePost record", record); 539 - return await bluesky.pdsAgent.post(record); 540 - }, 541 - { 542 - pending: (state) => { 543 - console.log("golivePost pending"); 544 - }, 545 - fulfilled: (state, action) => { 546 - console.log("golivePost fulfilled", action.payload); 547 - }, 548 - rejected: (state, action) => { 549 - console.error("golivePost rejected", action.error); 550 - // state.status = "failed"; 551 - }, 552 - }, 553 - ), 554 - 555 - createBlockRecord: create.asyncThunk( 556 - async ({ subjectDID }: { subjectDID: string }, thunkAPI) => { 557 - const { bluesky, streamplace } = thunkAPI.getState() as { 558 - bluesky: BlueskyState; 559 - streamplace: StreamplaceState; 560 - }; 561 - if (!bluesky.pdsAgent) { 562 - throw new Error("No agent"); 563 - } 564 - const did = bluesky.oauthSession?.did; 565 - if (!did) { 566 - throw new Error("No DID"); 567 - } 568 - const profile = bluesky.profiles[did]; 569 - if (!profile) { 570 - throw new Error("No profile"); 571 - } 572 - const record: AppBskyGraphBlock.Record = { 573 - $type: "app.bsky.graph.block", 574 - subject: subjectDID, 575 - createdAt: new Date().toISOString(), 576 - }; 577 - return await bluesky.pdsAgent.com.atproto.repo.createRecord({ 578 - repo: did, 579 - collection: "app.bsky.graph.block", 580 - record, 581 - }); 582 - }, 583 - { 584 - pending: (state) => { 585 - console.log("createBlockRecord pending"); 586 - }, 587 - fulfilled: (state, action) => { 588 - console.log("createBlockRecord fulfilled", action.payload); 589 - }, 590 - rejected: (state, action) => { 591 - console.error("createBlockRecord rejected", action.error); 592 - // state.status = "failed"; 593 - }, 594 - }, 595 - ), 596 - 597 - createStreamKeyRecord: create.asyncThunk( 598 - async ({ store }: { store: boolean }, thunkAPI) => { 599 - const { bluesky } = thunkAPI.getState() as { 600 - bluesky: BlueskyState; 601 - }; 602 - if (!bluesky.pdsAgent) { 603 - throw new Error("No agent"); 604 - } 605 - const did = bluesky.oauthSession?.did; 606 - if (!did) { 607 - throw new Error("No DID"); 608 - } 609 - const profile = bluesky.profiles[did]; 610 - if (!profile) { 611 - throw new Error("No profile"); 612 - } 613 - if (!did) { 614 - throw new Error("No DID"); 615 - } 616 - const keypair = await Secp256k1Keypair.create({ exportable: true }); 617 - const exportedKey = await keypair.export(); 618 - const didBytes = new TextEncoder().encode(did); 619 - const combinedKey = new Uint8Array([...exportedKey, ...didBytes]); 620 - const multibaseKey = bytesToMultibase(combinedKey, "base58btc"); 621 - const hexKey = Array.from(exportedKey) 622 - .map((b) => b.toString(16).padStart(2, "0")) 623 - .join(""); 624 - const account = await privateKeyToAccount(`0x${hexKey}`); 625 - const newKey = { 626 - privateKey: multibaseKey, 627 - did: keypair.did(), 628 - address: account.address.toLowerCase(), 629 - }; 630 - 631 - let platform: string = Platform.OS; 632 - 633 - // window only exists on web 634 - if (Platform.OS === "web" && window && window.navigator) { 635 - let splitUA = window.navigator.userAgent 636 - .split(" ") 637 - .pop() 638 - ?.split("/")[0]; 639 - if (splitUA) { 640 - platform = splitUA; 641 - } 642 - // proper capitalization 643 - } else if (platform === "android") { 644 - platform = "Android"; 645 - } else if (platform === "ios") { 646 - platform = "iOS"; 647 - } else if (platform === "macos") { 648 - platform = "macOS"; 649 - } else if (platform === "windows") { 650 - platform = "Windows"; 651 - } 652 - 653 - const record: PlaceStreamKey.Record = { 654 - $type: "place.stream.key", 655 - signingKey: keypair.did(), 656 - createdAt: new Date().toISOString(), 657 - createdBy: "Streamplace on " + platform, 658 - }; 659 - await bluesky.pdsAgent.com.atproto.repo.createRecord({ 660 - repo: did, 661 - collection: "place.stream.key", 662 - record, 663 - }); 664 - if (store) { 665 - await storage.setItem(STORED_KEY_KEY, JSON.stringify(newKey)); 666 - } 667 - return newKey; 668 - }, 669 - { 670 - pending: (state) => { 671 - console.log("golivePost pending"); 672 - }, 673 - fulfilled: (state, action) => { 674 - return { 675 - ...state, 676 - newKey: action.payload, 677 - storedKey: action.meta.arg.store ? action.payload : null, 678 - }; 679 - }, 680 - rejected: (state, action) => { 681 - console.error("createStreamKeyRecord rejected", action.error); 682 - // state.status = "failed"; 683 - }, 684 - }, 685 - ), 686 - 687 - clearStreamKeyRecord: create.reducer((state) => { 688 - return { 689 - ...state, 690 - newKey: null, 691 - }; 692 - }), 693 - 694 - getStreamKeyRecords: create.asyncThunk( 695 - async (_, thunkAPI) => { 696 - const { bluesky } = thunkAPI.getState() as { 697 - bluesky: BlueskyState; 698 - }; 699 - if (!bluesky.pdsAgent) { 700 - throw new Error("No agent"); 701 - } 702 - const did = bluesky.oauthSession?.did; 703 - if (!did) { 704 - throw new Error("No DID"); 705 - } 706 - const profile = bluesky.profiles[did]; 707 - if (!profile) { 708 - throw new Error("No profile"); 709 - } 710 - if (!did) { 711 - throw new Error("No DID"); 712 - } 713 - return await bluesky.pdsAgent.com.atproto.repo.listRecords({ 714 - repo: did, 715 - collection: "place.stream.key", 716 - limit: 100, 717 - }); 718 - }, 719 - { 720 - pending: (state) => { 721 - return { 722 - ...state, 723 - streamKeysResponse: { 724 - loading: true, 725 - error: null, 726 - records: null, 727 - }, 728 - }; 729 - }, 730 - fulfilled: (state, action) => { 731 - console.log(action.payload); 732 - return { 733 - ...state, 734 - streamKeysResponse: { 735 - loading: false, 736 - error: null, 737 - records: action.payload.data, 738 - }, 739 - }; 740 - }, 741 - rejected: (state, action) => { 742 - console.error("listStreamKeyRecords rejected", action.error); 743 - 744 - return { 745 - ...state, 746 - streamKeysResponse: { 747 - loading: false, 748 - error: action.error?.message ?? null, 749 - records: null, 750 - }, 751 - }; 752 - }, 753 - }, 754 - ), 755 - 756 - deleteStreamKeyRecord: create.asyncThunk( 757 - async ({ rkey }: { rkey: string }, thunkAPI) => { 758 - const { bluesky } = thunkAPI.getState() as { 759 - bluesky: BlueskyState; 760 - }; 761 - if (!bluesky.pdsAgent) { 762 - throw new Error("No agent"); 763 - } 764 - const did = bluesky.oauthSession?.did; 765 - if (!did) { 766 - throw new Error("No DID"); 767 - } 768 - const profile = bluesky.profiles[did]; 769 - if (!profile) { 770 - throw new Error("No profile"); 771 - } 772 - if (!did) { 773 - throw new Error("No DID"); 774 - } 775 - 776 - return await bluesky.pdsAgent.com.atproto.repo.deleteRecord({ 777 - repo: did, 778 - collection: "place.stream.key", 779 - rkey, 780 - }); 781 - }, 782 - { 783 - pending: (state) => { 784 - return { 785 - ...state, 786 - isDeletingKey: true, 787 - }; 788 - }, 789 - fulfilled: (state, action) => { 790 - let records = state.streamKeysResponse.records 791 - ? state.streamKeysResponse.records.records.filter( 792 - (r) => r.uri.split("/").pop() !== action.meta.arg.rkey, 793 - ) 794 - : []; 795 - 796 - return { 797 - ...state, 798 - isDeletingKey: false, 799 - streamKeysResponse: { 800 - ...state.streamKeysResponse, 801 - records: { 802 - ...state.streamKeysResponse.records, 803 - records, 804 - }, 805 - }, 806 - }; 807 - }, 808 - rejected: (state, action) => { 809 - console.error("deleteStreamKeyRecord rejected", action.error); 810 - return { 811 - ...state, 812 - isDeletingKey: false, 813 - }; 814 - }, 815 - }, 816 - ), 817 - 818 - setPDS: create.asyncThunk( 819 - async (pds: string, thunkAPI) => { 820 - await storage.setItem("pdsURL", pds); 821 - return pds; 822 - }, 823 - { 824 - pending: (state, action) => { 825 - return { 826 - ...state, 827 - pds: { 828 - ...state.pds, 829 - loading: true, 830 - }, 831 - }; 832 - }, 833 - fulfilled: (state, action) => { 834 - // document.location.href = action.payload.toString(); 835 - console.log("setPDS fulfilled", action.payload); 836 - return { 837 - ...state, 838 - pds: { 839 - ...state.pds, 840 - loading: false, 841 - url: action.payload, 842 - }, 843 - }; 844 - }, 845 - rejected: (state, action) => { 846 - return { 847 - ...state, 848 - pds: { 849 - ...state.pds, 850 - loading: false, 851 - error: action.error?.message ?? null, 852 - }, 853 - }; 854 - }, 855 - }, 856 - ), 857 - 858 - createLivestreamRecord: create.asyncThunk( 859 - async ( 860 - { title, customThumbnail }: { title: string; customThumbnail?: Blob }, 861 - thunkAPI, 862 - ) => { 863 - const now = new Date(); 864 - const { bluesky, streamplace } = thunkAPI.getState() as { 865 - bluesky: BlueskyState; 866 - streamplace: StreamplaceState; 867 - }; 868 - if (!bluesky.pdsAgent) { 869 - throw new Error("No agent"); 870 - } 871 - const did = bluesky.oauthSession?.did; 872 - if (!did) { 873 - throw new Error("No DID"); 874 - } 875 - const profile = bluesky.profiles[did]; 876 - if (!profile) { 877 - throw new Error("No profile"); 878 - } 879 - 880 - let thumbnail: BlobRef | undefined = undefined; 881 - 882 - const u = new URL(streamplace.url); 883 - 884 - if (customThumbnail) { 885 - try { 886 - thumbnail = await uploadThumbnail( 887 - profile.handle, 888 - u, 889 - bluesky.pdsAgent, 890 - profile, 891 - customThumbnail, 892 - ); 893 - } catch (e) { 894 - throw new Error(`Custom thumbnail upload failed ${e}`); 895 - } 896 - } else { 897 - // No custom thumbnail: fetch the server-side image and upload it 898 - // try thrice lel 899 - let tries = 0; 900 - try { 901 - for (; tries < 3; tries++) { 902 - try { 903 - console.log( 904 - `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, 905 - ); 906 - const thumbnailRes = await fetch( 907 - `${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, 908 - ); 909 - if (!thumbnailRes.ok) { 910 - throw new Error( 911 - `Failed to fetch thumbnail: ${thumbnailRes.status})`, 912 - ); 913 - } 914 - const thumbnailBlob = await thumbnailRes.blob(); 915 - console.log(thumbnailBlob); 916 - thumbnail = await uploadThumbnail( 917 - profile.handle, 918 - u, 919 - bluesky.pdsAgent, 920 - profile, 921 - thumbnailBlob, 922 - ); 923 - } catch (e) { 924 - console.warn( 925 - `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, 926 - ); 927 - // Wait 1 second before retrying 928 - await new Promise((resolve) => setTimeout(resolve, 2000)); 929 - if (tries === 2) { 930 - throw new Error( 931 - `Failed to fetch thumbnail after 3 tries: ${e}`, 932 - ); 933 - } 934 - } 935 - } 936 - } catch (e) { 937 - throw new Error(`Thumbnail upload failed ${e}`); 938 - } 939 - } 940 - 941 - const newPostAction = await thunkAPI.dispatch( 942 - golivePost({ text: title, now, thumbnail }), 943 - ); 944 - 945 - if (!newPostAction || newPostAction.type.endsWith("/rejected")) { 946 - throw new Error( 947 - `Failed to create post: ${(newPostAction as any)?.error?.message || "Unknown error"}`, 948 - ); 949 - } 950 - 951 - const newPost = newPostAction as { 952 - payload: { uri: string; cid: string }; 953 - }; 954 - 955 - if (!newPost.payload?.uri || !newPost.payload?.cid) { 956 - throw new Error( 957 - "Cannot read properties of undefined (reading 'uri' or 'cid')", 958 - ); 959 - } 960 - 961 - const record: PlaceStreamLivestream.Record = { 962 - $type: "place.stream.livestream", 963 - title: title, 964 - url: streamplace.url, 965 - createdAt: new Date().toISOString(), 966 - post: { 967 - uri: newPost.payload.uri, 968 - cid: newPost.payload.cid, 969 - }, 970 - thumb: thumbnail, 971 - }; 972 - 973 - await bluesky.pdsAgent.com.atproto.repo.createRecord({ 974 - repo: did, 975 - collection: "place.stream.livestream", 976 - record, 977 - }); 978 - return record; 979 - }, 980 - { 981 - pending: (state) => { 982 - return { 983 - ...state, 984 - newLivestream: { 985 - loading: true, 986 - error: null, 987 - record: null, 988 - }, 989 - }; 990 - }, 991 - fulfilled: (state, action) => { 992 - return { 993 - ...state, 994 - newLivestream: { 995 - loading: false, 996 - error: null, 997 - record: action.payload, 998 - }, 999 - }; 1000 - }, 1001 - rejected: (state, action) => { 1002 - console.error("createLivestreamRecord rejected", action.error); 1003 - return { 1004 - ...state, 1005 - newLivestream: { 1006 - loading: false, 1007 - error: action.error?.message ?? null, 1008 - record: null, 1009 - }, 1010 - }; 1011 - }, 1012 - }, 1013 - ), 1014 - 1015 - updateLivestreamRecord: create.asyncThunk( 1016 - async ( 1017 - { 1018 - title, 1019 - livestream, 1020 - }: { title: string; livestream: LivestreamViewHydrated | null }, 1021 - thunkAPI, 1022 - ) => { 1023 - const now = new Date(); 1024 - const { bluesky, streamplace } = thunkAPI.getState() as { 1025 - bluesky: BlueskyState; 1026 - streamplace: StreamplaceState; 1027 - }; 1028 - 1029 - if (!bluesky.pdsAgent) { 1030 - throw new Error("No agent"); 1031 - } 1032 - const did = bluesky.oauthSession?.did; 1033 - if (!did) { 1034 - throw new Error("No DID"); 1035 - } 1036 - const profile = bluesky.profiles[did]; 1037 - if (!profile) { 1038 - throw new Error("No profile"); 1039 - } 1040 - 1041 - let oldRecord = livestream; 1042 - if (!oldRecord) { 1043 - throw new Error("No latest record"); 1044 - } 1045 - 1046 - let rkey = oldRecord.uri.split("/").pop(); 1047 - let oldRecordValue: PlaceStreamLivestream.Record = oldRecord.record; 1048 - 1049 - if (!rkey) { 1050 - throw new Error("No rkey?"); 1051 - } 1052 - 1053 - console.log("Updating rkey", rkey); 1054 - 1055 - const record: PlaceStreamLivestream.Record = { 1056 - $type: "place.stream.livestream", 1057 - title: title, 1058 - url: streamplace.url, 1059 - createdAt: new Date().toISOString(), 1060 - post: oldRecordValue.post, 1061 - }; 1062 - 1063 - await bluesky.pdsAgent.com.atproto.repo.putRecord({ 1064 - repo: did, 1065 - collection: "place.stream.livestream", 1066 - rkey, 1067 - record, 1068 - }); 1069 - return record; 1070 - }, 1071 - { 1072 - pending: (state) => { 1073 - return { 1074 - ...state, 1075 - newLivestream: { 1076 - loading: true, 1077 - error: null, 1078 - record: null, 1079 - }, 1080 - }; 1081 - }, 1082 - fulfilled: (state, action) => { 1083 - return { 1084 - ...state, 1085 - newLivestream: { 1086 - loading: false, 1087 - error: null, 1088 - record: action.payload, 1089 - }, 1090 - }; 1091 - }, 1092 - rejected: (state, action) => { 1093 - console.error("createLivestreamRecord rejected", action.error); 1094 - return { 1095 - ...state, 1096 - newLivestream: { 1097 - loading: false, 1098 - error: action.error?.message ?? null, 1099 - record: null, 1100 - }, 1101 - }; 1102 - }, 1103 - }, 1104 - ), 1105 - 1106 - getChatProfileRecordFromPDS: create.asyncThunk( 1107 - async (_, thunkAPI) => { 1108 - const { bluesky } = thunkAPI.getState() as { bluesky: BlueskyState }; 1109 - const did = bluesky.oauthSession?.did; 1110 - if (!did) { 1111 - throw new Error("No DID"); 1112 - } 1113 - const profile = bluesky.profiles[did]; 1114 - if (!profile) { 1115 - throw new Error("No profile"); 1116 - } 1117 - if (!bluesky.pdsAgent) { 1118 - throw new Error("No agent"); 1119 - } 1120 - const res = await bluesky.pdsAgent.com.atproto.repo.getRecord({ 1121 - repo: did, 1122 - collection: "place.stream.chat.profile", 1123 - rkey: "self", 1124 - }); 1125 - if (!res.success) { 1126 - throw new Error("Failed to get chat profile record"); 1127 - } 1128 - 1129 - if (PlaceStreamChatProfile.isRecord(res.data.value)) { 1130 - return res.data.value; 1131 - } else { 1132 - console.log("not a record", res.data.value); 1133 - } 1134 - return null; 1135 - }, 1136 - { 1137 - pending: (state) => { 1138 - return { 1139 - ...state, 1140 - chatProfile: { 1141 - loading: true, 1142 - error: null, 1143 - profile: null, 1144 - }, 1145 - }; 1146 - }, 1147 - fulfilled: (state, action) => { 1148 - if (!action.payload) { 1149 - return state; 1150 - } 1151 - return { 1152 - ...state, 1153 - chatProfile: { 1154 - loading: false, 1155 - error: null, 1156 - profile: action.payload, 1157 - }, 1158 - }; 1159 - }, 1160 - }, 1161 - ), 1162 - 1163 - createChatProfileRecord: create.asyncThunk( 1164 - async ( 1165 - { red, green, blue }: { red: number; green: number; blue: number }, 1166 - thunkAPI, 1167 - ) => { 1168 - const { bluesky } = thunkAPI.getState() as { 1169 - bluesky: BlueskyState; 1170 - }; 1171 - if (!bluesky.pdsAgent) { 1172 - throw new Error("No agent"); 1173 - } 1174 - const did = bluesky.oauthSession?.did; 1175 - if (!did) { 1176 - throw new Error("No DID"); 1177 - } 1178 - const profile = bluesky.profiles[did]; 1179 - if (!profile) { 1180 - throw new Error("No profile"); 1181 - } 1182 - if (!did) { 1183 - throw new Error("No DID"); 1184 - } 1185 - 1186 - const chatProfile: PlaceStreamChatProfile.Record = { 1187 - $type: "place.stream.chat.profile", 1188 - color: { 1189 - red: red, 1190 - green: green, 1191 - blue: blue, 1192 - }, 1193 - }; 1194 - 1195 - const res = await bluesky.pdsAgent.com.atproto.repo.putRecord({ 1196 - repo: did, 1197 - collection: "place.stream.chat.profile", 1198 - record: chatProfile, 1199 - rkey: "self", 1200 - }); 1201 - if (!res.success) { 1202 - throw new Error("Failed to create chat profile record"); 1203 - } 1204 - return chatProfile; 1205 - }, 1206 - { 1207 - pending: (state) => { 1208 - return { 1209 - ...state, 1210 - chatProfile: { 1211 - loading: true, 1212 - error: null, 1213 - profile: null, 1214 - }, 1215 - }; 1216 - }, 1217 - fulfilled: (state, action) => { 1218 - return { 1219 - ...state, 1220 - chatProfile: { 1221 - loading: false, 1222 - error: null, 1223 - profile: action.payload, 1224 - }, 1225 - }; 1226 - }, 1227 - rejected: (state, action) => { 1228 - console.error("createChatProfileRecord rejected", action.error); 1229 - return { 1230 - ...state, 1231 - chatProfile: { 1232 - loading: false, 1233 - error: action.error?.message ?? null, 1234 - profile: null, 1235 - }, 1236 - }; 1237 - }, 1238 - }, 1239 - ), 1240 - 1241 - followUser: create.asyncThunk( 1242 - async (subjectDID: string, thunkAPI) => { 1243 - const { bluesky } = thunkAPI.getState() as { 1244 - bluesky: BlueskyState; 1245 - }; 1246 - if (!bluesky.pdsAgent) { 1247 - throw new Error("No agent"); 1248 - } 1249 - const did = bluesky.oauthSession?.did; 1250 - if (!did) { 1251 - throw new Error("No DID"); 1252 - } 1253 - await bluesky.pdsAgent.follow(subjectDID); 1254 - 1255 - return { subjectDID }; 1256 - }, 1257 - { 1258 - pending: (state) => { 1259 - console.log("followUser pending"); 1260 - }, 1261 - fulfilled: (state, action) => { 1262 - console.log("followUser fulfilled", action.payload); 1263 - }, 1264 - rejected: (state, action) => { 1265 - console.error("followUser rejected", action.error); 1266 - }, 1267 - }, 1268 - ), 1269 - 1270 - unfollowUser: create.asyncThunk( 1271 - async ( 1272 - { subjectDID, followUri }: { subjectDID: string; followUri?: string }, 1273 - thunkAPI, 1274 - ) => { 1275 - const { bluesky, streamplace } = thunkAPI.getState() as { 1276 - bluesky: BlueskyState; 1277 - streamplace: StreamplaceState; 1278 - }; 1279 - let agent; 1280 - if (!bluesky.pdsAgent) { 1281 - throw new Error("No agent"); 1282 - } 1283 - const did = bluesky.oauthSession?.did; 1284 - if (!did) { 1285 - throw new Error("No DID"); 1286 - } 1287 - 1288 - if (followUri) { 1289 - await bluesky.pdsAgent.deleteFollow(followUri); 1290 - } else { 1291 - const res = await fetch( 1292 - `${streamplace.url}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(subjectDID)}&userDID=${encodeURIComponent(did)}`, 1293 - { 1294 - credentials: "include", 1295 - }, 1296 - ); 1297 - const data = await res.json(); 1298 - 1299 - if (!data.follow || !data.follow.uri) { 1300 - throw new Error("Follow record not found"); 1301 - } 1302 - 1303 - await bluesky.pdsAgent.deleteFollow(data.follow.uri); 1304 - } 1305 - 1306 - return { subjectDID }; 1307 - }, 1308 - { 1309 - pending: (state) => { 1310 - console.log("unfollowUser pending"); 1311 - }, 1312 - fulfilled: (state, action) => { 1313 - console.log("unfollowUser fulfilled", action.payload); 1314 - }, 1315 - rejected: (state, action) => { 1316 - console.error("unfollowUser rejected", action.error); 1317 - }, 1318 - }, 1319 - ), 1320 - 1321 - getServerSettingsFromPDS: create.asyncThunk( 1322 - async (_, thunkAPI) => { 1323 - const { bluesky, streamplace } = thunkAPI.getState() as { 1324 - bluesky: BlueskyState; 1325 - streamplace: StreamplaceState; 1326 - }; 1327 - const did = bluesky.oauthSession?.did; 1328 - if (!did) { 1329 - throw new Error("No DID"); 1330 - } 1331 - const profile = bluesky.profiles[did]; 1332 - if (!profile) { 1333 - throw new Error("No profile"); 1334 - } 1335 - if (!bluesky.pdsAgent) { 1336 - throw new Error("No agent"); 1337 - } 1338 - const u = new URL(streamplace.url); 1339 - const res = await bluesky.pdsAgent.com.atproto.repo.getRecord({ 1340 - repo: did, 1341 - collection: "place.stream.server.settings", 1342 - rkey: u.host, 1343 - }); 1344 - if (!res.success) { 1345 - throw new Error("Failed to get chat profile record"); 1346 - } 1347 - 1348 - if (PlaceStreamServerSettings.isRecord(res.data.value)) { 1349 - return res.data.value as PlaceStreamServerSettings.Record; 1350 - } else { 1351 - console.log("not a record", res.data.value); 1352 - } 1353 - return null; 1354 - }, 1355 - { 1356 - pending: (state) => { 1357 - return { 1358 - ...state, 1359 - }; 1360 - }, 1361 - fulfilled: (state, action) => { 1362 - if (!action.payload) { 1363 - return state; 1364 - } 1365 - return { 1366 - ...state, 1367 - serverSettings: action.payload, 1368 - }; 1369 - }, 1370 - rejected: (state, action) => { 1371 - console.error("getServerSettingsFromPDS rejected", action.error); 1372 - return { 1373 - ...state, 1374 - }; 1375 - }, 1376 - }, 1377 - ), 1378 - 1379 - createServerSettingsRecord: create.asyncThunk( 1380 - async ({ debugRecording }: { debugRecording: boolean }, thunkAPI) => { 1381 - const { bluesky, streamplace } = thunkAPI.getState() as { 1382 - bluesky: BlueskyState; 1383 - streamplace: StreamplaceState; 1384 - }; 1385 - if (!bluesky.pdsAgent) { 1386 - throw new Error("No agent"); 1387 - } 1388 - const did = bluesky.oauthSession?.did; 1389 - if (!did) { 1390 - throw new Error("No DID"); 1391 - } 1392 - const profile = bluesky.profiles[did]; 1393 - if (!profile) { 1394 - throw new Error("No profile"); 1395 - } 1396 - if (!did) { 1397 - throw new Error("No DID"); 1398 - } 1399 - const u = new URL(streamplace.url); 1400 - const serverSettings: PlaceStreamServerSettings.Record = { 1401 - $type: "place.stream.server.settings", 1402 - debugRecording: debugRecording, 1403 - }; 1404 - 1405 - const res = await bluesky.pdsAgent.com.atproto.repo.putRecord({ 1406 - repo: did, 1407 - collection: "place.stream.server.settings", 1408 - record: serverSettings, 1409 - rkey: u.host, 1410 - }); 1411 - if (!res.success) { 1412 - throw new Error("Failed to create server settings record"); 1413 - } 1414 - return serverSettings; 1415 - }, 1416 - { 1417 - pending: (state) => { 1418 - return { 1419 - ...state, 1420 - }; 1421 - }, 1422 - fulfilled: (state, action) => { 1423 - return { 1424 - ...state, 1425 - serverSettings: action.payload, 1426 - }; 1427 - }, 1428 - rejected: (state, action) => { 1429 - console.error("createServerSettingsRecord rejected", action.error); 1430 - return { 1431 - ...state, 1432 - }; 1433 - }, 1434 - }, 1435 - ), 1436 - }), 1437 - 1438 - // You can define your selectors here. These selectors receive the slice 1439 - // state as their first argument. 1440 - selectors: { 1441 - selectOAuthSession: (bluesky) => bluesky.oauthSession, 1442 - selectPDS: (bluesky) => bluesky.pds, 1443 - selectLogin: (bluesky) => bluesky.login, 1444 - selectProfiles: (bluesky) => bluesky.profiles, 1445 - selectStoredKey: (bluesky) => bluesky.storedKey, 1446 - selectKeyRecords: (bluesky) => bluesky.streamKeysResponse, 1447 - selectServerSettings: (bluesky) => bluesky.serverSettings, 1448 - selectUserProfile: (bluesky) => { 1449 - const did = bluesky.oauthSession?.did; 1450 - if (!did) return null; 1451 - return bluesky.profiles[did]; 1452 - }, 1453 - selectIsReady: (bluesky) => { 1454 - if (bluesky.status === "start") { 1455 - return false; 1456 - } else if (bluesky.status === "loggedOut") { 1457 - return true; 1458 - } 1459 - if (!bluesky.oauthSession) { 1460 - return false; 1461 - } 1462 - const profile = blueskySlice.selectors.selectUserProfile({ bluesky }); 1463 - if (!profile) { 1464 - return false; 1465 - } 1466 - 1467 - return true; 1468 - }, 1469 - selectNewLivestream: (bluesky) => bluesky.newLivestream, 1470 - selectChatProfile: (bluesky) => bluesky.chatProfile, 1471 - selectCachedProfiles: (bluesky) => bluesky.profileCache, 1472 - }, 1473 - }); 1474 - 1475 - // Action creators are generated for each case reducer function. 1476 - export const { 1477 - loadOAuthClient, 1478 - login, 1479 - getProfile, 1480 - getProfiles, 1481 - logout, 1482 - golivePost, 1483 - oauthCallback, 1484 - setPDS, 1485 - oauthError, 1486 - createStreamKeyRecord, 1487 - clearStreamKeyRecord, 1488 - getStreamKeyRecords, 1489 - deleteStreamKeyRecord, 1490 - createLivestreamRecord, 1491 - updateLivestreamRecord, 1492 - createChatProfileRecord, 1493 - getChatProfileRecordFromPDS, 1494 - createBlockRecord, 1495 - followUser, 1496 - unfollowUser, 1497 - getServerSettingsFromPDS, 1498 - createServerSettingsRecord, 1499 - } = blueskySlice.actions; 1500 - 1501 - // Selectors returned by `slice.selectors` take the root state as their first argument. 1502 - export const { 1503 - selectOAuthSession, 1504 - selectProfiles, 1505 - selectUserProfile, 1506 - selectPDS, 1507 - selectLogin, 1508 - selectStoredKey, 1509 - selectKeyRecords, 1510 - selectIsReady, 1511 - selectNewLivestream, 1512 - selectChatProfile, 1513 - selectCachedProfiles, 1514 - selectServerSettings, 1515 - } = blueskySlice.selectors;
-53
js/app/features/bluesky/blueskyTypes.tsx
··· 1 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 2 - import { OutputSchema } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords"; 3 - import { OAuthSession } from "@atproto/oauth-client"; 4 - import { StreamKey } from "features/base/baseSlice"; 5 - import { 6 - PlaceStreamChatProfile, 7 - PlaceStreamLivestream, 8 - PlaceStreamServerSettings, 9 - StreamplaceAgent, 10 - } from "streamplace"; 11 - import { StreamplaceOAuthClient } from "./oauthClient"; 12 - 13 - type NewLivestream = { 14 - loading: boolean; 15 - error: string | null; 16 - record: PlaceStreamLivestream.Record | null; 17 - }; 18 - 19 - export interface BlueskyState { 20 - status: "start" | "loggedIn" | "loggedOut"; 21 - oauthState: null | string; 22 - oauthSession?: null | OAuthSession; 23 - pdsAgent: null | StreamplaceAgent; 24 - anonPDSAgent: null | StreamplaceAgent; 25 - profiles: { [key: string]: ProfileViewDetailed }; 26 - // for e.g. others' avatars 27 - profileCache: { [key: string]: ProfileViewDetailed }; 28 - client: null | StreamplaceOAuthClient; 29 - login: { 30 - loading: boolean; 31 - error: null | string; 32 - }; 33 - pds: { 34 - url: string; 35 - loading: boolean; 36 - error: null | string; 37 - }; 38 - newKey: null | StreamKey; 39 - storedKey: null | StreamKey; 40 - isDeletingKey: boolean; 41 - streamKeysResponse: { 42 - loading: boolean; 43 - error: null | string; 44 - records: null | OutputSchema; 45 - }; 46 - newLivestream: null | NewLivestream; 47 - chatProfile: { 48 - loading: boolean; 49 - error: null | string; 50 - profile: null | PlaceStreamChatProfile.Record; 51 - }; 52 - serverSettings: null | PlaceStreamServerSettings.Record; 53 - }
-385
js/app/features/bluesky/contentMetadataSlice.tsx
··· 1 - import { createAppSlice } from "../../hooks/createSlice"; 2 - import { BlueskyState } from "./blueskyTypes"; 3 - 4 - export interface ContentMetadataState { 5 - creating: boolean; 6 - updating: boolean; 7 - error: string | null; 8 - lastCreatedRecord: any | null; 9 - } 10 - 11 - const initialState: ContentMetadataState = { 12 - creating: false, 13 - updating: false, 14 - error: null, 15 - lastCreatedRecord: null, 16 - }; 17 - 18 - export const contentMetadataSlice = createAppSlice({ 19 - name: "contentMetadata", 20 - initialState, 21 - reducers: (create) => ({ 22 - createContentMetadata: create.asyncThunk( 23 - async ( 24 - { 25 - contentWarnings = [], 26 - distributionPolicy = { 27 - deleteAfter: undefined, 28 - }, 29 - contentRights = {}, 30 - }: { 31 - contentWarnings?: string[]; 32 - distributionPolicy?: { 33 - deleteAfter?: number; 34 - }; 35 - contentRights?: { 36 - creator?: string; 37 - copyrightNotice?: string; 38 - copyrightYear?: number; 39 - license?: string; 40 - creditLine?: string; 41 - }; 42 - }, 43 - thunkAPI, 44 - ) => { 45 - const { bluesky } = thunkAPI.getState() as { 46 - bluesky: BlueskyState; 47 - }; 48 - 49 - if (!bluesky.pdsAgent) { 50 - throw new Error("No agent"); 51 - } 52 - 53 - const did = bluesky.oauthSession?.did; 54 - if (!did) { 55 - throw new Error("No DID"); 56 - } 57 - 58 - const metadataRecord = { 59 - $type: "place.stream.metadata.configuration", 60 - createdAt: new Date().toISOString(), 61 - ...(contentWarnings.length > 0 && { 62 - contentWarnings: { warnings: contentWarnings }, 63 - }), 64 - ...(distributionPolicy.deleteAfter && { distributionPolicy }), 65 - ...(contentRights && 66 - Object.keys(contentRights).length > 0 && { 67 - contentRights, 68 - }), 69 - }; 70 - 71 - const result = await bluesky.pdsAgent.com.atproto.repo.createRecord({ 72 - repo: did, 73 - collection: "place.stream.metadata.configuration", 74 - rkey: "self", 75 - record: metadataRecord, 76 - }); 77 - 78 - // Extract rkey from the URI 79 - const rkey = result.data.uri.split("/").pop(); 80 - 81 - return { 82 - record: metadataRecord, 83 - uri: result.data.uri, 84 - cid: result.data.cid, 85 - rkey, 86 - }; 87 - }, 88 - { 89 - pending: (state) => { 90 - return { 91 - ...state, 92 - creating: true, 93 - error: null, 94 - }; 95 - }, 96 - fulfilled: (state, action) => { 97 - return { 98 - ...state, 99 - creating: false, 100 - error: null, 101 - lastCreatedRecord: action.payload, 102 - }; 103 - }, 104 - rejected: (state, action) => { 105 - return { 106 - ...state, 107 - creating: false, 108 - error: action.error?.message ?? "Failed to create content metadata", 109 - }; 110 - }, 111 - }, 112 - ), 113 - 114 - updateContentMetadata: create.asyncThunk( 115 - async ( 116 - { 117 - rkey, 118 - livestreamRef, 119 - contentWarnings = [], 120 - distributionPolicy = { 121 - deleteAfter: undefined, // No expiration means forever 122 - }, 123 - contentRights = {}, 124 - }: { 125 - rkey?: string; 126 - livestreamRef?: { 127 - uri: string; 128 - cid: string; 129 - }; 130 - contentWarnings?: string[]; 131 - distributionPolicy?: { 132 - deleteAfter?: number; 133 - }; 134 - contentRights?: { 135 - creator?: string; 136 - copyrightNotice?: string; 137 - copyrightYear?: number; 138 - license?: string; 139 - creditLine?: string; 140 - }; 141 - }, 142 - thunkAPI, 143 - ) => { 144 - const { bluesky } = thunkAPI.getState() as { 145 - bluesky: BlueskyState; 146 - }; 147 - 148 - if (!bluesky.pdsAgent) { 149 - throw new Error("No agent"); 150 - } 151 - 152 - const did = bluesky.oauthSession?.did; 153 - if (!did) { 154 - throw new Error("No DID"); 155 - } 156 - 157 - const metadataRecord = { 158 - $type: "place.stream.metadata.configuration", 159 - ...(livestreamRef && { livestreamRef }), 160 - createdAt: new Date().toISOString(), 161 - ...(contentWarnings.length > 0 && { 162 - contentWarnings: { warnings: contentWarnings }, 163 - }), 164 - ...(distributionPolicy.deleteAfter && { distributionPolicy }), 165 - ...(contentRights && 166 - Object.keys(contentRights).length > 0 && { 167 - contentRights, 168 - }), 169 - }; 170 - 171 - const result = await bluesky.pdsAgent.com.atproto.repo.putRecord({ 172 - repo: did, 173 - collection: "place.stream.metadata.configuration", 174 - rkey: "self", 175 - record: metadataRecord, 176 - }); 177 - 178 - return { 179 - record: metadataRecord, 180 - uri: `at://${did}/place.stream.metadata.configuration/self`, 181 - cid: result.data.cid, 182 - }; 183 - }, 184 - { 185 - pending: (state) => { 186 - return { 187 - ...state, 188 - updating: true, 189 - error: null, 190 - }; 191 - }, 192 - fulfilled: (state, action) => { 193 - return { 194 - ...state, 195 - updating: false, 196 - error: null, 197 - lastCreatedRecord: action.payload, 198 - }; 199 - }, 200 - rejected: (state, action) => { 201 - return { 202 - ...state, 203 - updating: false, 204 - error: action.error?.message ?? "Failed to update content metadata", 205 - }; 206 - }, 207 - }, 208 - ), 209 - 210 - getContentMetadata: create.asyncThunk( 211 - async ( 212 - { userDid, rkey = "self" }: { userDid?: string; rkey?: string } = {}, 213 - thunkAPI, 214 - ) => { 215 - const { bluesky } = thunkAPI.getState() as { 216 - bluesky: BlueskyState; 217 - }; 218 - 219 - if (!bluesky.pdsAgent) { 220 - throw new Error("No agent"); 221 - } 222 - 223 - // Use provided userDid or fall back to current user's DID 224 - const targetDid = userDid || bluesky.oauthSession?.did; 225 - if (!targetDid) { 226 - throw new Error("No DID provided or user not authenticated"); 227 - } 228 - 229 - // Add debugging information 230 - console.log(`[getContentMetadata] Debug info:`, { 231 - targetDid, 232 - rkey, 233 - pdsAgentType: bluesky.pdsAgent.constructor.name, 234 - hasOAuthSession: !!bluesky.oauthSession, 235 - currentUserDid: bluesky.oauthSession?.did, 236 - pdsAgentHost: 237 - (bluesky.pdsAgent as any)?.host || 238 - (bluesky.pdsAgent as any)?.service?.host || 239 - "unknown", 240 - pdsAgentUrl: 241 - (bluesky.pdsAgent as any)?.url || 242 - (bluesky.pdsAgent as any)?.service?.url || 243 - "unknown", 244 - }); 245 - 246 - try { 247 - // First, try to resolve the correct PDS for the target user 248 - let targetPDS = null; 249 - try { 250 - const didResponse = await fetch( 251 - `https://plc.directory/${targetDid}`, 252 - ); 253 - if (didResponse.ok) { 254 - const didDoc = await didResponse.json(); 255 - const pdsService = didDoc.service?.find( 256 - (s: any) => s.id === "#atproto_pds", 257 - ); 258 - if (pdsService) { 259 - targetPDS = pdsService.serviceEndpoint; 260 - console.log( 261 - `[getContentMetadata] Resolved PDS for ${targetDid}:`, 262 - targetPDS, 263 - ); 264 - } 265 - } 266 - } catch (pdsResolveError) { 267 - console.log( 268 - `[getContentMetadata] Failed to resolve PDS for ${targetDid}:`, 269 - pdsResolveError, 270 - ); 271 - } 272 - 273 - // Use the target PDS if available, otherwise fall back to the current agent 274 - let agent = bluesky.pdsAgent; 275 - if (targetPDS && targetPDS !== (bluesky.pdsAgent as any)?.host) { 276 - // Create a new agent pointing to the target PDS 277 - const { StreamplaceAgent } = await import("streamplace"); 278 - agent = new StreamplaceAgent(targetPDS) as any; 279 - console.log( 280 - `[getContentMetadata] Created new agent for PDS:`, 281 - targetPDS, 282 - ); 283 - } 284 - 285 - console.log(`[getContentMetadata] Attempting to fetch record from:`, { 286 - repo: targetDid, 287 - collection: "place.stream.metadata.configuration", 288 - rkey, 289 - usingPDS: targetPDS || "default", 290 - }); 291 - 292 - const result = await agent.com.atproto.repo.getRecord({ 293 - repo: targetDid, 294 - collection: "place.stream.metadata.configuration", 295 - rkey, 296 - }); 297 - 298 - console.log(`[getContentMetadata] API response:`, result); 299 - 300 - if (!result.success) { 301 - throw new Error("Failed to get content metadata record"); 302 - } 303 - 304 - return { 305 - userDid: targetDid, 306 - record: result.data.value, 307 - uri: result.data.uri, 308 - cid: result.data.cid, 309 - }; 310 - } catch (error) { 311 - console.log(`[getContentMetadata] Error details:`, { 312 - error: error.message, 313 - errorType: error.constructor.name, 314 - errorStack: error.stack, 315 - }); 316 - 317 - // If user doesn't have metadata record, return null instead of throwing 318 - if ( 319 - error.message?.includes("not found") || 320 - error.message?.includes("RecordNotFound") 321 - ) { 322 - return { 323 - userDid: targetDid, 324 - record: null, 325 - uri: null, 326 - cid: null, 327 - }; 328 - } 329 - throw error; 330 - } 331 - }, 332 - { 333 - pending: (state) => { 334 - return { 335 - ...state, 336 - error: null, 337 - }; 338 - }, 339 - fulfilled: (state, action) => { 340 - return { 341 - ...state, 342 - error: null, 343 - lastCreatedRecord: action.payload, 344 - }; 345 - }, 346 - rejected: (state, action) => { 347 - return { 348 - ...state, 349 - error: action.error?.message ?? "Failed to get content metadata", 350 - }; 351 - }, 352 - }, 353 - ), 354 - 355 - clearError: create.reducer((state) => { 356 - return { 357 - ...state, 358 - error: null, 359 - }; 360 - }), 361 - }), 362 - 363 - selectors: { 364 - selectContentMetadata: (state) => state, 365 - selectIsCreating: (state) => state.creating, 366 - selectIsUpdating: (state) => state.updating, 367 - selectError: (state) => state.error, 368 - selectLastCreatedRecord: (state) => state.lastCreatedRecord, 369 - }, 370 - }); 371 - 372 - export const { 373 - createContentMetadata, 374 - updateContentMetadata, 375 - getContentMetadata, 376 - clearError, 377 - } = contentMetadataSlice.actions; 378 - 379 - export const { 380 - selectContentMetadata, 381 - selectIsCreating, 382 - selectIsUpdating, 383 - selectError, 384 - selectLastCreatedRecord, 385 - } = contentMetadataSlice.selectors;
-202
js/app/features/platform/platformSlice.native.tsx
··· 1 - import messaging from "@react-native-firebase/messaging"; 2 - import { openAuthSessionAsync } from "expo-web-browser"; 3 - import { oauthCallback } from "features/bluesky/blueskySlice"; 4 - import { BlueskyState } from "features/bluesky/blueskyTypes"; 5 - import { PermissionsAndroid, Platform } from "react-native"; 6 - import { createAppSlice } from "../../hooks/createSlice"; 7 - import { 8 - initialState, 9 - PlatformState, 10 - RegisterNotificationTokenBody, 11 - } from "./shared"; 12 - 13 - const checkApplicationPermission = async () => { 14 - if (Platform.OS === "android") { 15 - try { 16 - await PermissionsAndroid.request( 17 - PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, 18 - ); 19 - } catch (error) { 20 - console.log("error getting notifications ", error); 21 - } 22 - } 23 - }; 24 - 25 - export const platformSlice = createAppSlice({ 26 - name: "platform", 27 - initialState, 28 - reducers: (create) => ({ 29 - handleNotification: create.reducer( 30 - ( 31 - state, 32 - action: { payload: { [key: string]: string | object } | undefined }, 33 - ) => { 34 - if (!action.payload) { 35 - return state; 36 - } 37 - if (typeof action.payload.path !== "string") { 38 - return state; 39 - } 40 - return { 41 - ...state, 42 - notificationDestination: action.payload.path, 43 - }; 44 - }, 45 - ), 46 - clearNotification: create.reducer((state) => { 47 - return { 48 - ...state, 49 - notificationDestination: null, 50 - }; 51 - }), 52 - openLoginLink: create.asyncThunk( 53 - async (url: string, thunkAPI) => { 54 - const res = await openAuthSessionAsync(url); 55 - if (res.type === "success") { 56 - thunkAPI.dispatch(oauthCallback(res.url)); 57 - } 58 - }, 59 - { 60 - pending: (state) => { 61 - state.status = "loading"; 62 - }, 63 - fulfilled: (state) => { 64 - state.status = "idle"; 65 - }, 66 - rejected: (state, { error }) => { 67 - state.status = "failed"; 68 - console.error(error); 69 - }, 70 - }, 71 - ), 72 - 73 - initPushNotifications: create.asyncThunk( 74 - async (_, thunkAPI) => { 75 - const msg = messaging(); 76 - messaging().setBackgroundMessageHandler(async (remoteMessage) => { 77 - console.log("Message handled in the background!", remoteMessage); 78 - }); 79 - await checkApplicationPermission(); 80 - const authorizationStatus = await msg.requestPermission(); 81 - 82 - let perms = ""; 83 - 84 - if (authorizationStatus === messaging.AuthorizationStatus.AUTHORIZED) { 85 - console.log("User has notification permissions enabled."); 86 - perms += "authorized"; 87 - } else if ( 88 - authorizationStatus === messaging.AuthorizationStatus.PROVISIONAL 89 - ) { 90 - console.log("User has provisional notification permissions."); 91 - perms += "provisional"; 92 - } else { 93 - console.log("User has notification permissions disabled"); 94 - perms += "disabled"; 95 - } 96 - 97 - const token = await msg.getToken(); 98 - 99 - messaging() 100 - .subscribeToTopic("live") 101 - .then(() => console.log("Subscribed to live!")); 102 - 103 - messaging().onMessage((remoteMessage) => { 104 - console.log("Foreground message:", remoteMessage); 105 - // Display the notification to the user 106 - }); 107 - messaging().onNotificationOpenedApp((remoteMessage) => { 108 - console.log( 109 - "App opened by notification while in foreground:", 110 - remoteMessage, 111 - ); 112 - thunkAPI.dispatch(handleNotification(remoteMessage.data)); 113 - // Handle notification interaction when the app is in the foreground 114 - }); 115 - messaging() 116 - .getInitialNotification() 117 - .then((remoteMessage) => { 118 - if (!remoteMessage) { 119 - return; 120 - } 121 - console.log( 122 - "App opened by notification from closed state:", 123 - remoteMessage, 124 - ); 125 - thunkAPI.dispatch(handleNotification(remoteMessage.data)); 126 - }); 127 - 128 - return { token }; 129 - }, 130 - { 131 - pending: (state) => {}, 132 - fulfilled: (state, { payload }) => { 133 - return { 134 - ...state, 135 - notificationToken: payload.token, 136 - }; 137 - }, 138 - rejected: (state) => {}, 139 - }, 140 - ), 141 - 142 - registerNotificationToken: create.asyncThunk( 143 - async (_, thunkAPI) => { 144 - if (typeof process.env.EXPO_PUBLIC_STREAMPLACE_URL !== "string") { 145 - console.log("process.env.EXPO_PUBLIC_STREAMPLACE_URL undefined!"); 146 - return; 147 - } 148 - const { platform, bluesky } = thunkAPI.getState() as { 149 - platform: PlatformState; 150 - bluesky: BlueskyState; 151 - }; 152 - if (!platform.notificationToken) { 153 - throw new Error("No notification token"); 154 - } 155 - const body: RegisterNotificationTokenBody = { 156 - token: platform.notificationToken, 157 - }; 158 - const did = bluesky.oauthSession?.did; 159 - if (did) { 160 - body.repoDID = did; 161 - } 162 - try { 163 - const res = await fetch( 164 - `${process.env.EXPO_PUBLIC_STREAMPLACE_URL}/api/notification`, 165 - { 166 - method: "POST", 167 - headers: { 168 - "content-type": "application/json", 169 - }, 170 - body: JSON.stringify(body), 171 - }, 172 - ); 173 - console.log({ status: res.status }); 174 - } catch (e) { 175 - console.log(e); 176 - } 177 - }, 178 - { 179 - pending: (state) => {}, 180 - fulfilled: (state) => {}, 181 - rejected: (state) => {}, 182 - }, 183 - ), 184 - }), 185 - 186 - selectors: { 187 - selectNotificationToken: (platform) => platform.notificationToken, 188 - selectNotificationDestination: (platform) => 189 - platform.notificationDestination, 190 - }, 191 - }); 192 - 193 - export const { 194 - openLoginLink, 195 - initPushNotifications, 196 - registerNotificationToken, 197 - handleNotification, 198 - clearNotification, 199 - } = platformSlice.actions; 200 - 201 - export const { selectNotificationToken, selectNotificationDestination } = 202 - platformSlice.selectors;
-66
js/app/features/platform/platformSlice.tsx
··· 1 - import { createAppSlice } from "../../hooks/createSlice"; 2 - import { initialState } from "./shared"; 3 - 4 - export const platformSlice = createAppSlice({ 5 - name: "platform", 6 - initialState, 7 - reducers: (create) => ({ 8 - handleNotification: create.reducer( 9 - ( 10 - state, 11 - action: { payload: { [key: string]: string | object } | undefined }, 12 - ) => { 13 - return state; 14 - }, 15 - ), 16 - clearNotification: create.reducer((state) => { 17 - return { 18 - ...state, 19 - notificationDestination: null, 20 - }; 21 - }), 22 - openLoginLink: create.asyncThunk( 23 - async (url: string) => { 24 - window.location.href = url; 25 - }, 26 - { 27 - pending: (state) => { 28 - state.status = "loading"; 29 - }, 30 - fulfilled: (state) => { 31 - state.status = "idle"; 32 - }, 33 - rejected: (state) => { 34 - state.status = "failed"; 35 - }, 36 - }, 37 - ), 38 - 39 - initPushNotifications: create.asyncThunk( 40 - async () => { 41 - // someday we'll do web notifications but for now it's mobile-only 42 - }, 43 - { 44 - pending: (state) => {}, 45 - fulfilled: (state) => {}, 46 - rejected: (state) => {}, 47 - }, 48 - ), 49 - 50 - registerNotificationToken: create.asyncThunk(async () => {}, { 51 - pending: (state) => {}, 52 - fulfilled: (state) => {}, 53 - rejected: (state) => {}, 54 - }), 55 - }), 56 - 57 - selectors: { 58 - selectNotificationToken: (platform) => platform.notificationToken, 59 - selectNotificationDestination: (platform) => 60 - platform.notificationDestination, 61 - }, 62 - }); 63 - 64 - export const { openLoginLink, clearNotification } = platformSlice.actions; 65 - export const { selectNotificationToken, selectNotificationDestination } = 66 - platformSlice.selectors;
+10 -11
js/app/features/streamplace/streamplaceProvider.tsx
··· 2 2 import Loading from "components/loading/loading"; 3 3 import { createContext, useEffect } from "react"; 4 4 import { View } from "react-native"; 5 - import { useAppDispatch, useAppSelector } from "store/hooks"; 6 - import { 7 - DEFAULT_URL, 8 - initialize, 9 - selectInitialized, 10 - selectUrl, 11 - } from "./streamplaceSlice"; 5 + import { useStore } from "store"; 6 + import { useStreamplaceInitialized, useStreamplaceUrl } from "store/hooks"; 7 + import { DEFAULT_URL } from "store/slices/streamplaceSlice"; 12 8 13 9 export const StreamplaceContext = createContext({ 14 10 url: DEFAULT_URL, ··· 19 15 }: { 20 16 children: React.ReactNode; 21 17 }): React.ReactElement { 22 - const url = useAppSelector(selectUrl); 23 - const initialized = useAppSelector(selectInitialized); 24 - const dispatch = useAppDispatch(); 18 + const url = useStreamplaceUrl(); 19 + const initialized = useStreamplaceInitialized(); 20 + const initialize = useStore((state) => state.initialize); 21 + 25 22 useEffect(() => { 26 23 if (!initialized) { 27 - dispatch(initialize()); 24 + initialize(); 28 25 } 29 26 }, [initialized]); 27 + 30 28 if (!initialized) { 31 29 return ( 32 30 <View style={[{ flex: 1 }]}> ··· 35 33 </View> 36 34 ); 37 35 } 36 + 38 37 return ( 39 38 <StreamplaceContext.Provider value={{ url: url }}> 40 39 {children}
-249
js/app/features/streamplace/streamplaceSlice.tsx
··· 1 - import { storage } from "@streamplace/components"; 2 - import { BlueskyState } from "features/bluesky/blueskyTypes"; 3 - import { Platform } from "react-native"; 4 - import { PlaceStreamLivestream, PlaceStreamSegment } from "streamplace"; 5 - import { createAppSlice } from "../../hooks/createSlice"; 6 - 7 - let DEFAULT_URL = process.env.EXPO_PUBLIC_STREAMPLACE_URL as string; 8 - if (Platform.OS === "web" && process.env.EXPO_PUBLIC_WEB_TRY_LOCAL === "true") { 9 - try { 10 - DEFAULT_URL = `${window.location.protocol}//${window.location.host}`; 11 - } catch (err) { 12 - // Oh well, fall back to hardcoded. 13 - } 14 - } 15 - export { DEFAULT_URL }; 16 - 17 - export type Segment = { 18 - id: string; 19 - repoDID: string; 20 - signingKeyDID: string; 21 - startTime: string; 22 - repo: Repo; 23 - viewers: number; 24 - }; 25 - 26 - export type Repo = { 27 - did: string; 28 - handle: string; 29 - pds: string; 30 - version: string; 31 - rootCid: string; 32 - }; 33 - 34 - export interface Identity { 35 - id: string; 36 - handle?: string; 37 - did?: string; 38 - } 39 - 40 - export interface StreamplaceState { 41 - url: string; 42 - identity: Identity | null; 43 - initialized: boolean; 44 - recentSegments: { 45 - segments: PlaceStreamLivestream.LivestreamView[]; 46 - error: string | null; 47 - loading: boolean; 48 - firstRequest: boolean; 49 - }; 50 - mySegments: PlaceStreamSegment.SegmentView[]; 51 - userMuted: boolean | null; 52 - chatWarned: boolean; 53 - } 54 - 55 - const initialState: StreamplaceState = { 56 - url: DEFAULT_URL, 57 - identity: null, 58 - initialized: false, 59 - recentSegments: { 60 - segments: [], 61 - error: null, 62 - loading: false, 63 - firstRequest: true, 64 - }, 65 - mySegments: [], 66 - userMuted: null, 67 - chatWarned: false, 68 - }; 69 - 70 - const USER_MUTED_KEY = "streamplaceUserMuted"; 71 - const URL_KEY = "streamplaceUrl"; 72 - const CHAT_WARNING_KEY = "streamplaceChatWarning2"; 73 - 74 - export const streamplaceSlice = createAppSlice({ 75 - name: "streamplace", 76 - initialState, 77 - reducers: (create) => ({ 78 - initialize: create.asyncThunk( 79 - async (_, { getState }) => { 80 - let [url, userMutedStr, chatWarningStr] = await Promise.all([ 81 - storage.getItem(URL_KEY), 82 - storage.getItem(USER_MUTED_KEY), 83 - storage.getItem(CHAT_WARNING_KEY), 84 - ]); 85 - if (!url) { 86 - url = DEFAULT_URL; 87 - } 88 - let userMuted: boolean | null = null; 89 - console.log("userMutedStr", userMutedStr); 90 - if (typeof userMutedStr === "string") { 91 - userMuted = userMutedStr === "true"; 92 - } else { 93 - userMuted = null; 94 - } 95 - let chatWarned: boolean = false; 96 - if (typeof chatWarningStr === "string") { 97 - chatWarned = chatWarningStr === "true"; 98 - } 99 - return { url, userMuted, chatWarned }; 100 - }, 101 - { 102 - pending: (state) => { 103 - // state.status = "loading"; 104 - }, 105 - fulfilled: (state, action) => { 106 - const { url, userMuted, chatWarned } = action.payload; 107 - return { 108 - ...state, 109 - url, 110 - userMuted, 111 - initialized: true, 112 - chatWarned, 113 - }; 114 - }, 115 - rejected: (_, { error }) => { 116 - // state.status = "failed"; 117 - }, 118 - }, 119 - ), 120 - 121 - setURL: create.reducer((state, action: { payload: string }) => { 122 - console.log("setURL", action); 123 - storage.setItem(URL_KEY, action.payload).catch((err) => { 124 - console.error("setURL error", err); 125 - }); 126 - return { 127 - ...state, 128 - url: action.payload, 129 - }; 130 - }), 131 - 132 - userMute: create.reducer((state, action: { payload: boolean }) => { 133 - storage 134 - .setItem(USER_MUTED_KEY, JSON.stringify(action.payload)) 135 - .catch((err) => { 136 - console.error("userMute error", err); 137 - }); 138 - return { 139 - ...state, 140 - userMuted: action.payload, 141 - }; 142 - }), 143 - 144 - chatWarn: create.reducer((state, action: { payload: boolean }) => { 145 - storage 146 - .setItem(CHAT_WARNING_KEY, JSON.stringify(action.payload)) 147 - .catch((err) => { 148 - console.error("chatWarn error", err); 149 - }); 150 - return { 151 - ...state, 152 - chatWarned: action.payload, 153 - }; 154 - }), 155 - 156 - getIdentity: create.asyncThunk( 157 - async (_, { getState }) => { 158 - const { streamplace } = getState() as { 159 - streamplace: StreamplaceState; 160 - }; 161 - const res = await fetch(`${streamplace.url}/api/identity`); 162 - return await res.json(); 163 - }, 164 - { 165 - pending: (state) => { 166 - // state.status = "loading"; 167 - }, 168 - fulfilled: (state, action) => { 169 - return { 170 - ...state, 171 - identity: action.payload, 172 - }; 173 - }, 174 - rejected: (state) => { 175 - console.error("loadOAuthClient rejected"); 176 - // state.status = "failed"; 177 - }, 178 - }, 179 - ), 180 - 181 - pollMySegments: create.asyncThunk( 182 - async (_, { getState, dispatch }) => { 183 - const { streamplace } = getState() as { 184 - streamplace: StreamplaceState; 185 - }; 186 - const { bluesky } = getState() as { 187 - bluesky: BlueskyState; 188 - }; 189 - 190 - if (!bluesky.pdsAgent) { 191 - throw new Error("no pdsAgent"); 192 - } 193 - if (!bluesky.oauthSession) { 194 - throw new Error("no oauthSession"); 195 - } 196 - return await bluesky.pdsAgent.place.stream.live.getSegments({ 197 - userDID: bluesky.oauthSession?.did ?? "", 198 - }); 199 - }, 200 - { 201 - pending: (state) => { 202 - return { 203 - ...state, 204 - }; 205 - }, 206 - fulfilled: (state, action) => { 207 - return { 208 - ...state, 209 - mySegments: action.payload.data.segments ?? [], 210 - }; 211 - }, 212 - rejected: (state, err) => { 213 - // console.error("pollMySegments rejected", err); 214 - return { 215 - ...state, 216 - }; 217 - }, 218 - }, 219 - ), 220 - }), 221 - 222 - selectors: { 223 - selectStreamplace: (streamplace) => streamplace, 224 - selectUrl: (streamplace) => streamplace.url, 225 - selectInitialized: (streamplace) => streamplace.initialized, 226 - selectRecentSegments: (streamplace) => streamplace.recentSegments, 227 - selectMySegments: (streamplace) => streamplace.mySegments, 228 - selectUserMuted: (streamplace) => streamplace.userMuted, 229 - selectChatWarned: (streamplace) => streamplace.chatWarned, 230 - }, 231 - }); 232 - 233 - // Action creators are generated for each case reducer function. 234 - export const { 235 - getIdentity, 236 - setURL, 237 - initialize, 238 - pollMySegments, 239 - userMute, 240 - chatWarn, 241 - } = streamplaceSlice.actions; 242 - export const { 243 - selectStreamplace, 244 - selectMySegments, 245 - selectUserMuted, 246 - selectChatWarned, 247 - selectUrl, 248 - selectInitialized, 249 - } = streamplaceSlice.selectors;
-6
js/app/hooks/createSlice.tsx
··· 1 - import { asyncThunkCreator, buildCreateSlice } from "@reduxjs/toolkit"; 2 - 3 - // `buildCreateSlice` allows us to create a slice with async thunks. 4 - export const createAppSlice = buildCreateSlice({ 5 - creators: { asyncThunk: asyncThunkCreator }, 6 - });
+6 -10
js/app/hooks/useAvatars.tsx
··· 1 1 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 2 - import { 3 - getProfiles, 4 - selectCachedProfiles, 5 - } from "features/bluesky/blueskySlice"; 6 2 import { useEffect, useMemo } from "react"; 7 - import { useAppDispatch, useAppSelector } from "store/hooks"; 3 + import { useStore } from "store"; 4 + import { useCachedProfiles } from "store/hooks"; 8 5 9 6 // Hack: Easy way to cache and get avatars 10 7 export default function useAvatars(dids: string[]) { 11 - const dispatch = useAppDispatch(); 12 - const profiles: Record<string, ProfileViewDetailed> = 13 - useAppSelector(selectCachedProfiles); 8 + const getProfiles = useStore((state) => state.getProfiles); 9 + const profiles: Record<string, ProfileViewDetailed> = useCachedProfiles(); 14 10 15 11 const missingDids = useMemo( 16 12 () => dids.filter((did) => !(did in profiles)), ··· 20 16 useEffect(() => { 21 17 if (missingDids.length > 0) { 22 18 console.log("Fetching profiles for DIDs:", missingDids); 23 - dispatch(getProfiles(missingDids)).then((e) => console.log("ok", e)); 19 + getProfiles(missingDids).then((e) => console.log("ok", e)); 24 20 } 25 - }, [missingDids, dispatch]); 21 + }, [missingDids]); 26 22 27 23 return profiles; 28 24 }
+2 -3
js/app/hooks/useLiveUser.tsx
··· 1 - import { selectMySegments } from "features/streamplace/streamplaceSlice"; 2 - import { useAppSelector } from "store/hooks"; 1 + import { useStore } from "store"; 3 2 import { PlaceStreamSegment } from "streamplace"; 4 3 5 4 // composite selector that tells us when the current user is live 6 5 export const useLiveUser = (): boolean => { 7 - const mySegments = useAppSelector(selectMySegments); 6 + const mySegments = useStore((state) => state.mySegments); 8 7 if (mySegments.length === 0) { 9 8 return false; 10 9 }
+10 -19
js/app/hooks/useSidebarControl.tsx
··· 5 5 useSharedValue, 6 6 withTiming, 7 7 } from "react-native-reanimated"; 8 - import { useDispatch, useSelector } from "react-redux"; 9 - 8 + import { useStore } from "store"; 10 9 import { 11 - selectIsSidebarCollapsed, 12 - selectIsSidebarHidden, 13 - selectSidebarTargetWidth, 14 - toggleSidebar, 15 - } from "../features/base/sidebarSlice"; 16 - import { RootState } from "../store/store"; 10 + useIsSidebarCollapsed, 11 + useIsSidebarHidden, 12 + useSidebarTargetWidth, 13 + } from "store/hooks"; 17 14 18 15 // Returns *true* if the screen is > 1024px 19 16 function useIsLargeScreen() { ··· 41 38 * - toggle: () => void - A function to dispatch the Redux action to toggle the sidebar. 42 39 */ 43 40 export function useSidebarControl(): UseSidebarOutput { 44 - const dispatch = useDispatch(); 45 - const isCollapsed = useSelector((state: RootState) => 46 - selectIsSidebarCollapsed(state), 47 - ); 48 - const targetWidth = useSelector((state: RootState) => 49 - selectSidebarTargetWidth(state), 50 - ); 41 + const toggleSidebar = useStore((state) => state.toggleSidebar); 42 + const isCollapsed = useIsSidebarCollapsed(); 43 + const targetWidth = useSidebarTargetWidth(); 51 44 52 - const isHidden = useSelector((state: RootState) => 53 - selectIsSidebarHidden(state), 54 - ); 45 + const isHidden = useIsSidebarHidden(); 55 46 56 47 const animatedWidth = useSharedValue(targetWidth); 57 48 ··· 69 60 const handleToggle = () => { 70 61 if (isActive) { 71 62 // Only allow toggle if the sidebar functionality is active 72 - dispatch(toggleSidebar()); 63 + toggleSidebar(); 73 64 } 74 65 }; 75 66
+2 -3
js/app/package.json
··· 46 46 "@react-navigation/drawer": "^6.7.2", 47 47 "@react-navigation/native": "^6.1.18", 48 48 "@react-navigation/native-stack": "^6.11.0", 49 - "@reduxjs/toolkit": "^2.3.0", 50 49 "@sentry/react-native": "^6.14.0", 51 50 "@streamplace/atproto-oauth-client-react-native": "workspace:*", 52 51 "@streamplace/components": "workspace:*", ··· 105 104 "react-native-web": "^0.20.0", 106 105 "react-native-webrtc": "git+https://github.com/streamplace/react-native-webrtc.git#6b8472a771ac47f89217d327058a8a4124a6ae56", 107 106 "react-native-webview": "13.15.0", 108 - "react-redux": "^9.1.2", 109 107 "react-use-websocket": "^4.13.0", 110 108 "reanimated-color-picker": "^4.0.0", 111 109 "rtcaudiodevice": "git+https://github.com/streamplace/RTCAudioDevice.git#918e08a0f6f0818fb495a0db0b696b44d11d1336", ··· 114 112 "streamplace": "workspace:*", 115 113 "ua-parser-js": "^2.0.0-rc.1", 116 114 "uuid": "^11.0.2", 117 - "viem": "^2.21.44" 115 + "viem": "^2.21.44", 116 + "zustand": "^5.0.5" 118 117 }, 119 118 "devDependencies": { 120 119 "@babel/core": "^7.26.0",
+31 -26
js/app/src/router.tsx
··· 22 22 import { DeveloperSettings } from "components/settings/developer"; 23 23 import Sidebar, { ExternalDrawerItem } from "components/sidebar/sidebar"; 24 24 import * as ExpoLinking from "expo-linking"; 25 - import { hydrate, selectHydrated } from "features/base/baseSlice"; 26 - import { selectUserProfile } from "features/bluesky/blueskySlice"; 27 - import { 28 - clearNotification, 29 - initPushNotifications, 30 - registerNotificationToken, 31 - selectNotificationDestination, 32 - selectNotificationToken, 33 - } from "features/platform/platformSlice.native"; 34 - import { pollMySegments } from "features/streamplace/streamplaceSlice"; 35 25 import { useLiveUser } from "hooks/useLiveUser"; 36 26 import usePlatform from "hooks/usePlatform"; 37 27 import { useSidebarControl } from "hooks/useSidebarControl"; ··· 60 50 StatusBar, 61 51 View, 62 52 } from "react-native"; 63 - import { useAppDispatch, useAppSelector } from "store/hooks"; 53 + import { useStore } from "store"; 54 + import { 55 + useHydrated, 56 + useNotificationDestination, 57 + useNotificationToken, 58 + useUserProfile, 59 + } from "store/hooks"; 64 60 import AboutScreen from "./screens/about"; 65 61 import AppReturnScreen from "./screens/app-return"; 66 62 import PopoutChat from "./screens/chat-popout"; ··· 72 68 import SupportScreen from "./screens/support"; 73 69 74 70 import KeyManager from "components/settings/key-manager"; 75 - import { loadStateFromStorage } from "features/base/sidebarSlice"; 76 - import { store } from "store/store"; 77 71 import HomeScreen from "./screens/home"; 78 72 79 73 import { useUrl } from "@streamplace/components"; ··· 87 81 import DanmuOBSScreen from "./screens/danmu-obs"; 88 82 import MobileGoLive from "./screens/mobile-go-live"; 89 83 import MobileStream from "./screens/mobile-stream"; 90 - store.dispatch(loadStateFromStorage()); 84 + 85 + // Initialize sidebar state on app load 86 + useStore.getState().loadStateFromStorage(); 91 87 92 88 const Stack = createNativeStackNavigator(); 93 89 ··· 243 239 }; 244 240 245 241 const AvatarButton = () => { 246 - const userProfile = useAppSelector(selectUserProfile); 242 + const userProfile = useUserProfile(); 247 243 let source: ImageSourcePropType | undefined = undefined; 248 244 let opacity = 1; 249 245 if (userProfile) { ··· 344 340 const theme = useTheme(); 345 341 const { isWeb, isElectron, isNative, isBrowser } = usePlatform(); 346 342 const navigation = useNavigation(); 347 - const dispatch = useAppDispatch(); 348 343 const [livePopup, setLivePopup] = useState(false); 349 344 350 345 const sidebar = useSidebarControl(); ··· 354 349 SystemBars.setStyle("dark"); 355 350 356 351 // Top-level stuff to handle push notification registration 352 + const hydrate = useStore((state) => state.hydrate); 353 + const initPushNotifications = useStore( 354 + (state) => state.initPushNotifications, 355 + ); 356 + const registerNotificationToken = useStore( 357 + (state) => state.registerNotificationToken, 358 + ); 359 + 357 360 useEffect(() => { 358 - dispatch(hydrate()); 359 - dispatch(initPushNotifications()); 361 + hydrate(); 362 + initPushNotifications(); 360 363 }, []); 361 - const notificationToken = useAppSelector(selectNotificationToken); 362 - const userProfile = useAppSelector(selectUserProfile); 363 - const hydrated = useAppSelector(selectHydrated); 364 + const notificationToken = useNotificationToken(); 365 + const userProfile = useUserProfile(); 366 + const hydrated = useHydrated(); 364 367 useEffect(() => { 365 368 if (notificationToken) { 366 - dispatch(registerNotificationToken()); 369 + registerNotificationToken(); 367 370 } 368 371 }, [notificationToken, userProfile]); 369 372 370 373 // Stuff to handle incoming push notification routing 371 - const notificationDestination = useAppSelector(selectNotificationDestination); 374 + const notificationDestination = useNotificationDestination(); 372 375 const linkTo = useLinkTo(); 376 + const clearNotification = useStore((state) => state.clearNotification); 373 377 374 378 const animatedDrawerStyle = useAnimatedStyle(() => { 375 379 return { ··· 380 384 useEffect(() => { 381 385 if (notificationDestination) { 382 386 linkTo(notificationDestination); 383 - dispatch(clearNotification()); 387 + clearNotification(); 384 388 } 385 389 }, [notificationDestination]); 386 390 387 391 // Top-level stuff to handle polling for live streamers 392 + const pollMySegments = useStore((state) => state.pollMySegments); 388 393 useEffect(() => { 389 394 let handle: NodeJS.Timeout; 390 395 handle = setInterval(() => { 391 - dispatch(pollMySegments()); 396 + pollMySegments(); 392 397 }, 2500); 393 - dispatch(pollMySegments()); 398 + pollMySegments(); 394 399 return () => clearInterval(handle); 395 400 }, []); 396 401
+2 -3
js/app/src/screens/chat-popout.native.tsx
··· 13 13 } from "@streamplace/components"; 14 14 import emojiData from "assets/emoji-data.json"; 15 15 import LiveDot from "components/home/live-dot"; 16 - import { selectUserProfile } from "features/bluesky/blueskySlice"; 17 16 import { ArrowLeft } from "lucide-react-native"; 18 17 import { useCallback, useEffect, useRef } from "react"; 19 18 import { KeyboardAvoidingView, Pressable, View } from "react-native"; 20 19 import { useSafeAreaInsets } from "react-native-safe-area-context"; 21 - import { useAppSelector } from "store/hooks"; 20 + import { useUserProfile } from "store/hooks"; 22 21 23 22 export default function PopoutChat({ route }) { 24 23 const user = route.params?.user; ··· 47 46 48 47 export function PopoutChatInner({ user }: { user: string }) { 49 48 const setSrc = usePlayerStore((x) => x.setSrc); 50 - const profile = useAppSelector(selectUserProfile); 49 + const profile = useUserProfile(); 51 50 const navigation = useNavigation(); 52 51 const { ingest, profile: streamProfile } = useLivestreamInfo(); 53 52 const status = usePlayerStore((x) => x.status);
+2 -3
js/app/src/screens/chat-popout.tsx
··· 7 7 zero, 8 8 } from "@streamplace/components"; 9 9 import emojiData from "assets/emoji-data.json"; 10 - import { selectUserProfile } from "features/bluesky/blueskySlice"; 11 10 import { useEffect } from "react"; 12 11 import { View } from "react-native"; 13 - import { useAppSelector } from "store/hooks"; 12 + import { useUserProfile } from "store/hooks"; 14 13 15 14 export default function PopoutChat({ route }) { 16 15 const user = route.params?.user; ··· 29 28 30 29 export function PopoutChatInner({ user }: { user: string }) { 31 30 const setSrc = usePlayerStore((x) => x.setSrc); 32 - const profile = useAppSelector(selectUserProfile); 31 + const profile = useUserProfile(); 33 32 useEffect(() => { 34 33 setSrc(user); 35 34 }, [user]);
+5 -8
js/app/src/screens/embed.tsx
··· 5 5 PlayerProvider, 6 6 } from "@streamplace/components"; 7 7 import { DesktopUi } from "components/mobile/desktop-ui"; 8 - import { 9 - setSidebarHidden, 10 - setSidebarUnhidden, 11 - } from "features/base/sidebarSlice"; 12 8 import { useEffect } from "react"; 13 9 import { Platform } from "react-native"; 14 - import { useAppDispatch } from "store/hooks"; 10 + import { useStore } from "store"; 15 11 import { queryToProps } from "./util"; 16 12 17 13 const isWeb = Platform.OS === "web"; ··· 19 15 export default function EmbedScreen({ route }) { 20 16 const { user, protocol, url } = route.params; 21 17 let extraProps: Partial<PlayerProps> = {}; 22 - const dispatch = useAppDispatch(); 18 + const setSidebarHidden = useStore((state) => state.setSidebarHidden); 19 + const setSidebarUnhidden = useStore((state) => state.setSidebarUnhidden); 23 20 useEffect(() => { 24 - dispatch(setSidebarHidden()); 21 + setSidebarHidden(); 25 22 () => { 26 23 // on unmount, unhide the sidebar 27 - dispatch(setSidebarUnhidden()); 24 + setSidebarUnhidden(); 28 25 }; 29 26 }, []); 30 27 if (isWeb) {
+3 -7
js/app/src/screens/live-dashboard.tsx
··· 7 7 import BentoGrid from "components/live-dashboard/bento-grid"; 8 8 import Loading from "components/loading/loading"; 9 9 import { VideoElementProvider } from "contexts/VideoElementContext"; 10 - import { 11 - selectIsReady, 12 - selectUserProfile, 13 - } from "features/bluesky/blueskySlice"; 14 10 import { useLiveUser } from "hooks/useLiveUser"; 15 11 import { useCallback, useState } from "react"; 16 12 import { View } from "react-native"; 17 - import { useAppSelector } from "store/hooks"; 13 + import { useIsReady, useUserProfile } from "store/hooks"; 18 14 19 15 const { flex, bg } = zero; 20 16 21 17 export default function LiveDashboard() { 22 - const isReady = useAppSelector(selectIsReady); 23 - const userProfile = useAppSelector(selectUserProfile); 18 + const isReady = useIsReady(); 19 + const userProfile = useUserProfile(); 24 20 const isLive = useLiveUser(); 25 21 const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( 26 22 null,
+2 -3
js/app/src/screens/mobile-go-live.tsx
··· 2 2 import { Redirect } from "components/aqlink"; 3 3 import { Player } from "components/mobile/player"; 4 4 import { FullscreenProvider } from "contexts/FullscreenContext"; 5 - import { selectUserProfile } from "features/bluesky/blueskySlice"; 6 - import { useAppSelector } from "store/hooks"; 5 + import { useUserProfile } from "store/hooks"; 7 6 8 7 export default function MobileGoLive() { 9 - const userProfile = useAppSelector(selectUserProfile); 8 + const userProfile = useUserProfile(); 10 9 11 10 if (!userProfile) { 12 11 return <Redirect to={{ screen: "Login" }} />;
+79
js/app/store/hooks.ts
··· 1 + import { useStore } from "./index"; 2 + 3 + // Base selectors 4 + export const useHydrated = () => useStore((state) => state.hydrated); 5 + 6 + // Sidebar selectors 7 + export const useIsSidebarCollapsed = () => 8 + useStore((state) => state.isCollapsed); 9 + export const useSidebarTargetWidth = () => 10 + useStore((state) => state.targetWidth); 11 + export const useIsSidebarLoaded = () => useStore((state) => state.isLoaded); 12 + export const useIsSidebarHidden = () => useStore((state) => state.isHidden); 13 + 14 + // Bluesky selectors 15 + export const useOAuthSession = () => useStore((state) => state.oauthSession); 16 + export const usePDS = () => useStore((state) => state.pds); 17 + export const useLogin = () => useStore((state) => state.loginState); 18 + export const useProfiles = () => useStore((state) => state.profiles); 19 + export const useStoredKey = () => useStore((state) => state.storedKey); 20 + export const useKeyRecords = () => 21 + useStore((state) => state.streamKeysResponse); 22 + export const useServerSettings = () => 23 + useStore((state) => state.serverSettings); 24 + export const useUserProfile = () => { 25 + const oauthSession = useOAuthSession(); 26 + const profiles = useProfiles(); 27 + const did = oauthSession?.did; 28 + if (!did) return null; 29 + return profiles[did]; 30 + }; 31 + export const useIsReady = () => { 32 + const authStatus = useStore((state) => state.authStatus); 33 + const oauthSession = useOAuthSession(); 34 + const profile = useUserProfile(); 35 + 36 + if (authStatus === "start") { 37 + return false; 38 + } else if (authStatus === "loggedOut") { 39 + return true; 40 + } 41 + if (!oauthSession) { 42 + return false; 43 + } 44 + if (!profile) { 45 + return false; 46 + } 47 + return true; 48 + }; 49 + export const useNewLivestream = () => useStore((state) => state.newLivestream); 50 + export const useChatProfile = () => useStore((state) => state.chatProfile); 51 + export const useCachedProfiles = () => useStore((state) => state.profileCache); 52 + 53 + // ContentMetadata selectors 54 + export const useContentMetadata = () => 55 + useStore((state) => ({ 56 + creating: state.creating, 57 + updating: state.updating, 58 + error: state.error, 59 + lastCreatedRecord: state.lastCreatedRecord, 60 + })); 61 + export const useIsCreating = () => useStore((state) => state.creating); 62 + export const useIsUpdating = () => useStore((state) => state.updating); 63 + export const useContentMetadataError = () => useStore((state) => state.error); 64 + export const useLastCreatedRecord = () => 65 + useStore((state) => state.lastCreatedRecord); 66 + 67 + // Streamplace selectors 68 + export const useStreamplaceUrl = () => useStore((state) => state.url); 69 + export const useStreamplaceInitialized = () => 70 + useStore((state) => state.initialized); 71 + export const useUserMuted = () => useStore((state) => state.userMuted); 72 + export const useChatWarned = () => useStore((state) => state.chatWarned); 73 + export const useMySegments = () => useStore((state) => state.mySegments); 74 + 75 + // Platform selectors 76 + export const useNotificationToken = () => 77 + useStore((state) => state.notificationToken); 78 + export const useNotificationDestination = () => 79 + useStore((state) => state.notificationDestination);
-12
js/app/store/hooks.tsx
··· 1 - // This file serves as a central hub for re-exporting pre-typed Redux hooks. 2 - // These imports are restricted elsewhere to ensure consistent 3 - // usage of typed hooks throughout the application. 4 - // We disable the ESLint rule here because this is the designated place 5 - // for importing and re-exporting the typed versions of hooks. 6 - /* eslint-disable @typescript-eslint/no-restricted-imports */ 7 - import { useDispatch, useSelector } from "react-redux"; 8 - import type { AppDispatch, RootState } from "./store"; 9 - 10 - // Use throughout your app instead of plain `useDispatch` and `useSelector` 11 - export const useAppDispatch = useDispatch.withTypes<AppDispatch>(); 12 - export const useAppSelector = useSelector.withTypes<RootState>();
+39
js/app/store/index.ts
··· 1 + import { create } from "zustand"; 2 + import { BaseSlice, createBaseSlice } from "./slices/baseSlice"; 3 + import { BlueskySlice, createBlueskySlice } from "./slices/blueskySlice"; 4 + import { 5 + ContentMetadataSlice, 6 + createContentMetadataSlice, 7 + } from "./slices/contentMetadataSlice"; 8 + import { PlatformSlice, createPlatformSlice } from "./slices/platformSlice"; 9 + import { SidebarSlice, createSidebarSlice } from "./slices/sidebarSlice"; 10 + import { 11 + StreamplaceSlice, 12 + createStreamplaceSlice, 13 + } from "./slices/streamplaceSlice"; 14 + 15 + // Combined store type 16 + export type AppStore = BaseSlice & 17 + SidebarSlice & 18 + BlueskySlice & 19 + ContentMetadataSlice & 20 + StreamplaceSlice & 21 + PlatformSlice; 22 + 23 + // Create the combined store 24 + export const useStore = create<AppStore>()((...a) => ({ 25 + ...createBaseSlice(...a), 26 + ...createSidebarSlice(...a), 27 + ...createBlueskySlice(...a), 28 + ...createContentMetadataSlice(...a), 29 + ...createStreamplaceSlice(...a), 30 + ...createPlatformSlice(...a), 31 + })); 32 + 33 + // Export everything from slices for convenience 34 + export * from "./slices/baseSlice"; 35 + export * from "./slices/blueskySlice"; 36 + export * from "./slices/contentMetadataSlice"; 37 + export * from "./slices/platformSlice"; 38 + export * from "./slices/sidebarSlice"; 39 + export * from "./slices/streamplaceSlice";
-24
js/app/store/listener.ts
··· 1 - import { createListenerMiddleware, isAnyOf } from "@reduxjs/toolkit"; 2 - import { storage } from "@streamplace/components"; 3 - import { SIDEBAR_STORAGE_KEY, sidebarSlice } from "features/base/sidebarSlice"; 4 - import { RootState } from "./store"; 5 - 6 - export const listenerMiddleware = createListenerMiddleware(); 7 - 8 - listenerMiddleware.startListening({ 9 - matcher: isAnyOf(sidebarSlice.actions.toggleSidebar), 10 - effect: async (action, listenerApi) => { 11 - const state = listenerApi.getState() as RootState; 12 - const sidebarStateToSave = state.sidebar; 13 - 14 - try { 15 - await storage.setItem( 16 - SIDEBAR_STORAGE_KEY, 17 - JSON.stringify(sidebarStateToSave), 18 - ); 19 - console.log("Sidebar state saved to localStorage."); 20 - } catch (error) { 21 - console.error("Failed to save sidebar state to storage:", error); 22 - } 23 - }, 24 - });
+32
js/app/store/slices/baseSlice.ts
··· 1 + import { storage } from "@streamplace/components"; 2 + import { StateCreator } from "zustand"; 3 + 4 + export const STORED_KEY_KEY = "storedKey"; 5 + export const DID_KEY = "did"; 6 + 7 + export interface StreamKey { 8 + privateKey: string; 9 + did: string; 10 + address: string; 11 + } 12 + 13 + export interface BaseSlice { 14 + hydrated: boolean; 15 + hydrate: () => Promise<void>; 16 + } 17 + 18 + export const createBaseSlice: StateCreator<BaseSlice> = (set, get) => ({ 19 + hydrated: false, 20 + hydrate: async () => { 21 + try { 22 + let storedKey: StreamKey | null = null; 23 + const storedKeyStr = await storage.getItem(STORED_KEY_KEY); 24 + if (storedKeyStr) { 25 + storedKey = JSON.parse(storedKeyStr); 26 + } 27 + set({ hydrated: true }); 28 + } catch (e) { 29 + set({ hydrated: false }); 30 + } 31 + }, 32 + });
+1141
js/app/store/slices/blueskySlice.ts
··· 1 + import { 2 + Agent, 3 + AppBskyFeedPost, 4 + AppBskyGraphBlock, 5 + BlobRef, 6 + RichText, 7 + } from "@atproto/api"; 8 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 9 + import { OutputSchema } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords"; 10 + import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; 11 + import { OAuthSession } from "@atproto/oauth-client"; 12 + import { storage } from "@streamplace/components"; 13 + import { Platform } from "react-native"; 14 + import { 15 + PlaceStreamChatProfile, 16 + PlaceStreamKey, 17 + PlaceStreamLivestream, 18 + PlaceStreamServerSettings, 19 + StreamplaceAgent, 20 + } from "streamplace"; 21 + import { privateKeyToAccount } from "viem/accounts"; 22 + import { StateCreator } from "zustand"; 23 + import createOAuthClient, { 24 + StreamplaceOAuthClient, 25 + } from "../../features/bluesky/oauthClient"; 26 + import { DID_KEY, STORED_KEY_KEY, StreamKey } from "./baseSlice"; 27 + 28 + type NewLivestream = { 29 + loading: boolean; 30 + error: string | null; 31 + record: PlaceStreamLivestream.Record | null; 32 + }; 33 + 34 + export interface BlueskySlice { 35 + authStatus: "start" | "loggedIn" | "loggedOut"; 36 + oauthState: null | string; 37 + oauthSession?: null | OAuthSession; 38 + pdsAgent: null | StreamplaceAgent; 39 + anonPDSAgent: null | StreamplaceAgent; 40 + profiles: { [key: string]: ProfileViewDetailed }; 41 + profileCache: { [key: string]: ProfileViewDetailed }; 42 + client: null | StreamplaceOAuthClient; 43 + loginState: { 44 + loading: boolean; 45 + error: null | string; 46 + }; 47 + pds: { 48 + url: string; 49 + loading: boolean; 50 + error: null | string; 51 + }; 52 + newKey: null | StreamKey; 53 + storedKey: null | StreamKey; 54 + isDeletingKey: boolean; 55 + streamKeysResponse: { 56 + loading: boolean; 57 + error: null | string; 58 + records: null | OutputSchema; 59 + }; 60 + newLivestream: null | NewLivestream; 61 + chatProfile: { 62 + loading: boolean; 63 + error: null | string; 64 + profile: null | PlaceStreamChatProfile.Record; 65 + }; 66 + serverSettings: null | PlaceStreamServerSettings.Record; 67 + // actions 68 + loadOAuthClient: (streamplaceUrl: string) => Promise<void>; 69 + oauthError: (error: string, description: string) => void; 70 + login: ( 71 + handle: string, 72 + streamplaceUrl: string, 73 + openLoginLink: (url: string) => Promise<void>, 74 + ) => Promise<void>; 75 + logout: () => Promise<void>; 76 + getProfile: (actor: string) => Promise<void>; 77 + getProfiles: (actors: string[]) => Promise<void>; 78 + oauthCallback: (url: string, streamplaceUrl: string) => Promise<void>; 79 + golivePost: ( 80 + text: string, 81 + now: Date, 82 + thumbnail?: BlobRef, 83 + streamplaceUrl?: string, 84 + ) => Promise<{ uri: string; cid: string }>; 85 + createBlockRecord: (subjectDID: string) => Promise<void>; 86 + createStreamKeyRecord: (store: boolean) => Promise<void>; 87 + clearStreamKeyRecord: () => void; 88 + getStreamKeyRecords: () => Promise<void>; 89 + deleteStreamKeyRecord: (rkey: string) => Promise<void>; 90 + setPDS: (pds: string) => Promise<void>; 91 + createLivestreamRecord: ( 92 + title: string, 93 + customThumbnail?: Blob, 94 + streamplaceUrl?: string, 95 + ) => Promise<void>; 96 + updateLivestreamRecord: ( 97 + title: string, 98 + livestream: any, 99 + streamplaceUrl?: string, 100 + ) => Promise<void>; 101 + getChatProfileRecordFromPDS: () => Promise<void>; 102 + createChatProfileRecord: ( 103 + red: number, 104 + green: number, 105 + blue: number, 106 + ) => Promise<void>; 107 + followUser: (subjectDID: string) => Promise<void>; 108 + unfollowUser: ( 109 + subjectDID: string, 110 + followUri?: string, 111 + streamplaceUrl?: string, 112 + ) => Promise<void>; 113 + getServerSettingsFromPDS: (streamplaceUrl?: string) => Promise<void>; 114 + createServerSettingsRecord: ( 115 + debugRecording: boolean, 116 + streamplaceUrl?: string, 117 + ) => Promise<void>; 118 + } 119 + 120 + const clearQueryParams = () => { 121 + if (Platform.OS !== "web") { 122 + return; 123 + } 124 + const u = new URL(document.location.href); 125 + const params = new URLSearchParams(u.search); 126 + if (u.search === "") { 127 + return; 128 + } 129 + params.delete("iss"); 130 + params.delete("state"); 131 + params.delete("code"); 132 + u.search = params.toString(); 133 + window.history.replaceState(null, "", u.toString()); 134 + }; 135 + 136 + const uploadThumbnail = async ( 137 + handle: string, 138 + u: URL, 139 + pdsAgent: StreamplaceAgent, 140 + profile: ProfileViewDetailed, 141 + customThumbnail?: Blob, 142 + ) => { 143 + if (customThumbnail) { 144 + let tries = 0; 145 + try { 146 + let thumbnail = await pdsAgent.uploadBlob(customThumbnail); 147 + 148 + while ( 149 + thumbnail.data.blob.size === 0 && 150 + customThumbnail.size !== 0 && 151 + tries < 3 152 + ) { 153 + console.warn( 154 + "Reuploading blob as blob sizes don't match! Blob size recieved is", 155 + thumbnail.data.blob.size, 156 + "and sent blob size is", 157 + customThumbnail.size, 158 + ); 159 + thumbnail = await pdsAgent.uploadBlob(customThumbnail); 160 + } 161 + 162 + if (tries === 3) { 163 + throw new Error("Could not successfully upload blob (tried thrice)"); 164 + } 165 + 166 + if (thumbnail.success) { 167 + console.log("Successfully uploaded thumbnail"); 168 + return thumbnail.data.blob; 169 + } 170 + } catch (e) { 171 + throw new Error("Error uploading thumbnail: " + e); 172 + } 173 + } 174 + }; 175 + 176 + export const createBlueskySlice: StateCreator<BlueskySlice> = (set, get) => ({ 177 + authStatus: "start", 178 + oauthState: null, 179 + oauthSession: undefined, 180 + pdsAgent: null, 181 + anonPDSAgent: null, 182 + profiles: {}, 183 + profileCache: {}, 184 + client: null, 185 + loginState: { 186 + loading: false, 187 + error: null, 188 + }, 189 + pds: { 190 + url: "bsky.social", 191 + loading: false, 192 + error: null, 193 + }, 194 + newKey: null, 195 + storedKey: null, 196 + isDeletingKey: false, 197 + streamKeysResponse: { 198 + loading: true, 199 + error: null, 200 + records: null, 201 + }, 202 + newLivestream: null, 203 + chatProfile: { 204 + loading: false, 205 + error: null, 206 + profile: null, 207 + }, 208 + serverSettings: null, 209 + 210 + loadOAuthClient: async (streamplaceUrl: string) => { 211 + set({ authStatus: "start" }); 212 + try { 213 + const client = await createOAuthClient(streamplaceUrl); 214 + const anonPDSAgent = new StreamplaceAgent(streamplaceUrl); 215 + const maybeDIDs = await Promise.all([ 216 + storage.getItem(DID_KEY), 217 + storage.getItem("@@atproto/oauth-client-browser(sub)"), 218 + storage.getItem("@@atproto/oauth-client-react-native:did:(sub)"), 219 + ]); 220 + const did = maybeDIDs.find((d) => d !== null) || null; 221 + let session: OAuthSession | null = null; 222 + if (did) { 223 + try { 224 + session = await client.restore(did); 225 + } catch (e) { 226 + console.error("Error restoring session", e); 227 + } 228 + } 229 + console.log("loadOAuthClient fulfilled", { 230 + client, 231 + session, 232 + anonPDSAgent, 233 + }); 234 + if (session) { 235 + storage.setItem(DID_KEY, session.did).catch((e) => { 236 + console.error("Error setting did", e); 237 + }); 238 + set({ 239 + client, 240 + authStatus: "loggedIn", 241 + oauthSession: session, 242 + pdsAgent: new StreamplaceAgent(session), 243 + anonPDSAgent, 244 + }); 245 + } else { 246 + set({ 247 + oauthSession: session, 248 + authStatus: "loggedOut", 249 + client, 250 + anonPDSAgent, 251 + }); 252 + } 253 + } catch (error) { 254 + console.error("loadOAuthClient error", error); 255 + } 256 + }, 257 + 258 + oauthError: (error: string, description: string) => { 259 + set({ 260 + loginState: { 261 + loading: false, 262 + error: description || error, 263 + }, 264 + authStatus: "loggedOut", 265 + }); 266 + }, 267 + 268 + login: async ( 269 + handle: string, 270 + streamplaceUrl: string, 271 + openLoginLink: (url: string) => Promise<void>, 272 + ) => { 273 + set({ 274 + loginState: { 275 + loading: true, 276 + error: null, 277 + }, 278 + }); 279 + try { 280 + const state = get() as BlueskySlice; 281 + await state.loadOAuthClient(streamplaceUrl); 282 + const updatedState = get() as BlueskySlice; 283 + if (!updatedState.client) { 284 + throw new Error("No client"); 285 + } 286 + const u = await updatedState.client.authorize(handle, {}); 287 + if (document.location.href.startsWith("http://127.0.0.1")) { 288 + const hostUrl = new URL(document.location.href); 289 + u.host = hostUrl.host; 290 + u.protocol = hostUrl.protocol; 291 + } 292 + await openLoginLink(u.toString()); 293 + // cheeky 500ms delay so you don't see the text flash back 294 + await new Promise((resolve) => setTimeout(resolve, 5000)); 295 + set({ 296 + loginState: { 297 + loading: false, 298 + error: null, 299 + }, 300 + }); 301 + } catch (error) { 302 + console.error("login rejected", error); 303 + set({ 304 + loginState: { 305 + loading: false, 306 + error: error?.message ?? null, 307 + }, 308 + }); 309 + } 310 + }, 311 + 312 + logout: async () => { 313 + await storage.removeItem("did"); 314 + await storage.removeItem(STORED_KEY_KEY); 315 + const state = get() as BlueskySlice; 316 + if (!state.oauthSession) { 317 + throw new Error("No oauth session"); 318 + } 319 + await state.oauthSession.signOut(); 320 + set({ 321 + oauthSession: undefined, 322 + pdsAgent: null, 323 + authStatus: "loggedOut", 324 + }); 325 + }, 326 + 327 + getProfile: async (actor: string) => { 328 + try { 329 + const state = get() as BlueskySlice; 330 + if (!state.pdsAgent) { 331 + throw new Error("No agent"); 332 + } 333 + const result = await state.pdsAgent.getProfile({ actor }); 334 + clearQueryParams(); 335 + set((s) => ({ 336 + authStatus: "loggedIn", 337 + profiles: { 338 + ...(s as BlueskySlice).profiles, 339 + [actor]: result.data, 340 + }, 341 + })); 342 + } catch (error) { 343 + clearQueryParams(); 344 + set({ authStatus: "loggedOut" }); 345 + } 346 + }, 347 + 348 + getProfiles: async (actors: string[]) => { 349 + if (actors.length > 25) { 350 + throw Error("Requested too many actors! (max 25 actors)"); 351 + } 352 + try { 353 + const bskyAgent = new Agent("https://public.api.bsky.app"); 354 + const payload = await bskyAgent.getProfiles({ actors }); 355 + let parsedProfiles = {}; 356 + console.log(payload); 357 + payload.data.profiles.forEach((p) => { 358 + parsedProfiles[p.did] = p; 359 + }); 360 + set((s) => ({ 361 + profileCache: { 362 + ...(s as BlueskySlice).profileCache, 363 + ...parsedProfiles, 364 + }, 365 + })); 366 + } catch (error) { 367 + console.error("getProfiles error", error); 368 + } 369 + }, 370 + 371 + oauthCallback: async (url: string, streamplaceUrl: string) => { 372 + set({ authStatus: "start" }); 373 + try { 374 + console.log("oauthCallback", url); 375 + if (!url.includes("?")) { 376 + throw new Error("No query params"); 377 + } 378 + const params = new URLSearchParams(url.split("?")[1]); 379 + if (!(params.has("code") && params.has("state") && params.has("iss"))) { 380 + if (params.has("error")) { 381 + const blueskySlice = get() as BlueskySlice; 382 + blueskySlice.oauthError( 383 + params.get("error") ?? "", 384 + params.get("error_description") ?? "", 385 + ); 386 + } 387 + throw new Error("Missing params, got: " + url); 388 + } 389 + const client = await createOAuthClient(streamplaceUrl); 390 + try { 391 + const ret = await client.callback(params); 392 + await storage.setItem(DID_KEY, ret.session.did); 393 + console.log("oauthCallback fulfilled", { 394 + session: ret.session, 395 + client, 396 + }); 397 + set({ 398 + client, 399 + oauthSession: ret.session, 400 + pdsAgent: new StreamplaceAgent(ret.session), 401 + authStatus: "loggedIn", 402 + }); 403 + } catch (e) { 404 + let message = e.message; 405 + while (e.cause) { 406 + message = `${message}: ${e.cause.message}`; 407 + e = e.cause; 408 + } 409 + console.error("oauthCallback error", message); 410 + throw e; 411 + } 412 + } catch (error) { 413 + console.error("oauthCallback rejected", error); 414 + set({ authStatus: "loggedOut" }); 415 + } 416 + }, 417 + 418 + golivePost: async ( 419 + text: string, 420 + now: Date, 421 + thumbnail?: BlobRef, 422 + streamplaceUrl?: string, 423 + ) => { 424 + const state = get() as BlueskySlice; 425 + if (!state.pdsAgent) { 426 + throw new Error("No agent"); 427 + } 428 + const did = state.oauthSession?.did; 429 + if (!did) { 430 + throw new Error("No DID"); 431 + } 432 + const profile = state.profiles[did]; 433 + if (!profile) { 434 + throw new Error("No profile"); 435 + } 436 + const u = new URL(streamplaceUrl!); 437 + const params = new URLSearchParams({ 438 + did: did, 439 + time: new Date().toISOString(), 440 + }); 441 + 442 + const linkUrl = `${u.protocol}//${u.host}/${profile.handle}?${params.toString()}`; 443 + const prefix = `🔴 LIVE `; 444 + const textUrl = `${u.protocol}//${u.host}/${profile.handle}`; 445 + const suffix = ` ${text}`; 446 + const content = prefix + textUrl + suffix; 447 + 448 + const rt = new RichText({ text: content }); 449 + rt.detectFacetsWithoutResolution(); 450 + 451 + const record: AppBskyFeedPost.Record = { 452 + $type: "app.bsky.feed.post", 453 + text: content, 454 + "place.stream.livestream": { 455 + url: linkUrl, 456 + title: text, 457 + }, 458 + facets: rt.facets, 459 + createdAt: now.toISOString(), 460 + }; 461 + record.embed = { 462 + $type: "app.bsky.embed.external", 463 + external: { 464 + description: text, 465 + thumb: thumbnail, 466 + title: `@${profile.handle} is 🔴LIVE on ${u.host}!`, 467 + uri: linkUrl, 468 + }, 469 + }; 470 + console.log("golivePost record", record); 471 + return await state.pdsAgent.post(record); 472 + }, 473 + 474 + createBlockRecord: async (subjectDID: string) => { 475 + try { 476 + const state = get() as BlueskySlice; 477 + if (!state.pdsAgent) { 478 + throw new Error("No agent"); 479 + } 480 + const did = state.oauthSession?.did; 481 + if (!did) { 482 + throw new Error("No DID"); 483 + } 484 + const profile = state.profiles[did]; 485 + if (!profile) { 486 + throw new Error("No profile"); 487 + } 488 + const record: AppBskyGraphBlock.Record = { 489 + $type: "app.bsky.graph.block", 490 + subject: subjectDID, 491 + createdAt: new Date().toISOString(), 492 + }; 493 + await state.pdsAgent.com.atproto.repo.createRecord({ 494 + repo: did, 495 + collection: "app.bsky.graph.block", 496 + record, 497 + }); 498 + console.log("createBlockRecord fulfilled"); 499 + } catch (error) { 500 + console.error("createBlockRecord rejected", error); 501 + } 502 + }, 503 + 504 + createStreamKeyRecord: async (store: boolean) => { 505 + try { 506 + const state = get() as BlueskySlice; 507 + if (!state.pdsAgent) { 508 + throw new Error("No agent"); 509 + } 510 + const did = state.oauthSession?.did; 511 + if (!did) { 512 + throw new Error("No DID"); 513 + } 514 + const profile = state.profiles[did]; 515 + if (!profile) { 516 + throw new Error("No profile"); 517 + } 518 + const keypair = await Secp256k1Keypair.create({ exportable: true }); 519 + const exportedKey = await keypair.export(); 520 + const didBytes = new TextEncoder().encode(did); 521 + const combinedKey = new Uint8Array([...exportedKey, ...didBytes]); 522 + const multibaseKey = bytesToMultibase(combinedKey, "base58btc"); 523 + const hexKey = Array.from(exportedKey) 524 + .map((b) => b.toString(16).padStart(2, "0")) 525 + .join(""); 526 + const account = await privateKeyToAccount(`0x${hexKey}`); 527 + const newKey = { 528 + privateKey: multibaseKey, 529 + did: keypair.did(), 530 + address: account.address.toLowerCase(), 531 + }; 532 + 533 + let platform: string = Platform.OS; 534 + 535 + if (Platform.OS === "web" && window && window.navigator) { 536 + let splitUA = window.navigator.userAgent 537 + .split(" ") 538 + .pop() 539 + ?.split("/")[0]; 540 + if (splitUA) { 541 + platform = splitUA; 542 + } 543 + } else if (platform === "android") { 544 + platform = "Android"; 545 + } else if (platform === "ios") { 546 + platform = "iOS"; 547 + } else if (platform === "macos") { 548 + platform = "macOS"; 549 + } else if (platform === "windows") { 550 + platform = "Windows"; 551 + } 552 + 553 + const record: PlaceStreamKey.Record = { 554 + $type: "place.stream.key", 555 + signingKey: keypair.did(), 556 + createdAt: new Date().toISOString(), 557 + createdBy: "Streamplace on " + platform, 558 + }; 559 + await state.pdsAgent.com.atproto.repo.createRecord({ 560 + repo: did, 561 + collection: "place.stream.key", 562 + record, 563 + }); 564 + if (store) { 565 + await storage.setItem(STORED_KEY_KEY, JSON.stringify(newKey)); 566 + } 567 + set({ 568 + newKey: newKey, 569 + storedKey: store ? newKey : null, 570 + }); 571 + } catch (error) { 572 + console.error("createStreamKeyRecord rejected", error); 573 + } 574 + }, 575 + 576 + clearStreamKeyRecord: () => { 577 + set({ newKey: null }); 578 + }, 579 + 580 + getStreamKeyRecords: async () => { 581 + set({ 582 + streamKeysResponse: { 583 + loading: true, 584 + error: null, 585 + records: null, 586 + }, 587 + }); 588 + try { 589 + const state = get() as BlueskySlice; 590 + if (!state.pdsAgent) { 591 + throw new Error("No agent"); 592 + } 593 + const did = state.oauthSession?.did; 594 + if (!did) { 595 + throw new Error("No DID"); 596 + } 597 + const profile = state.profiles[did]; 598 + if (!profile) { 599 + throw new Error("No profile"); 600 + } 601 + const result = await state.pdsAgent.com.atproto.repo.listRecords({ 602 + repo: did, 603 + collection: "place.stream.key", 604 + limit: 100, 605 + }); 606 + console.log(result); 607 + set({ 608 + streamKeysResponse: { 609 + loading: false, 610 + error: null, 611 + records: result.data, 612 + }, 613 + }); 614 + } catch (error) { 615 + console.error("listStreamKeyRecords rejected", error); 616 + set({ 617 + streamKeysResponse: { 618 + loading: false, 619 + error: error?.message ?? null, 620 + records: null, 621 + }, 622 + }); 623 + } 624 + }, 625 + 626 + deleteStreamKeyRecord: async (rkey: string) => { 627 + set({ isDeletingKey: true }); 628 + try { 629 + const state = get() as BlueskySlice; 630 + if (!state.pdsAgent) { 631 + throw new Error("No agent"); 632 + } 633 + const did = state.oauthSession?.did; 634 + if (!did) { 635 + throw new Error("No DID"); 636 + } 637 + const profile = state.profiles[did]; 638 + if (!profile) { 639 + throw new Error("No profile"); 640 + } 641 + await state.pdsAgent.com.atproto.repo.deleteRecord({ 642 + repo: did, 643 + collection: "place.stream.key", 644 + rkey, 645 + }); 646 + let records = state.streamKeysResponse.records 647 + ? state.streamKeysResponse.records.records.filter( 648 + (r) => r.uri.split("/").pop() !== rkey, 649 + ) 650 + : []; 651 + set({ 652 + isDeletingKey: false, 653 + streamKeysResponse: { 654 + ...state.streamKeysResponse, 655 + records: { 656 + ...state.streamKeysResponse.records!, 657 + records, 658 + }, 659 + }, 660 + }); 661 + } catch (error) { 662 + console.error("deleteStreamKeyRecord rejected", error); 663 + set({ isDeletingKey: false }); 664 + } 665 + }, 666 + 667 + setPDS: async (pds: string) => { 668 + set({ 669 + pds: { 670 + ...(get() as BlueskySlice).pds, 671 + loading: true, 672 + }, 673 + }); 674 + try { 675 + await storage.setItem("pdsURL", pds); 676 + console.log("setPDS fulfilled", pds); 677 + set({ 678 + pds: { 679 + ...(get() as BlueskySlice).pds, 680 + loading: false, 681 + url: pds, 682 + }, 683 + }); 684 + } catch (error) { 685 + set({ 686 + pds: { 687 + ...(get() as BlueskySlice).pds, 688 + loading: false, 689 + error: error?.message ?? null, 690 + }, 691 + }); 692 + } 693 + }, 694 + 695 + createLivestreamRecord: async ( 696 + title: string, 697 + customThumbnail?: Blob, 698 + streamplaceUrl?: string, 699 + ) => { 700 + set({ 701 + newLivestream: { 702 + loading: true, 703 + error: null, 704 + record: null, 705 + }, 706 + }); 707 + try { 708 + const now = new Date(); 709 + const state = get() as BlueskySlice; 710 + if (!state.pdsAgent) { 711 + throw new Error("No agent"); 712 + } 713 + const did = state.oauthSession?.did; 714 + if (!did) { 715 + throw new Error("No DID"); 716 + } 717 + const profile = state.profiles[did]; 718 + if (!profile) { 719 + throw new Error("No profile"); 720 + } 721 + 722 + let thumbnail: BlobRef | undefined = undefined; 723 + const u = new URL(streamplaceUrl!); 724 + 725 + if (customThumbnail) { 726 + try { 727 + thumbnail = await uploadThumbnail( 728 + profile.handle, 729 + u, 730 + state.pdsAgent, 731 + profile, 732 + customThumbnail, 733 + ); 734 + } catch (e) { 735 + throw new Error(`Custom thumbnail upload failed ${e}`); 736 + } 737 + } else { 738 + let tries = 0; 739 + try { 740 + for (; tries < 3; tries++) { 741 + try { 742 + console.log( 743 + `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, 744 + ); 745 + const thumbnailRes = await fetch( 746 + `${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, 747 + ); 748 + if (!thumbnailRes.ok) { 749 + throw new Error( 750 + `Failed to fetch thumbnail: ${thumbnailRes.status})`, 751 + ); 752 + } 753 + const thumbnailBlob = await thumbnailRes.blob(); 754 + console.log(thumbnailBlob); 755 + thumbnail = await uploadThumbnail( 756 + profile.handle, 757 + u, 758 + state.pdsAgent, 759 + profile, 760 + thumbnailBlob, 761 + ); 762 + } catch (e) { 763 + console.warn( 764 + `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, 765 + ); 766 + await new Promise((resolve) => setTimeout(resolve, 2000)); 767 + if (tries === 2) { 768 + throw new Error( 769 + `Failed to fetch thumbnail after 3 tries: ${e}`, 770 + ); 771 + } 772 + } 773 + } 774 + } catch (e) { 775 + throw new Error(`Thumbnail upload failed ${e}`); 776 + } 777 + } 778 + 779 + const newPost = await state.golivePost( 780 + title, 781 + now, 782 + thumbnail, 783 + streamplaceUrl, 784 + ); 785 + 786 + if (!newPost?.uri || !newPost?.cid) { 787 + throw new Error( 788 + "Cannot read properties of undefined (reading 'uri' or 'cid')", 789 + ); 790 + } 791 + 792 + const record: PlaceStreamLivestream.Record = { 793 + $type: "place.stream.livestream", 794 + title: title, 795 + url: streamplaceUrl!, 796 + createdAt: new Date().toISOString(), 797 + post: { 798 + uri: newPost.uri, 799 + cid: newPost.cid, 800 + }, 801 + thumb: thumbnail, 802 + }; 803 + 804 + await state.pdsAgent.com.atproto.repo.createRecord({ 805 + repo: did, 806 + collection: "place.stream.livestream", 807 + record, 808 + }); 809 + set({ 810 + newLivestream: { 811 + loading: false, 812 + error: null, 813 + record: record, 814 + }, 815 + }); 816 + } catch (error) { 817 + console.error("createLivestreamRecord rejected", error); 818 + set({ 819 + newLivestream: { 820 + loading: false, 821 + error: error?.message ?? null, 822 + record: null, 823 + }, 824 + }); 825 + } 826 + }, 827 + 828 + updateLivestreamRecord: async ( 829 + title: string, 830 + livestream: any, 831 + streamplaceUrl?: string, 832 + ) => { 833 + set({ 834 + newLivestream: { 835 + loading: true, 836 + error: null, 837 + record: null, 838 + }, 839 + }); 840 + try { 841 + const now = new Date(); 842 + const state = get() as BlueskySlice; 843 + 844 + if (!state.pdsAgent) { 845 + throw new Error("No agent"); 846 + } 847 + const did = state.oauthSession?.did; 848 + if (!did) { 849 + throw new Error("No DID"); 850 + } 851 + const profile = state.profiles[did]; 852 + if (!profile) { 853 + throw new Error("No profile"); 854 + } 855 + 856 + let oldRecord = livestream; 857 + if (!oldRecord) { 858 + throw new Error("No latest record"); 859 + } 860 + 861 + let rkey = oldRecord.uri.split("/").pop(); 862 + let oldRecordValue: PlaceStreamLivestream.Record = oldRecord.record; 863 + 864 + if (!rkey) { 865 + throw new Error("No rkey?"); 866 + } 867 + 868 + console.log("Updating rkey", rkey); 869 + 870 + const record: PlaceStreamLivestream.Record = { 871 + $type: "place.stream.livestream", 872 + title: title, 873 + url: streamplaceUrl!, 874 + createdAt: new Date().toISOString(), 875 + post: oldRecordValue.post, 876 + }; 877 + 878 + await state.pdsAgent.com.atproto.repo.putRecord({ 879 + repo: did, 880 + collection: "place.stream.livestream", 881 + rkey, 882 + record, 883 + }); 884 + set({ 885 + newLivestream: { 886 + loading: false, 887 + error: null, 888 + record: record, 889 + }, 890 + }); 891 + } catch (error) { 892 + console.error("createLivestreamRecord rejected", error); 893 + set({ 894 + newLivestream: { 895 + loading: false, 896 + error: error?.message ?? null, 897 + record: null, 898 + }, 899 + }); 900 + } 901 + }, 902 + 903 + getChatProfileRecordFromPDS: async () => { 904 + set({ 905 + chatProfile: { 906 + loading: true, 907 + error: null, 908 + profile: null, 909 + }, 910 + }); 911 + try { 912 + const state = get() as BlueskySlice; 913 + const did = state.oauthSession?.did; 914 + if (!did) { 915 + throw new Error("No DID"); 916 + } 917 + const profile = state.profiles[did]; 918 + if (!profile) { 919 + throw new Error("No profile"); 920 + } 921 + if (!state.pdsAgent) { 922 + throw new Error("No agent"); 923 + } 924 + const res = await state.pdsAgent.com.atproto.repo.getRecord({ 925 + repo: did, 926 + collection: "place.stream.chat.profile", 927 + rkey: "self", 928 + }); 929 + if (!res.success) { 930 + throw new Error("Failed to get chat profile record"); 931 + } 932 + 933 + if (PlaceStreamChatProfile.isRecord(res.data.value)) { 934 + set({ 935 + chatProfile: { 936 + loading: false, 937 + error: null, 938 + profile: res.data.value, 939 + }, 940 + }); 941 + } else { 942 + console.log("not a record", res.data.value); 943 + } 944 + } catch (error) { 945 + console.error("getChatProfileRecordFromPDS error", error); 946 + } 947 + }, 948 + 949 + createChatProfileRecord: async (red: number, green: number, blue: number) => { 950 + set({ 951 + chatProfile: { 952 + loading: true, 953 + error: null, 954 + profile: null, 955 + }, 956 + }); 957 + try { 958 + const state = get() as BlueskySlice; 959 + if (!state.pdsAgent) { 960 + throw new Error("No agent"); 961 + } 962 + const did = state.oauthSession?.did; 963 + if (!did) { 964 + throw new Error("No DID"); 965 + } 966 + const profile = state.profiles[did]; 967 + if (!profile) { 968 + throw new Error("No profile"); 969 + } 970 + 971 + const chatProfile: PlaceStreamChatProfile.Record = { 972 + $type: "place.stream.chat.profile", 973 + color: { 974 + red: red, 975 + green: green, 976 + blue: blue, 977 + }, 978 + }; 979 + 980 + const res = await state.pdsAgent.com.atproto.repo.putRecord({ 981 + repo: did, 982 + collection: "place.stream.chat.profile", 983 + record: chatProfile, 984 + rkey: "self", 985 + }); 986 + if (!res.success) { 987 + throw new Error("Failed to create chat profile record"); 988 + } 989 + set({ 990 + chatProfile: { 991 + loading: false, 992 + error: null, 993 + profile: chatProfile, 994 + }, 995 + }); 996 + } catch (error) { 997 + console.error("createChatProfileRecord rejected", error); 998 + set({ 999 + chatProfile: { 1000 + loading: false, 1001 + error: error?.message ?? null, 1002 + profile: null, 1003 + }, 1004 + }); 1005 + } 1006 + }, 1007 + 1008 + followUser: async (subjectDID: string) => { 1009 + try { 1010 + console.log("followUser pending"); 1011 + const state = get() as BlueskySlice; 1012 + if (!state.pdsAgent) { 1013 + throw new Error("No agent"); 1014 + } 1015 + const did = state.oauthSession?.did; 1016 + if (!did) { 1017 + throw new Error("No DID"); 1018 + } 1019 + await state.pdsAgent.follow(subjectDID); 1020 + console.log("followUser fulfilled", { subjectDID }); 1021 + } catch (error) { 1022 + console.error("followUser rejected", error); 1023 + } 1024 + }, 1025 + 1026 + unfollowUser: async ( 1027 + subjectDID: string, 1028 + followUri?: string, 1029 + streamplaceUrl?: string, 1030 + ) => { 1031 + try { 1032 + console.log("unfollowUser pending"); 1033 + const state = get() as BlueskySlice; 1034 + if (!state.pdsAgent) { 1035 + throw new Error("No agent"); 1036 + } 1037 + const did = state.oauthSession?.did; 1038 + if (!did) { 1039 + throw new Error("No DID"); 1040 + } 1041 + 1042 + if (followUri) { 1043 + await state.pdsAgent.deleteFollow(followUri); 1044 + } else { 1045 + const res = await fetch( 1046 + `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(subjectDID)}&userDID=${encodeURIComponent(did)}`, 1047 + { 1048 + credentials: "include", 1049 + }, 1050 + ); 1051 + const data = await res.json(); 1052 + 1053 + if (!data.follow || !data.follow.uri) { 1054 + throw new Error("Follow record not found"); 1055 + } 1056 + 1057 + await state.pdsAgent.deleteFollow(data.follow.uri); 1058 + } 1059 + 1060 + console.log("unfollowUser fulfilled", { subjectDID }); 1061 + } catch (error) { 1062 + console.error("unfollowUser rejected", error); 1063 + } 1064 + }, 1065 + 1066 + getServerSettingsFromPDS: async (streamplaceUrl?: string) => { 1067 + try { 1068 + const state = get() as BlueskySlice; 1069 + const did = state.oauthSession?.did; 1070 + if (!did) { 1071 + throw new Error("No DID"); 1072 + } 1073 + const profile = state.profiles[did]; 1074 + if (!profile) { 1075 + throw new Error("No profile"); 1076 + } 1077 + if (!state.pdsAgent) { 1078 + throw new Error("No agent"); 1079 + } 1080 + const u = new URL(streamplaceUrl!); 1081 + const res = await state.pdsAgent.com.atproto.repo.getRecord({ 1082 + repo: did, 1083 + collection: "place.stream.server.settings", 1084 + rkey: u.host, 1085 + }); 1086 + if (!res.success) { 1087 + throw new Error("Failed to get chat profile record"); 1088 + } 1089 + 1090 + if (PlaceStreamServerSettings.isRecord(res.data.value)) { 1091 + set({ 1092 + serverSettings: res.data.value as PlaceStreamServerSettings.Record, 1093 + }); 1094 + } else { 1095 + console.log("not a record", res.data.value); 1096 + } 1097 + } catch (error) { 1098 + console.error("getServerSettingsFromPDS rejected", error); 1099 + } 1100 + }, 1101 + 1102 + createServerSettingsRecord: async ( 1103 + debugRecording: boolean, 1104 + streamplaceUrl?: string, 1105 + ) => { 1106 + try { 1107 + const state = get() as BlueskySlice; 1108 + if (!state.pdsAgent) { 1109 + throw new Error("No agent"); 1110 + } 1111 + const did = state.oauthSession?.did; 1112 + if (!did) { 1113 + throw new Error("No DID"); 1114 + } 1115 + const profile = state.profiles[did]; 1116 + if (!profile) { 1117 + throw new Error("No profile"); 1118 + } 1119 + const u = new URL(streamplaceUrl!); 1120 + const serverSettings: PlaceStreamServerSettings.Record = { 1121 + $type: "place.stream.server.settings", 1122 + debugRecording: debugRecording, 1123 + }; 1124 + 1125 + const res = await state.pdsAgent.com.atproto.repo.putRecord({ 1126 + repo: did, 1127 + collection: "place.stream.server.settings", 1128 + record: serverSettings, 1129 + rkey: u.host, 1130 + }); 1131 + if (!res.success) { 1132 + throw new Error("Failed to create server settings record"); 1133 + } 1134 + set({ 1135 + serverSettings: serverSettings, 1136 + }); 1137 + } catch (error) { 1138 + console.error("createServerSettingsRecord rejected", error); 1139 + } 1140 + }, 1141 + });
+298
js/app/store/slices/contentMetadataSlice.ts
··· 1 + import { StateCreator } from "zustand"; 2 + import { BlueskySlice } from "./blueskySlice"; 3 + 4 + export interface ContentMetadataSlice { 5 + creating: boolean; 6 + updating: boolean; 7 + error: string | null; 8 + lastCreatedRecord: any | null; 9 + // actions 10 + createContentMetadata: (params: { 11 + contentWarnings?: string[]; 12 + distributionPolicy?: { deleteAfter?: number }; 13 + contentRights?: { 14 + creator?: string; 15 + copyrightNotice?: string; 16 + copyrightYear?: number; 17 + license?: string; 18 + creditLine?: string; 19 + }; 20 + }) => Promise<void>; 21 + updateContentMetadata: (params: { 22 + rkey?: string; 23 + livestreamRef?: { uri: string; cid: string }; 24 + contentWarnings?: string[]; 25 + distributionPolicy?: { deleteAfter?: number }; 26 + contentRights?: { 27 + creator?: string; 28 + copyrightNotice?: string; 29 + copyrightYear?: number; 30 + license?: string; 31 + creditLine?: string; 32 + }; 33 + }) => Promise<void>; 34 + getContentMetadata: (params?: { 35 + userDid?: string; 36 + rkey?: string; 37 + }) => Promise<void>; 38 + clearError: () => void; 39 + } 40 + 41 + export const createContentMetadataSlice: StateCreator< 42 + ContentMetadataSlice, 43 + [], 44 + [], 45 + ContentMetadataSlice 46 + > = (set, get) => ({ 47 + creating: false, 48 + updating: false, 49 + error: null, 50 + lastCreatedRecord: null, 51 + 52 + createContentMetadata: async ({ 53 + contentWarnings = [], 54 + distributionPolicy = { deleteAfter: undefined }, 55 + contentRights = {}, 56 + }) => { 57 + set({ creating: true, error: null }); 58 + try { 59 + // need access to bluesky slice - will handle in combined store 60 + const state = get() as any; 61 + const bluesky: BlueskySlice = state; 62 + 63 + if (!bluesky.pdsAgent) { 64 + throw new Error("No agent"); 65 + } 66 + 67 + const did = bluesky.oauthSession?.did; 68 + if (!did) { 69 + throw new Error("No DID"); 70 + } 71 + 72 + const metadataRecord = { 73 + $type: "place.stream.metadata.configuration", 74 + createdAt: new Date().toISOString(), 75 + ...(contentWarnings.length > 0 && { 76 + contentWarnings: { warnings: contentWarnings }, 77 + }), 78 + ...(distributionPolicy.deleteAfter && { distributionPolicy }), 79 + ...(contentRights && 80 + Object.keys(contentRights).length > 0 && { 81 + contentRights, 82 + }), 83 + }; 84 + 85 + const result = await bluesky.pdsAgent.com.atproto.repo.createRecord({ 86 + repo: did, 87 + collection: "place.stream.metadata.configuration", 88 + rkey: "self", 89 + record: metadataRecord, 90 + }); 91 + 92 + const rkey = result.data.uri.split("/").pop(); 93 + 94 + set({ 95 + creating: false, 96 + error: null, 97 + lastCreatedRecord: { 98 + record: metadataRecord, 99 + uri: result.data.uri, 100 + cid: result.data.cid, 101 + rkey, 102 + }, 103 + }); 104 + } catch (error) { 105 + set({ 106 + creating: false, 107 + error: error?.message ?? "Failed to create content metadata", 108 + }); 109 + } 110 + }, 111 + 112 + updateContentMetadata: async ({ 113 + rkey, 114 + livestreamRef, 115 + contentWarnings = [], 116 + distributionPolicy = { deleteAfter: undefined }, 117 + contentRights = {}, 118 + }) => { 119 + set({ updating: true, error: null }); 120 + try { 121 + const state = get() as any; 122 + const bluesky: BlueskySlice = state; 123 + 124 + if (!bluesky.pdsAgent) { 125 + throw new Error("No agent"); 126 + } 127 + 128 + const did = bluesky.oauthSession?.did; 129 + if (!did) { 130 + throw new Error("No DID"); 131 + } 132 + 133 + const metadataRecord = { 134 + $type: "place.stream.metadata.configuration", 135 + ...(livestreamRef && { livestreamRef }), 136 + createdAt: new Date().toISOString(), 137 + ...(contentWarnings.length > 0 && { 138 + contentWarnings: { warnings: contentWarnings }, 139 + }), 140 + ...(distributionPolicy.deleteAfter && { distributionPolicy }), 141 + ...(contentRights && 142 + Object.keys(contentRights).length > 0 && { 143 + contentRights, 144 + }), 145 + }; 146 + 147 + const result = await bluesky.pdsAgent.com.atproto.repo.putRecord({ 148 + repo: did, 149 + collection: "place.stream.metadata.configuration", 150 + rkey: "self", 151 + record: metadataRecord, 152 + }); 153 + 154 + set({ 155 + updating: false, 156 + error: null, 157 + lastCreatedRecord: { 158 + record: metadataRecord, 159 + uri: `at://${did}/place.stream.metadata.configuration/self`, 160 + cid: result.data.cid, 161 + }, 162 + }); 163 + } catch (error) { 164 + set({ 165 + updating: false, 166 + error: error?.message ?? "Failed to update content metadata", 167 + }); 168 + } 169 + }, 170 + 171 + getContentMetadata: async ({ userDid, rkey = "self" } = {}) => { 172 + set({ error: null }); 173 + try { 174 + const state = get() as any; 175 + const bluesky: BlueskySlice = state; 176 + 177 + if (!bluesky.pdsAgent) { 178 + throw new Error("No agent"); 179 + } 180 + 181 + const targetDid = userDid || bluesky.oauthSession?.did; 182 + if (!targetDid) { 183 + throw new Error("No DID provided or user not authenticated"); 184 + } 185 + 186 + console.log(`[getContentMetadata] Debug info:`, { 187 + targetDid, 188 + rkey, 189 + pdsAgentType: bluesky.pdsAgent.constructor.name, 190 + hasOAuthSession: !!bluesky.oauthSession, 191 + currentUserDid: bluesky.oauthSession?.did, 192 + pdsAgentHost: 193 + (bluesky.pdsAgent as any)?.host || 194 + (bluesky.pdsAgent as any)?.service?.host || 195 + "unknown", 196 + pdsAgentUrl: 197 + (bluesky.pdsAgent as any)?.url || 198 + (bluesky.pdsAgent as any)?.service?.url || 199 + "unknown", 200 + }); 201 + 202 + try { 203 + let targetPDS = null; 204 + try { 205 + const didResponse = await fetch(`https://plc.directory/${targetDid}`); 206 + if (didResponse.ok) { 207 + const didDoc = await didResponse.json(); 208 + const pdsService = didDoc.service?.find( 209 + (s: any) => s.id === "#atproto_pds", 210 + ); 211 + if (pdsService) { 212 + targetPDS = pdsService.serviceEndpoint; 213 + console.log( 214 + `[getContentMetadata] Resolved PDS for ${targetDid}:`, 215 + targetPDS, 216 + ); 217 + } 218 + } 219 + } catch (pdsResolveError) { 220 + console.log( 221 + `[getContentMetadata] Failed to resolve PDS for ${targetDid}:`, 222 + pdsResolveError, 223 + ); 224 + } 225 + 226 + let agent = bluesky.pdsAgent; 227 + if (targetPDS && targetPDS !== (bluesky.pdsAgent as any)?.host) { 228 + const { StreamplaceAgent } = await import("streamplace"); 229 + agent = new StreamplaceAgent(targetPDS) as any; 230 + console.log( 231 + `[getContentMetadata] Created new agent for PDS:`, 232 + targetPDS, 233 + ); 234 + } 235 + 236 + console.log(`[getContentMetadata] Attempting to fetch record from:`, { 237 + repo: targetDid, 238 + collection: "place.stream.metadata.configuration", 239 + rkey, 240 + usingPDS: targetPDS || "default", 241 + }); 242 + 243 + const result = await agent.com.atproto.repo.getRecord({ 244 + repo: targetDid, 245 + collection: "place.stream.metadata.configuration", 246 + rkey, 247 + }); 248 + 249 + console.log(`[getContentMetadata] API response:`, result); 250 + 251 + if (!result.success) { 252 + throw new Error("Failed to get content metadata record"); 253 + } 254 + 255 + set({ 256 + error: null, 257 + lastCreatedRecord: { 258 + userDid: targetDid, 259 + record: result.data.value, 260 + uri: result.data.uri, 261 + cid: result.data.cid, 262 + }, 263 + }); 264 + } catch (error) { 265 + console.log(`[getContentMetadata] Error details:`, { 266 + error: error.message, 267 + errorType: error.constructor.name, 268 + errorStack: error.stack, 269 + }); 270 + 271 + if ( 272 + error.message?.includes("not found") || 273 + error.message?.includes("RecordNotFound") 274 + ) { 275 + set({ 276 + error: null, 277 + lastCreatedRecord: { 278 + userDid: targetDid, 279 + record: null, 280 + uri: null, 281 + cid: null, 282 + }, 283 + }); 284 + return; 285 + } 286 + throw error; 287 + } 288 + } catch (error) { 289 + set({ 290 + error: error?.message ?? "Failed to get content metadata", 291 + }); 292 + } 293 + }, 294 + 295 + clearError: () => { 296 + set({ error: null }); 297 + }, 298 + });
+40
js/app/store/slices/platformSlice.ts
··· 1 + import { StateCreator } from "zustand"; 2 + 3 + export interface PlatformSlice { 4 + status: "idle" | "loading" | "failed"; 5 + notificationToken: string | null; 6 + notificationDestination: string | null; 7 + // actions 8 + handleNotification: (payload?: { [key: string]: string | object }) => void; 9 + clearNotification: () => void; 10 + openLoginLink: (url: string) => Promise<void>; 11 + initPushNotifications: () => Promise<void>; 12 + registerNotificationToken: () => Promise<void>; 13 + } 14 + 15 + export const createPlatformSlice: StateCreator<PlatformSlice> = (set, get) => ({ 16 + status: "idle", 17 + notificationToken: null, 18 + notificationDestination: null, 19 + handleNotification: (payload) => { 20 + // notification handling logic 21 + }, 22 + clearNotification: () => { 23 + set({ notificationDestination: null }); 24 + }, 25 + openLoginLink: async (url: string) => { 26 + set({ status: "loading" }); 27 + try { 28 + window.location.href = url; 29 + set({ status: "idle" }); 30 + } catch (error) { 31 + set({ status: "failed" }); 32 + } 33 + }, 34 + initPushNotifications: async () => { 35 + // mobile-only, web notifications someday 36 + }, 37 + registerNotificationToken: async () => { 38 + // notification token registration 39 + }, 40 + });
+117
js/app/store/slices/sidebarSlice.ts
··· 1 + import { storage } from "@streamplace/components"; 2 + import { StateCreator } from "zustand"; 3 + 4 + export const SIDEBAR_STORAGE_KEY = "sidebarState"; 5 + 6 + export interface SidebarSlice { 7 + isCollapsed: boolean; 8 + isHidden: boolean; 9 + targetWidth: number; 10 + isLoaded: boolean; 11 + setSidebarHidden: () => void; 12 + setSidebarUnhidden: () => void; 13 + toggleSidebar: () => void; 14 + loadStateFromStorage: () => Promise<void>; 15 + } 16 + 17 + function verifySidebarState(state: any): Partial<SidebarSlice> { 18 + const verifiedState: Partial<SidebarSlice> = { 19 + isCollapsed: 20 + typeof state.isCollapsed === "boolean" ? state.isCollapsed : false, 21 + isHidden: typeof state.isHidden === "boolean" ? state.isHidden : false, 22 + targetWidth: 23 + typeof state.targetWidth === "number" ? state.targetWidth : 250, 24 + isLoaded: false, 25 + }; 26 + 27 + if (!verifiedState.isHidden) { 28 + if (verifiedState.targetWidth! < 64) { 29 + verifiedState.targetWidth = 64; 30 + } else if (verifiedState.targetWidth! > 250) { 31 + verifiedState.targetWidth = 250; 32 + } 33 + } else { 34 + verifiedState.targetWidth = 0; 35 + } 36 + 37 + return verifiedState; 38 + } 39 + 40 + export const createSidebarSlice: StateCreator<SidebarSlice> = (set, get) => ({ 41 + isCollapsed: false, 42 + isHidden: false, 43 + targetWidth: 250, 44 + isLoaded: false, 45 + setSidebarHidden: () => { 46 + set((state) => { 47 + const isHidden = true; 48 + const targetWidth = 49 + (state as SidebarSlice).isCollapsed || isHidden 50 + ? isHidden 51 + ? 0 52 + : 64 53 + : 250; 54 + return { isHidden, targetWidth }; 55 + }); 56 + }, 57 + setSidebarUnhidden: () => { 58 + set((state) => { 59 + const isHidden = false; 60 + const targetWidth = 61 + (state as SidebarSlice).isCollapsed || isHidden 62 + ? isHidden 63 + ? 0 64 + : 64 65 + : 250; 66 + return { isHidden, targetWidth }; 67 + }); 68 + }, 69 + toggleSidebar: () => { 70 + set((state) => { 71 + const sidebarState = state as SidebarSlice; 72 + const isCollapsed = !sidebarState.isCollapsed; 73 + const targetWidth = 74 + isCollapsed || sidebarState.isHidden 75 + ? sidebarState.isHidden 76 + ? 0 77 + : 64 78 + : 250; 79 + // persist to storage 80 + storage.setItem( 81 + SIDEBAR_STORAGE_KEY, 82 + JSON.stringify({ 83 + isCollapsed, 84 + isHidden: sidebarState.isHidden, 85 + targetWidth, 86 + isLoaded: sidebarState.isLoaded, 87 + }), 88 + ); 89 + return { isCollapsed, targetWidth }; 90 + }); 91 + }, 92 + loadStateFromStorage: async () => { 93 + try { 94 + const storedStateString = await storage.getItem(SIDEBAR_STORAGE_KEY); 95 + if (storedStateString) { 96 + let state = JSON.parse(storedStateString); 97 + state.isHidden = false; 98 + const verifiedState = verifySidebarState(state); 99 + console.log("Sidebar state loaded from localStorage:", verifiedState); 100 + set({ ...verifiedState, isLoaded: true }); 101 + } else { 102 + console.log("No sidebar state found in localStorage, using defaults."); 103 + set({ isLoaded: true }); 104 + } 105 + } catch (error) { 106 + console.error( 107 + "Failed to load sidebar state from storage, using defaults:", 108 + error, 109 + ); 110 + set({ 111 + isCollapsed: false, 112 + targetWidth: 250, 113 + isLoaded: true, 114 + }); 115 + } 116 + }, 117 + });
+117
js/app/store/slices/streamplaceSlice.ts
··· 1 + import { storage } from "@streamplace/components"; 2 + import { Platform } from "react-native"; 3 + import type { PlaceStreamSegment } from "streamplace"; 4 + import { StateCreator } from "zustand"; 5 + 6 + let DEFAULT_URL = process.env.EXPO_PUBLIC_STREAMPLACE_URL as string; 7 + if (Platform.OS === "web" && process.env.EXPO_PUBLIC_WEB_TRY_LOCAL === "true") { 8 + try { 9 + DEFAULT_URL = `${window.location.protocol}//${window.location.host}`; 10 + } catch (err) { 11 + // fall back to hardcoded 12 + } 13 + } 14 + 15 + export { DEFAULT_URL }; 16 + 17 + const USER_MUTED_KEY = "streamplaceUserMuted"; 18 + const URL_KEY = "streamplaceUrl"; 19 + const CHAT_WARNING_KEY = "streamplaceChatWarning2"; 20 + 21 + export interface Identity { 22 + id: string; 23 + handle?: string; 24 + did?: string; 25 + } 26 + 27 + export interface StreamplaceSlice { 28 + url: string; 29 + identity: Identity | null; 30 + initialized: boolean; 31 + userMuted: boolean | null; 32 + chatWarned: boolean; 33 + mySegments: PlaceStreamSegment.SegmentView[]; 34 + // actions 35 + initialize: () => Promise<void>; 36 + setURL: (url: string) => void; 37 + userMute: (muted: boolean) => void; 38 + chatWarn: (warned: boolean) => void; 39 + getIdentity: () => Promise<void>; 40 + pollMySegments: () => Promise<void>; 41 + } 42 + 43 + export const createStreamplaceSlice: StateCreator<StreamplaceSlice> = ( 44 + set, 45 + get, 46 + ) => ({ 47 + url: DEFAULT_URL, 48 + identity: null, 49 + initialized: false, 50 + userMuted: null, 51 + chatWarned: false, 52 + mySegments: [], 53 + initialize: async () => { 54 + let [url, userMutedStr, chatWarningStr] = await Promise.all([ 55 + storage.getItem(URL_KEY), 56 + storage.getItem(USER_MUTED_KEY), 57 + storage.getItem(CHAT_WARNING_KEY), 58 + ]); 59 + if (!url) { 60 + url = DEFAULT_URL; 61 + } 62 + let userMuted: boolean | null = null; 63 + console.log("userMutedStr", userMutedStr); 64 + if (typeof userMutedStr === "string") { 65 + userMuted = userMutedStr === "true"; 66 + } else { 67 + userMuted = null; 68 + } 69 + let chatWarned: boolean = false; 70 + if (typeof chatWarningStr === "string") { 71 + chatWarned = chatWarningStr === "true"; 72 + } 73 + set({ url, userMuted, chatWarned, initialized: true }); 74 + }, 75 + setURL: (url: string) => { 76 + console.log("setURL", url); 77 + storage.setItem(URL_KEY, url).catch((err) => { 78 + console.error("setURL error", err); 79 + }); 80 + set({ url }); 81 + }, 82 + userMute: (muted: boolean) => { 83 + storage.setItem(USER_MUTED_KEY, JSON.stringify(muted)).catch((err) => { 84 + console.error("userMute error", err); 85 + }); 86 + set({ userMuted: muted }); 87 + }, 88 + chatWarn: (warned: boolean) => { 89 + storage.setItem(CHAT_WARNING_KEY, JSON.stringify(warned)).catch((err) => { 90 + console.error("chatWarn error", err); 91 + }); 92 + set({ chatWarned: warned }); 93 + }, 94 + getIdentity: async () => { 95 + const state = get() as StreamplaceSlice; 96 + const res = await fetch(`${state.url}/api/identity`); 97 + const identity = await res.json(); 98 + set({ identity }); 99 + }, 100 + pollMySegments: async () => { 101 + try { 102 + const state = get() as any; // need to access bluesky slice 103 + if (!state.pdsAgent) { 104 + throw new Error("no pdsAgent"); 105 + } 106 + if (!state.oauthSession) { 107 + throw new Error("no oauthSession"); 108 + } 109 + const result = await state.pdsAgent.place.stream.live.getSegments({ 110 + userDID: state.oauthSession?.did ?? "", 111 + }); 112 + set({ mySegments: result.data.segments ?? [] }); 113 + } catch (err) { 114 + // silently fail 115 + } 116 + }, 117 + });
-60
js/app/store/store.tsx
··· 1 - import type { Action, ThunkAction } from "@reduxjs/toolkit"; 2 - import { combineSlices, configureStore } from "@reduxjs/toolkit"; 3 - import { setupListeners } from "@reduxjs/toolkit/query"; 4 - import { baseSlice } from "features/base/baseSlice"; 5 - import { sidebarSlice } from "features/base/sidebarSlice"; 6 - import { blueskySlice } from "features/bluesky/blueskySlice"; 7 - import { contentMetadataSlice } from "features/bluesky/contentMetadataSlice"; 8 - import { platformSlice } from "features/platform/platformSlice"; 9 - import { streamplaceSlice } from "features/streamplace/streamplaceSlice"; 10 - 11 - import { listenerMiddleware } from "./listener"; 12 - 13 - const rootReducer = combineSlices( 14 - blueskySlice, 15 - contentMetadataSlice, 16 - streamplaceSlice, 17 - platformSlice, 18 - sidebarSlice, 19 - baseSlice, 20 - ); 21 - 22 - export type RootState = ReturnType<typeof rootReducer>; 23 - 24 - export const makeStore = (preloadedState?: Partial<RootState>) => { 25 - const store = configureStore({ 26 - reducer: rootReducer, 27 - // Adding the api middleware enables caching, invalidation, polling, 28 - // and other useful features of `rtk-query`. 29 - middleware: (getDefaultMiddleware) => { 30 - return getDefaultMiddleware({ 31 - serializableCheck: { 32 - // Ignore these action types 33 - ignoredActions: [], 34 - // Ignore these field paths in all actions 35 - ignoredActionPaths: ["payload"], 36 - // Ignore these paths in the state 37 - ignoredPaths: [/^bluesky\..*/, /^streamplace\..*/], 38 - }, 39 - }).prepend(listenerMiddleware.middleware); 40 - }, 41 - preloadedState, 42 - }); 43 - // configure listeners using the provided defaults 44 - // optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors 45 - setupListeners(store.dispatch); 46 - return store; 47 - }; 48 - 49 - export const store = makeStore(); 50 - 51 - // Infer the type of `store` 52 - export type AppStore = typeof store; 53 - // Infer the `AppDispatch` type from the store itself 54 - export type AppDispatch = AppStore["dispatch"]; 55 - export type AppThunk<ThunkReturnType = void> = ThunkAction< 56 - ThunkReturnType, 57 - RootState, 58 - unknown, 59 - Action 60 - >;
+10 -76
pnpm-lock.yaml
··· 102 102 '@react-navigation/native-stack': 103 103 specifier: ^6.11.0 104 104 version: 6.11.0(@react-navigation/native@6.1.18(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-safe-area-context@5.4.1(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 105 - '@reduxjs/toolkit': 106 - specifier: ^2.3.0 107 - version: 2.3.0(react-redux@9.1.2(@types/react@18.3.12)(react@19.0.0)(redux@5.0.1))(react@19.0.0) 108 105 '@sentry/react-native': 109 106 specifier: ^6.14.0 110 107 version: 6.14.0(encoding@0.1.13)(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) ··· 279 276 react-native-webview: 280 277 specifier: 13.15.0 281 278 version: 13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 282 - react-redux: 283 - specifier: ^9.1.2 284 - version: 9.1.2(@types/react@18.3.12)(react@19.0.0)(redux@5.0.1) 285 279 react-use-websocket: 286 280 specifier: ^4.13.0 287 281 version: 4.13.0 ··· 309 303 viem: 310 304 specifier: ^2.21.44 311 305 version: 2.21.44(bufferutil@4.0.8)(typescript@5.3.3)(utf-8-validate@5.0.10)(zod@3.24.4) 306 + zustand: 307 + specifier: ^5.0.5 308 + version: 5.0.5(@types/react@18.3.12)(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 312 309 devDependencies: 313 310 '@babel/core': 314 311 specifier: ^7.26.0 ··· 535 532 version: 2.21.44(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.4) 536 533 zustand: 537 534 specifier: ^5.0.5 538 - version: 5.0.5(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 535 + version: 5.0.5(@types/react@18.3.12)(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 539 536 devDependencies: 540 537 '@fluent/syntax': 541 538 specifier: ^0.19.0 ··· 3712 3709 resolution: {integrity: sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==} 3713 3710 engines: {node: '>=18'} 3714 3711 3715 - '@reduxjs/toolkit@2.3.0': 3716 - resolution: {integrity: sha512-WC7Yd6cNGfHx8zf+iu+Q1UPTfEcXhQ+ATi7CV1hlrSAaQBdlPzg7Ww/wJHNQem7qG9rxmWoFCDCPubSvFObGzA==} 3717 - peerDependencies: 3718 - react: ^16.9.0 || ^17.0.0 || ^18 3719 - react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 3720 - peerDependenciesMeta: 3721 - react: 3722 - optional: true 3723 - react-redux: 3724 - optional: true 3725 - 3726 3712 '@reforged/maker-appimage@5.0.0': 3727 3713 resolution: {integrity: sha512-25nli9nt5MVMRladnoJ3uP5W+2KpND5mzA36rc/Duj/R71oGcOj3t9Uoc/dDmaED8afAEeaSYpVE7VPPe9T54A==} 3728 3714 engines: {node: '>=19.0.0 || ^18.11.0'} ··· 4803 4789 4804 4790 '@types/unist@3.0.3': 4805 4791 resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} 4806 - 4807 - '@types/use-sync-external-store@0.0.3': 4808 - resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} 4809 4792 4810 4793 '@types/uuid@10.0.0': 4811 4794 resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} ··· 10681 10664 '@types/react': 10682 10665 optional: true 10683 10666 10684 - react-redux@9.1.2: 10685 - resolution: {integrity: sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==} 10686 - peerDependencies: 10687 - '@types/react': ^18.2.25 10688 - react: ^18.0 10689 - redux: ^5.0.0 10690 - peerDependenciesMeta: 10691 - '@types/react': 10692 - optional: true 10693 - redux: 10694 - optional: true 10695 - 10696 10667 react-refresh@0.14.2: 10697 10668 resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} 10698 10669 engines: {node: '>=0.10.0'} ··· 10845 10816 resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} 10846 10817 engines: {node: '>=4'} 10847 10818 10848 - redux-thunk@3.1.0: 10849 - resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} 10850 - peerDependencies: 10851 - redux: ^5.0.0 10852 - 10853 - redux@5.0.1: 10854 - resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} 10855 - 10856 10819 regenerate-unicode-properties@10.2.0: 10857 10820 resolution: {integrity: sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==} 10858 10821 engines: {node: '>=4'} ··· 10972 10935 resedit@2.0.2: 10973 10936 resolution: {integrity: sha512-UKTnq602iVe+W5SyRAQx/WdWMnlDiONfXBLFg/ur4QE4EQQ8eP7Jgm5mNXdK12kKawk1vvXPja2iXKqZiGDW6Q==} 10974 10937 engines: {node: '>=14', npm: '>=7'} 10975 - 10976 - reselect@5.1.1: 10977 - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} 10978 10938 10979 10939 resolve-alpn@1.2.1: 10980 10940 resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} ··· 17698 17658 17699 17659 '@readme/openapi-schemas@3.1.0': {} 17700 17660 17701 - '@reduxjs/toolkit@2.3.0(react-redux@9.1.2(@types/react@18.3.12)(react@19.0.0)(redux@5.0.1))(react@19.0.0)': 17702 - dependencies: 17703 - immer: 10.1.1 17704 - redux: 5.0.1 17705 - redux-thunk: 3.1.0(redux@5.0.1) 17706 - reselect: 5.1.1 17707 - optionalDependencies: 17708 - react: 19.0.0 17709 - react-redux: 9.1.2(@types/react@18.3.12)(react@19.0.0)(redux@5.0.1) 17710 - 17711 17661 '@reforged/maker-appimage@5.0.0': 17712 17662 dependencies: 17713 17663 '@electron-forge/maker-base': 7.5.0 ··· 17748 17698 '@rn-primitives/portal@1.3.0(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0))': 17749 17699 dependencies: 17750 17700 react: 19.0.0 17751 - zustand: 5.0.5(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 17701 + zustand: 5.0.5(@types/react@18.3.12)(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 17752 17702 optionalDependencies: 17753 17703 react-native: 0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 17754 17704 react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) ··· 19014 18964 '@types/unist@2.0.11': {} 19015 18965 19016 18966 '@types/unist@3.0.3': {} 19017 - 19018 - '@types/use-sync-external-store@0.0.3': {} 19019 18967 19020 18968 '@types/uuid@10.0.0': {} 19021 18969 ··· 23270 23218 dependencies: 23271 23219 queue: 6.0.2 23272 23220 23273 - immer@10.1.1: {} 23221 + immer@10.1.1: 23222 + optional: true 23274 23223 23275 23224 import-fresh@2.0.0: 23276 23225 dependencies: ··· 26831 26780 - supports-color 26832 26781 - utf-8-validate 26833 26782 26834 - react-redux@9.1.2(@types/react@18.3.12)(react@19.0.0)(redux@5.0.1): 26835 - dependencies: 26836 - '@types/use-sync-external-store': 0.0.3 26837 - react: 19.0.0 26838 - use-sync-external-store: 1.2.2(react@19.0.0) 26839 - optionalDependencies: 26840 - '@types/react': 18.3.12 26841 - redux: 5.0.1 26842 - 26843 26783 react-refresh@0.14.2: {} 26844 26784 26845 26785 react-remove-scroll-bar@2.3.8(react@19.0.0): ··· 27017 26957 dependencies: 27018 26958 redis-errors: 1.2.0 27019 26959 27020 - redux-thunk@3.1.0(redux@5.0.1): 27021 - dependencies: 27022 - redux: 5.0.1 27023 - 27024 - redux@5.0.1: {} 27025 - 27026 26960 regenerate-unicode-properties@10.2.0: 27027 26961 dependencies: 27028 26962 regenerate: 1.4.2 ··· 27198 27132 resedit@2.0.2: 27199 27133 dependencies: 27200 27134 pe-library: 1.0.1 27201 - 27202 - reselect@5.1.1: {} 27203 27135 27204 27136 resolve-alpn@1.2.1: {} 27205 27137 ··· 28685 28617 use-sync-external-store@1.2.2(react@19.0.0): 28686 28618 dependencies: 28687 28619 react: 19.0.0 28620 + optional: true 28688 28621 28689 28622 username@5.1.0: 28690 28623 dependencies: ··· 29279 29212 29280 29213 zod@3.24.4: {} 29281 29214 29282 - zustand@5.0.5(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)): 29215 + zustand@5.0.5(@types/react@18.3.12)(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)): 29283 29216 optionalDependencies: 29217 + '@types/react': 18.3.12 29284 29218 immer: 10.1.1 29285 29219 react: 19.0.0 29286 29220 use-sync-external-store: 1.2.2(react@19.0.0)