Live video on the AT Protocol

simple key manager

Natalie B 087e2d7a c451b8c6

+272 -1
+121
js/app/components/settings/keymgr.tsx
··· 1 + import { YStack, XStack, Text, Separator, Button, ScrollView } from "tamagui"; 2 + import { useEffect } from "react"; 3 + import { Dices, X } from "@tamagui/lucide-icons"; 4 + import { 5 + deleteStreamKeyRecord, 6 + getStreamKeyRecords, 7 + selectKeyRecords, 8 + } from "features/bluesky/blueskySlice"; 9 + import { useAppDispatch, useAppSelector } from "store/hooks"; 10 + import { PlaceStreamKey } from "lexicons"; 11 + import Loading from "components/loading/loading"; 12 + import { timeAgo } from "utils/timeAgo"; 13 + import AQLink from "components/aqlink"; 14 + 15 + function KeyRow({ 16 + keyRecord, 17 + rkey, 18 + deleteKeyRecord, 19 + }: { 20 + keyRecord: PlaceStreamKey.Record; 21 + rkey: string; 22 + deleteKeyRecord: (rkey: string) => void; 23 + }) { 24 + return ( 25 + <XStack 26 + style={{ 27 + justifyContent: "space-between", 28 + alignItems: "center", 29 + }} 30 + gap="$4" 31 + > 32 + <XStack gap="$4"> 33 + {keyRecord?.signingKey && ( 34 + <Text 35 + fontFamily="$mono" 36 + fontSize="$2" 37 + $sm={{ width: "$14" }} 38 + ellipse 39 + numberOfLines={1} 40 + > 41 + {keyRecord?.signingKey} 42 + </Text> 43 + )} 44 + {keyRecord?.createdAt && ( 45 + <Text fontSize="$2" f={1}> 46 + made {timeAgo(new Date(keyRecord.createdAt))} 47 + </Text> 48 + )} 49 + </XStack> 50 + <Button 51 + aria-label="Delete" 52 + size="$3" 53 + aspectRatio={1 / 1} 54 + padding="$2" 55 + hoverStyle={{ backgroundColor: "#f46" }} 56 + onPress={() => deleteKeyRecord(rkey)} 57 + > 58 + <X /> 59 + </Button> 60 + </XStack> 61 + ); 62 + } 63 + 64 + export default function KeyManager() { 65 + const dispatch = useAppDispatch(); 66 + const keyRecords = useAppSelector(selectKeyRecords); 67 + 68 + const deleteKeyRecord = (rkey: string) => { 69 + dispatch(deleteStreamKeyRecord({ rkey })); 70 + dispatch(getStreamKeyRecords()); 71 + }; 72 + 73 + useEffect(() => { 74 + dispatch(getStreamKeyRecords()); 75 + }, []); 76 + 77 + return ( 78 + <ScrollView justifyContent="flex-start" alignItems="center"> 79 + <YStack f={1} p="$4" gap="$4" maxWidth={750}> 80 + {keyRecords === null ? ( 81 + <Loading /> 82 + ) : keyRecords.records.length === 0 ? ( 83 + <> 84 + <Text mt="$8">No keys here!</Text> 85 + <AQLink to={{ screen: "LiveDashboard" }}> 86 + <Text fontSize="$2" color="$color.blue7Light"> 87 + Go to the live dashboard to create a key. 88 + </Text> 89 + </AQLink> 90 + </> 91 + ) : ( 92 + <> 93 + <YStack gap="$2"> 94 + <Text fontSize="$8">Existing Pubkeys</Text> 95 + <Text fontSize="$2" color="$color.gray11Dark"> 96 + Your private stream key is the secret credential you use to 97 + stream. Listed are the associated public keys. 98 + </Text> 99 + {keyRecords.records.map((keyRecord) => ( 100 + <KeyRow 101 + rkey={keyRecord.uri.split("/").pop() as string} 102 + keyRecord={keyRecord.value as any} 103 + deleteKeyRecord={deleteKeyRecord} 104 + /> 105 + ))} 106 + <Text fontSize="$2" color="$color.gray11Dark"> 107 + {keyRecords.records.length} key 108 + {keyRecords.records.length > 1 && "s"} 109 + </Text> 110 + </YStack> 111 + <Separator /> 112 + 113 + <Text fontSize="$2" color="$color.gray11Dark"> 114 + Go to the live dashboard to create a key. 115 + </Text> 116 + </> 117 + )} 118 + </YStack> 119 + </ScrollView> 120 + ); 121 + }
+101 -1
js/app/features/bluesky/blueskySlice.tsx
··· 8 8 } from "@atproto/api"; 9 9 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 10 import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; 11 - import { hydrate, STORED_KEY_KEY } from "features/base/baseSlice"; 11 + import { hydrate, STORED_KEY_KEY, StreamKey } from "features/base/baseSlice"; 12 12 import { openLoginLink } from "features/platform/platformSlice"; 13 13 import { 14 14 LivestreamViewHydrated, ··· 62 62 }, 63 63 newKey: null, 64 64 storedKey: null, 65 + isDeletingKey: false, 66 + streamKeysResponse: null, 65 67 newLivestream: null, 66 68 }; 67 69 ··· 814 816 }; 815 817 }), 816 818 819 + getStreamKeyRecords: create.asyncThunk( 820 + async (_, thunkAPI) => { 821 + const { bluesky } = thunkAPI.getState() as { 822 + bluesky: BlueskyState; 823 + }; 824 + if (!bluesky.pdsAgent) { 825 + throw new Error("No agent"); 826 + } 827 + const did = bluesky.oauthSession?.did; 828 + if (!did) { 829 + throw new Error("No DID"); 830 + } 831 + const profile = bluesky.profiles[did]; 832 + if (!profile) { 833 + throw new Error("No profile"); 834 + } 835 + if (!did) { 836 + throw new Error("No DID"); 837 + } 838 + return await bluesky.pdsAgent.com.atproto.repo.listRecords({ 839 + repo: did, 840 + collection: "place.stream.key", 841 + limit: 100, 842 + }); 843 + }, 844 + { 845 + pending: (state) => { 846 + return { 847 + ...state, 848 + streamKeysResponse: null, 849 + }; 850 + }, 851 + fulfilled: (state, action) => { 852 + console.log(action.payload); 853 + return { 854 + ...state, 855 + streamKeysResponse: action.payload.data, 856 + }; 857 + }, 858 + rejected: (state, action) => { 859 + console.error("listStreamKeyRecords rejected", action.error); 860 + }, 861 + }, 862 + ), 863 + 864 + deleteStreamKeyRecord: create.asyncThunk( 865 + async ({ rkey }: { rkey: string }, thunkAPI) => { 866 + const { bluesky } = thunkAPI.getState() as { 867 + bluesky: BlueskyState; 868 + }; 869 + if (!bluesky.pdsAgent) { 870 + throw new Error("No agent"); 871 + } 872 + const did = bluesky.oauthSession?.did; 873 + if (!did) { 874 + throw new Error("No DID"); 875 + } 876 + const profile = bluesky.profiles[did]; 877 + if (!profile) { 878 + throw new Error("No profile"); 879 + } 880 + if (!did) { 881 + throw new Error("No DID"); 882 + } 883 + 884 + return await bluesky.pdsAgent.com.atproto.repo.deleteRecord({ 885 + repo: did, 886 + collection: "place.stream.key", 887 + rkey, 888 + }); 889 + }, 890 + { 891 + pending: (state) => { 892 + return { 893 + ...state, 894 + isDeletingKey: true, 895 + }; 896 + }, 897 + fulfilled: (state, action) => { 898 + return { 899 + ...state, 900 + isDeletingKey: false, 901 + }; 902 + }, 903 + rejected: (state, action) => { 904 + console.error("deleteStreamKeyRecord rejected", action.error); 905 + return { 906 + ...state, 907 + isDeletingKey: false, 908 + }; 909 + }, 910 + }, 911 + ), 912 + 817 913 setPDS: create.asyncThunk( 818 914 async (pds: string, thunkAPI) => { 819 915 await Storage.setItem("pdsURL", pds); ··· 1259 1355 selectLogin: (bluesky) => bluesky.login, 1260 1356 selectProfiles: (bluesky) => bluesky.profiles, 1261 1357 selectStoredKey: (bluesky) => bluesky.storedKey, 1358 + selectKeyRecords: (bluesky) => bluesky.streamKeysResponse, 1262 1359 selectUserProfile: (bluesky) => { 1263 1360 const did = bluesky.oauthSession?.did; 1264 1361 if (!did) return null; ··· 1299 1396 oauthError, 1300 1397 createStreamKeyRecord, 1301 1398 clearStreamKeyRecord, 1399 + getStreamKeyRecords, 1400 + deleteStreamKeyRecord, 1302 1401 createLivestreamRecord, 1303 1402 updateLivestreamRecord, 1304 1403 createChatProfileRecord, ··· 1318 1417 selectPDS, 1319 1418 selectLogin, 1320 1419 selectStoredKey, 1420 + selectKeyRecords, 1321 1421 selectIsReady, 1322 1422 selectNewLivestream, 1323 1423 selectChatProfile,
+3
js/app/features/bluesky/blueskyTypes.tsx
··· 1 1 import { OAuthSession } from "@streamplace/atproto-oauth-client-react-native"; 2 2 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 3 + import { OutputSchema } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords"; 3 4 import { StreamKey } from "features/base/baseSlice"; 4 5 import { PlaceStreamChatProfile, PlaceStreamLivestream } from "streamplace"; 5 6 import { StreamplaceOAuthClient } from "./oauthClient"; ··· 32 33 }; 33 34 newKey: null | StreamKey; 34 35 storedKey: null | StreamKey; 36 + isDeletingKey: boolean; 37 + streamKeysResponse: null | OutputSchema; 35 38 newLivestream: null | NewLivestream; 36 39 chatProfile: { 37 40 loading: boolean;
+11
js/app/src/router.tsx
··· 77 77 import { store } from "store/store"; 78 78 import { loadStateFromStorage } from "features/base/sidebarSlice"; 79 79 import HomeScreen from "./screens/home"; 80 + import KeyManager from "components/settings/keymgr"; 80 81 81 82 store.dispatch(loadStateFromStorage()); 82 83 ··· 92 93 Multi: { config: string }; 93 94 Support: undefined; 94 95 Settings: undefined; 96 + KeyManagement: undefined; 95 97 GoLive: undefined; 96 98 LiveDashboard: undefined; 97 99 Login: undefined; ··· 124 126 Multi: "multi/:config", 125 127 Support: "support", 126 128 Settings: "settings", 129 + KeyManagement: "settings/key-management", 127 130 GoLive: "golive", 128 131 LiveDashboard: "live", 129 132 Login: "login", ··· 421 424 options={{ 422 425 drawerIcon: () => <SettingsIcon />, 423 426 drawerLabel: () => <Text>Settings</Text>, 427 + }} 428 + /> 429 + <Drawer.Screen 430 + name="Key Manager" 431 + component={KeyManager} 432 + options={{ 433 + drawerLabel: () => <Text>Key Manager</Text>, 434 + drawerItemStyle: { display: "none" }, 424 435 }} 425 436 /> 426 437 <Drawer.Screen
+36
js/app/utils/timeAgo.ts
··· 1 + export function timeAgo(date: Date) { 2 + const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000); 3 + let interval = Math.floor(seconds / 31536000); 4 + 5 + // return date.toLocaleDateString("en-US"); 6 + if (interval > 1) { 7 + const formatter = new Intl.DateTimeFormat("en-US", { 8 + year: "numeric", 9 + month: "short", 10 + day: "numeric", 11 + hour: "numeric", 12 + minute: "numeric", 13 + }); 14 + return "on " + formatter.format(date); 15 + } 16 + interval = Math.floor(seconds / 86400); 17 + // return date without years 18 + if (interval > 1) { 19 + const formatter = new Intl.DateTimeFormat("en-US", { 20 + month: "short", 21 + day: "numeric", 22 + hour: "numeric", 23 + minute: "numeric", 24 + }); 25 + return formatter.format(date); 26 + } 27 + interval = Math.floor(seconds / 3600); 28 + if (interval > 1) { 29 + return interval + " hours ago"; 30 + } 31 + interval = Math.floor(seconds / 60); 32 + if (interval > 1) { 33 + return interval + " minutes ago"; 34 + } 35 + return Math.floor(seconds) + " seconds ago"; 36 + }