Live video on the AT Protocol
79
fork

Configure Feed

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

Merge pull request #235 from streamplace/natb/key-mgr-attempt-2

settings: add key manager (attempt 2)

authored by

natalie and committed by
GitHub
72dbaf0f 1dcab4ca

+642 -100
+52
js/app/components/button-selector.tsx
··· 1 + import { Button, Text, XStack, YStack, YStackProps } from "tamagui"; 2 + 3 + export default function ButtonSelector({ 4 + text, 5 + values, 6 + selectedValue, 7 + setSelectedValue, 8 + ...props 9 + }: { 10 + text?: string; 11 + values: { label: string; value: string }[]; 12 + selectedValue: string; 13 + setSelectedValue: (value: any) => void; 14 + } & YStackProps) { 15 + return ( 16 + <YStack ai="flex-start" gap="$2" pt="$2" {...props}> 17 + {text && ( 18 + <Text fontSize="$base" fontWeight="semibold"> 19 + {text} 20 + </Text> 21 + )} 22 + <XStack 23 + ai="center" 24 + jc="space-around" 25 + gap="$1" 26 + w="100%" 27 + bg="$background" 28 + borderRadius="$xl" 29 + > 30 + {values.map(({ label, value }) => ( 31 + <Button 32 + key={value} 33 + onPress={() => setSelectedValue(value)} 34 + f={1} 35 + height="$2" 36 + variant={selectedValue === value ? "outlined" : undefined} 37 + > 38 + <Text 39 + color={ 40 + selectedValue === value 41 + ? "$color.foreground" 42 + : "$color.mutedForeground" 43 + } 44 + > 45 + {label} 46 + </Text> 47 + </Button> 48 + ))} 49 + </XStack> 50 + </YStack> 51 + ); 52 + }
+192
js/app/components/settings/key-manager.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 2 + import { RefreshCcw, X } from "@tamagui/lucide-icons"; 3 + import AQLink from "components/aqlink"; 4 + import Loading from "components/loading/loading"; 5 + import { 6 + deleteStreamKeyRecord, 7 + getStreamKeyRecords, 8 + selectKeyRecords, 9 + } from "features/bluesky/blueskySlice"; 10 + import { useEffect, useState } from "react"; 11 + import { useAppDispatch, useAppSelector } from "store/hooks"; 12 + import { PlaceStreamKey } from "streamplace"; 13 + import { 14 + Button, 15 + ScrollView, 16 + Spinner, 17 + Text, 18 + View, 19 + XStack, 20 + YStack, 21 + } from "tamagui"; 22 + import { timeAgo } from "utils/timeAgo"; 23 + 24 + function KeyRow({ 25 + keyRecord, 26 + rkey, 27 + deleteKeyRecord, 28 + isDeleting, 29 + }: { 30 + keyRecord: PlaceStreamKey.Record; 31 + rkey: string; 32 + deleteKeyRecord: (rkey: string) => void; 33 + isDeleting: boolean; 34 + }) { 35 + return ( 36 + <XStack 37 + justifyContent="space-between" 38 + alignItems="stretch" 39 + gap="$4" 40 + opacity={isDeleting ? 0.5 : 1} 41 + pointerEvents={isDeleting ? "none" : "auto"} 42 + position="relative" 43 + > 44 + <View 45 + flexDirection="row" 46 + $xs={{ flexDirection: "column", marginBottom: "$4" }} 47 + gap="$2" 48 + > 49 + {keyRecord?.signingKey && ( 50 + <Text 51 + fontFamily="$mono" 52 + fontSize="$2" 53 + $xs={{ width: "$14" }} 54 + ellipse 55 + numberOfLines={1} 56 + > 57 + {keyRecord?.signingKey} 58 + </Text> 59 + )} 60 + {keyRecord?.createdAt && ( 61 + <Text fontSize="$2" f={1}> 62 + made {timeAgo(new Date(keyRecord.createdAt))} 63 + </Text> 64 + )} 65 + </View> 66 + <Button 67 + aria-label="Delete" 68 + size="$3" 69 + aspectRatio={1 / 1} 70 + padding="$2" 71 + hoverStyle={{ backgroundColor: "#f46" }} 72 + onPress={() => deleteKeyRecord(rkey)} 73 + disabled={isDeleting} 74 + > 75 + {isDeleting ? <Spinner size="small" /> : <X />} 76 + </Button> 77 + </XStack> 78 + ); 79 + } 80 + 81 + export default function KeyManager() { 82 + const dispatch = useAppDispatch(); 83 + const keyObj = useAppSelector(selectKeyRecords); 84 + const keyRecords = keyObj?.records || null; 85 + const navigation = useNavigation(); 86 + 87 + const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set()); 88 + const deleteKeyRecord = (rkey: string) => { 89 + if (deletingKeys.has(rkey)) return; // Prevent double deletes 90 + setDeletingKeys((prev) => new Set(prev).add(rkey)); 91 + dispatch(deleteStreamKeyRecord({ rkey })).finally(() => { 92 + setDeletingKeys((prev) => { 93 + const newSet = new Set(prev); 94 + newSet.delete(rkey); 95 + return newSet; 96 + }); 97 + }); 98 + }; 99 + 100 + useEffect(() => { 101 + // delay 500ms to allow the screen to render 102 + setTimeout(() => { 103 + dispatch(getStreamKeyRecords()); 104 + }, 500); 105 + }, []); 106 + 107 + navigation.setOptions({ title: `Key Manager` }); 108 + 109 + return ( 110 + <ScrollView justifyContent="flex-start" alignItems="center"> 111 + <YStack f={1} p="$4" gap="$4" maxWidth={650}> 112 + {keyRecords === null || keyObj === null ? ( 113 + <Loading /> 114 + ) : keyRecords.records.length === 0 ? ( 115 + <> 116 + <Text mt="$8">No keys here!</Text> 117 + <AQLink to={{ screen: "LiveDashboard" }}> 118 + <Text fontSize="$2" color="$color.blue7Light"> 119 + Go to the live dashboard to create a key. 120 + </Text> 121 + </AQLink> 122 + <Button 123 + aria-label="Refresh" 124 + size="$3" 125 + padding="$2" 126 + onPress={() => dispatch(getStreamKeyRecords())} 127 + > 128 + <RefreshCcw /> 129 + </Button> 130 + </> 131 + ) : keyObj.loading == true || keyRecords === null ? ( 132 + <Loading /> 133 + ) : keyRecords.records.length === 0 ? ( 134 + <> 135 + <Text mt="$8">No keys here!</Text> 136 + <AQLink to={{ screen: "LiveDashboard" }}> 137 + <Text fontSize="$2" color="$color.blue7Light"> 138 + Go to the live dashboard to create a key. 139 + </Text> 140 + </AQLink> 141 + </> 142 + ) : ( 143 + <> 144 + <YStack 145 + gap="$2" 146 + borderBottomWidth={1} 147 + borderBottomColor="$color.gray3Dark" 148 + pb="$2" 149 + mb="$2" 150 + > 151 + <YStack 152 + gap="$2" 153 + borderBottomWidth={1} 154 + borderBottomColor="$color.gray3Dark" 155 + pb="$2" 156 + mb="$2" 157 + > 158 + <Text fontSize="$8">Your Stream Pubkeys</Text> 159 + <Text fontSize="$2" color="$color.gray11Dark"> 160 + A pubkey is a pair to one of your stream keys. You can revoke 161 + access for a specific stream key by revoking its associated 162 + pubkey below. 163 + </Text> 164 + </YStack> 165 + <YStack gap="$2"> 166 + {keyRecords.records.map((keyRecord) => { 167 + const rkey = keyRecord.uri.split("/").pop() as string; 168 + return ( 169 + <KeyRow 170 + rkey={rkey} 171 + keyRecord={keyRecord.value as any} 172 + deleteKeyRecord={deleteKeyRecord} 173 + isDeleting={deletingKeys.has(rkey)} 174 + /> 175 + ); 176 + })} 177 + </YStack> 178 + <Text fontSize="$2" color="$color.gray11Dark"> 179 + {keyRecords.records.length} key 180 + {keyRecords.records.length > 1 && "s"} 181 + </Text> 182 + </YStack> 183 + 184 + <Text fontSize="$2" color="$color.gray11Dark"> 185 + Go to the live dashboard to create a key. 186 + </Text> 187 + </> 188 + )} 189 + </YStack> 190 + </ScrollView> 191 + ); 192 + }
+113 -90
js/app/components/settings/settings.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 2 + import { ArrowRight } from "@tamagui/lucide-icons"; 3 + import AQLink from "components/aqlink"; 4 + import Container from "components/container"; 1 5 import { 2 6 DEFAULT_URL, 3 7 selectTelemetry, ··· 6 10 } from "features/streamplace/streamplaceSlice"; 7 11 import useStreamplaceNode from "hooks/useStreamplaceNode"; 8 12 import { useEffect, useState } from "react"; 9 - import { Switch } from "react-native"; 10 13 import { useAppDispatch, useAppSelector } from "store/hooks"; 11 - import { Button, Form, H3, Input, Text, View, XStack, isWeb } from "tamagui"; 14 + import { Button, H3, H5, Input, Switch, Text, View, XStack } from "tamagui"; 12 15 import { Updates } from "./updates"; 13 16 14 17 export function Settings() { ··· 18 21 const [newUrl, setNewUrl] = useState(""); 19 22 const [overrideEnabled, setOverrideEnabled] = useState(false); 20 23 24 + // are we logged in? 25 + const loggedIn = useAppSelector( 26 + (state) => state.bluesky.status === "loggedIn", 27 + ); 28 + 29 + const navigate = useNavigation(); 30 + 21 31 // Initialize the override state based on current URL 22 32 useEffect(() => { 23 33 setOverrideEnabled(url !== defaultUrl); 24 34 }, [url, defaultUrl]); 25 35 26 - const onSubmit = () => { 36 + const onSubmitUrl = () => { 27 37 if (newUrl) { 28 38 dispatch(setURL(newUrl)); 29 39 setNewUrl(""); ··· 38 48 }; 39 49 40 50 const telemetry = useAppSelector(selectTelemetry); 51 + 52 + const handleTelemetryToggle = (checked: boolean) => { 53 + dispatch(telemetryOpt(checked)); 54 + }; 41 55 42 56 return ( 43 - <View f={1} alignItems="stretch" justifyContent="center" fg={1}> 44 - <Updates /> 45 - <Form 46 - fg={1} 47 - flexBasis={0} 48 - alignItems="center" 49 - justifyContent="center" 50 - padding="$4" 51 - onSubmit={onSubmit} 57 + <Container alignItems="center" justifyContent="center"> 58 + <View 59 + f={1} 60 + alignItems="stretch" 61 + justifyContent="flex-start" 62 + mt="$8" 63 + maxWidth={500} 64 + $platform-web={{ width: "100%" }} 65 + gap="$6" 52 66 > 53 - <View 54 - alignItems="center" 55 - justifyContent="center" 56 - gap="$2" 57 - fg={1} 58 - flexBasis={0} 59 - backgroundColor="rgba(0, 0, 0, 0.1)" 60 - > 61 - <XStack alignItems="center" justifyContent="space-around"> 62 - <View> 63 - <XStack width={isWeb ? "100%" : "75%"}> 64 - <H3 fontSize="$8">Use custom node</H3> 65 - <Switch 66 - accessibilityLabel="Use custom node" 67 - accessibilityHint="Toggle to use a custom node" 68 - style={{ 69 - transform: [{ scaleX: 1.2 }, { scaleY: 1.2 }], 70 - marginLeft: 20, 71 - marginTop: isWeb ? 8 : 4, 72 - }} 73 - value={overrideEnabled} 74 - onValueChange={handleToggleOverride} 75 - /> 76 - </XStack> 77 - <Text 78 - fontSize="$6" 79 - color="$gray10" 80 - style={{ opacity: overrideEnabled ? 0 : 1 }} 81 - numberOfLines={1} 82 - ellipsizeMode="middle" 83 - maxWidth={280} 67 + <View maxHeight={200}> 68 + <Updates /> 69 + </View> 70 + 71 + <View alignItems="center" justifyContent="center" gap="$4"> 72 + <XStack 73 + alignItems="stretch" 74 + justifyContent="space-between" 75 + width="100%" 76 + flexDirection="column" 77 + > 78 + <View 79 + flexDirection="row" 80 + alignItems="center" 81 + justifyContent="space-between" 82 + flex={1} 83 + > 84 + <View flex={1} pr="$3"> 85 + <H3 fontSize="$7">Use Custom Node</H3> 86 + <Text fontSize="$5" color="$gray10"> 87 + Default: {url} 88 + </Text> 89 + </View> 90 + <Switch 91 + size="small" 92 + checked={overrideEnabled} 93 + onCheckedChange={handleToggleOverride} 84 94 > 85 - Default node: {url} 86 - </Text> 95 + <Switch.Thumb animation="bouncy" /> 96 + </Switch> 87 97 </View> 88 98 </XStack> 89 99 100 + {/* Custom URL Input Row */} 90 101 <XStack 91 - alignItems="stretch" 102 + alignItems="center" // Changed to center 92 103 gap="$2" 93 - width={isWeb ? "100%" : "75%"} 94 104 style={{ 95 105 opacity: overrideEnabled ? 1 : 0, 96 - marginTop: -15, 106 + height: overrideEnabled ? "auto" : 0, // Collapse when hidden 107 + overflow: "hidden", // Hide overflow when collapsed 108 + transition: "opacity 0.2s ease-in-out, height 0.2s ease-in-out", 97 109 }} 98 110 > 99 111 <Input 100 112 value={newUrl} 101 113 flex={1} 102 - size="$3" 103 - placeholder={url} 104 - onChangeText={(t) => setNewUrl(t)} 105 - onSubmitEditing={onSubmit} 114 + size="$4" 115 + placeholder={url || "Enter custom node URL"} 116 + onChangeText={setNewUrl} 117 + onSubmitEditing={onSubmitUrl} 106 118 textContentType="URL" 107 119 autoCapitalize="none" 108 120 autoCorrect={false} 121 + keyboardType="url" 109 122 /> 110 - <Form.Trigger asChild> 111 - <Button size="$3">SAVE</Button> 112 - </Form.Trigger> 123 + <Button size="$4" onPress={onSubmitUrl}> 124 + <Text>SAVE</Text> 125 + </Button> 113 126 </XStack> 114 127 </View> 115 - </Form> 116 - <View 117 - alignItems="center" 118 - justifyContent="center" 119 - gap="$2" 120 - fg={1} 121 - flexBasis={0} 122 - > 123 - <XStack alignItems="center" gap="$6"> 124 - <View> 125 - <H3 fontSize="$8">Player Telemetry</H3> 126 - <Text 127 - fontSize="$6" 128 - color="$gray10" 129 - style={{ position: "absolute", bottom: -15 }} 128 + 129 + <View alignItems="center" justifyContent="center" gap="$4"> 130 + <XStack 131 + alignItems="center" 132 + justifyContent="space-between" 133 + width="100%" 134 + > 135 + <View flex={1} pr="$3"> 136 + <H3 fontSize="$7">Player Telemetry</H3> 137 + <Text fontSize="$5" color="$gray10"> 138 + Optional 139 + </Text> 140 + </View> 141 + <Switch 142 + size="$3" 143 + checked={telemetry === true} 144 + onCheckedChange={handleTelemetryToggle} 145 + theme="purple" 130 146 > 131 - Optional 132 - </Text> 133 - </View> 134 - <Switch 135 - accessibilityLabel="Player Telemetry" 136 - accessibilityHint="Toggle to enable player telemetry" 137 - style={{ 138 - transform: [{ scaleX: 1.2 }, { scaleY: 1.2 }], 139 - marginTop: isWeb ? 0 : 8, 140 - }} 141 - value={telemetry === true} 142 - onValueChange={(checked) => { 143 - if (checked === true) { 144 - dispatch(telemetryOpt(true)); 145 - } else { 146 - dispatch(telemetryOpt(false)); 147 - } 147 + <Switch.Thumb animation="bouncy" /> 148 + </Switch> 149 + </XStack> 150 + </View> 151 + 152 + {loggedIn && ( 153 + <AQLink 154 + to={{ 155 + screen: "KeyManagement", 148 156 }} 149 - /> 150 - </XStack> 157 + > 158 + <View 159 + flexDirection="row" 160 + gap="$2" 161 + alignItems="center" 162 + justifyContent="center" 163 + borderWidth={1} 164 + borderColor="$color.gray3Dark" 165 + padding="$2" 166 + borderRadius="$4" 167 + backgroundColor="$color.gray1Dark" 168 + > 169 + <H5>Manage Keys</H5> 170 + <ArrowRight size="$1" /> 171 + </View> 172 + </AQLink> 173 + )} 151 174 </View> 152 - </View> 175 + </Container> 153 176 ); 154 177 }
+1 -7
js/app/components/settings/updates.tsx
··· 4 4 // maybe someday some PWA update stuff will live here 5 5 export function Updates() { 6 6 return ( 7 - <View 8 - f={1} 9 - alignItems="center" 10 - justifyContent="center" 11 - fg={1} 12 - flexBasis={0} 13 - > 7 + <View alignItems="center" justifyContent="center" py="$6"> 14 8 <View> 15 9 <H2 textAlign="center">Streamplace v{pkg.version}</H2> 16 10 </View>
+159
js/app/features/bluesky/blueskySlice.tsx
··· 16 16 setURL, 17 17 StreamplaceState, 18 18 } from "features/streamplace/streamplaceSlice"; 19 + import { Platform } from "react-native"; 19 20 import Storage from "storage"; 20 21 import { 21 22 LivestreamViewHydrated, ··· 55 56 }, 56 57 newKey: null, 57 58 storedKey: null, 59 + isDeletingKey: false, 60 + streamKeysResponse: { 61 + loading: true, 62 + error: null, 63 + records: null, 64 + }, 58 65 newLivestream: null, 59 66 }; 60 67 ··· 610 617 did: keypair.did(), 611 618 address: account.address.toLowerCase(), 612 619 }; 620 + 621 + let platform: string = Platform.OS; 622 + 623 + // window only exists on web 624 + if (Platform.OS === "web" && window && window.navigator) { 625 + let splitUA = window.navigator.userAgent 626 + .split(" ") 627 + .pop() 628 + ?.split("/")[0]; 629 + if (splitUA) { 630 + platform = splitUA; 631 + } 632 + // proper capitalization 633 + } else if (platform === "android") { 634 + platform = "Android"; 635 + } else if (platform === "ios") { 636 + platform = "iOS"; 637 + } else if (platform === "macos") { 638 + platform = "macOS"; 639 + } else if (platform === "windows") { 640 + platform = "Windows"; 641 + } 642 + 613 643 const record: PlaceStreamKey.Record = { 614 644 signingKey: keypair.did(), 615 645 createdAt: new Date().toISOString(), 646 + createdBy: "Streamplace on " + platform, 616 647 }; 617 648 await bluesky.pdsAgent.com.atproto.repo.createRecord({ 618 649 repo: did, ··· 649 680 }; 650 681 }), 651 682 683 + getStreamKeyRecords: create.asyncThunk( 684 + async (_, thunkAPI) => { 685 + const { bluesky } = thunkAPI.getState() as { 686 + bluesky: BlueskyState; 687 + }; 688 + if (!bluesky.pdsAgent) { 689 + throw new Error("No agent"); 690 + } 691 + const did = bluesky.oauthSession?.did; 692 + if (!did) { 693 + throw new Error("No DID"); 694 + } 695 + const profile = bluesky.profiles[did]; 696 + if (!profile) { 697 + throw new Error("No profile"); 698 + } 699 + if (!did) { 700 + throw new Error("No DID"); 701 + } 702 + return await bluesky.pdsAgent.com.atproto.repo.listRecords({ 703 + repo: did, 704 + collection: "place.stream.key", 705 + limit: 100, 706 + }); 707 + }, 708 + { 709 + pending: (state) => { 710 + return { 711 + ...state, 712 + streamKeysResponse: { 713 + loading: true, 714 + error: null, 715 + records: null, 716 + }, 717 + }; 718 + }, 719 + fulfilled: (state, action) => { 720 + console.log(action.payload); 721 + return { 722 + ...state, 723 + streamKeysResponse: { 724 + loading: false, 725 + error: null, 726 + records: action.payload.data, 727 + }, 728 + }; 729 + }, 730 + rejected: (state, action) => { 731 + console.error("listStreamKeyRecords rejected", action.error); 732 + 733 + return { 734 + ...state, 735 + streamKeysResponse: { 736 + loading: false, 737 + error: action.error?.message ?? null, 738 + records: null, 739 + }, 740 + }; 741 + }, 742 + }, 743 + ), 744 + 745 + deleteStreamKeyRecord: create.asyncThunk( 746 + async ({ rkey }: { rkey: string }, thunkAPI) => { 747 + const { bluesky } = thunkAPI.getState() as { 748 + bluesky: BlueskyState; 749 + }; 750 + if (!bluesky.pdsAgent) { 751 + throw new Error("No agent"); 752 + } 753 + const did = bluesky.oauthSession?.did; 754 + if (!did) { 755 + throw new Error("No DID"); 756 + } 757 + const profile = bluesky.profiles[did]; 758 + if (!profile) { 759 + throw new Error("No profile"); 760 + } 761 + if (!did) { 762 + throw new Error("No DID"); 763 + } 764 + 765 + return await bluesky.pdsAgent.com.atproto.repo.deleteRecord({ 766 + repo: did, 767 + collection: "place.stream.key", 768 + rkey, 769 + }); 770 + }, 771 + { 772 + pending: (state) => { 773 + return { 774 + ...state, 775 + isDeletingKey: true, 776 + }; 777 + }, 778 + fulfilled: (state, action) => { 779 + let records = state.streamKeysResponse.records 780 + ? state.streamKeysResponse.records.records.filter( 781 + (r) => r.uri.split("/").pop() !== action.meta.arg.rkey, 782 + ) 783 + : []; 784 + 785 + return { 786 + ...state, 787 + isDeletingKey: false, 788 + streamKeysResponse: { 789 + ...state.streamKeysResponse, 790 + records: { 791 + ...state.streamKeysResponse.records, 792 + records, 793 + }, 794 + }, 795 + }; 796 + }, 797 + rejected: (state, action) => { 798 + console.error("deleteStreamKeyRecord rejected", action.error); 799 + return { 800 + ...state, 801 + isDeletingKey: false, 802 + }; 803 + }, 804 + }, 805 + ), 806 + 652 807 setPDS: create.asyncThunk( 653 808 async (pds: string, thunkAPI) => { 654 809 await Storage.setItem("pdsURL", pds); ··· 1139 1294 selectLogin: (bluesky) => bluesky.login, 1140 1295 selectProfiles: (bluesky) => bluesky.profiles, 1141 1296 selectStoredKey: (bluesky) => bluesky.storedKey, 1297 + selectKeyRecords: (bluesky) => bluesky.streamKeysResponse, 1142 1298 selectUserProfile: (bluesky) => { 1143 1299 const did = bluesky.oauthSession?.did; 1144 1300 if (!did) return null; ··· 1179 1335 oauthError, 1180 1336 createStreamKeyRecord, 1181 1337 clearStreamKeyRecord, 1338 + getStreamKeyRecords, 1339 + deleteStreamKeyRecord, 1182 1340 createLivestreamRecord, 1183 1341 updateLivestreamRecord, 1184 1342 createChatProfileRecord, ··· 1196 1354 selectPDS, 1197 1355 selectLogin, 1198 1356 selectStoredKey, 1357 + selectKeyRecords, 1199 1358 selectIsReady, 1200 1359 selectNewLivestream, 1201 1360 selectChatProfile,
+8 -1
js/app/features/bluesky/blueskyTypes.tsx
··· 1 1 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 2 - import { OAuthSession } from "@streamplace/atproto-oauth-client-react-native"; 2 + import { OutputSchema } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords"; 3 + import { OAuthSession } from "@atproto/oauth-client"; 3 4 import { StreamKey } from "features/base/baseSlice"; 4 5 import { 5 6 PlaceStreamChatProfile, ··· 35 36 }; 36 37 newKey: null | StreamKey; 37 38 storedKey: null | StreamKey; 39 + isDeletingKey: boolean; 40 + streamKeysResponse: { 41 + loading: boolean; 42 + error: null | string; 43 + records: null | OutputSchema; 44 + }; 38 45 newLivestream: null | NewLivestream; 39 46 chatProfile: { 40 47 loading: boolean;
+11
js/app/src/router.tsx
··· 73 73 74 74 // probabl should move this 75 75 import SignUp from "components/login/signup"; 76 + import KeyManager from "components/settings/key-manager"; 76 77 import { loadStateFromStorage } from "features/base/sidebarSlice"; 77 78 import { store } from "store/store"; 78 79 import HomeScreen from "./screens/home"; ··· 102 103 Multi: { config: string }; 103 104 Support: undefined; 104 105 Settings: undefined; 106 + KeyManagement: undefined; 105 107 GoLive: undefined; 106 108 LiveDashboard: undefined; 107 109 Login: undefined; ··· 135 137 Multi: "multi/:config", 136 138 Support: "support", 137 139 Settings: "settings", 140 + KeyManagement: "key-management", 138 141 GoLive: "golive", 139 142 LiveDashboard: "live", 140 143 Login: "login", ··· 430 433 }} 431 434 /> 432 435 436 + <Drawer.Screen 437 + name="KeyManagement" 438 + component={KeyManager} 439 + options={{ 440 + drawerLabel: () => <Text>Key Manager</Text>, 441 + drawerItemStyle: { display: "none" }, 442 + }} 443 + /> 433 444 <Drawer.Screen 434 445 name="Support" 435 446 component={SupportScreen}
-1
js/app/src/screens/live-dashboard.tsx
··· 36 36 ); 37 37 38 38 const [playerId, setPlayerId] = useState<string | null>(null); 39 - 40 39 const [page, setPage] = useState<"update" | "create">("create"); 41 40 42 41 const videoRef = useCallback((node: HTMLVideoElement | null) => {
+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 + }
+5
js/docs/src/content/docs/lex-reference/place-stream-key.md
··· 23 23 | ------------ | -------- | ----- | ---------------------------------------------------- | --------------------------------- | 24 24 | `signingKey` | `string` | ✅ | The did:key signing key for the stream. | Min Length: 57<br/>Max Length: 57 | 25 25 | `createdAt` | `string` | ✅ | Client-declared timestamp when this key was created. | Format: `datetime` | 26 + | `createdBy` | `string` | ❌ | The name of the client that created this key. | | 26 27 27 28 --- 28 29 ··· 51 52 "type": "string", 52 53 "format": "datetime", 53 54 "description": "Client-declared timestamp when this key was created." 55 + }, 56 + "createdBy": { 57 + "type": "string", 58 + "description": "The name of the client that created this key." 54 59 } 55 60 } 56 61 }
+4
lexicons/place/stream/key.json
··· 20 20 "type": "string", 21 21 "format": "datetime", 22 22 "description": "Client-declared timestamp when this key was created." 23 + }, 24 + "createdBy": { 25 + "type": "string", 26 + "description": "The name of the client that created this key." 23 27 } 24 28 } 25 29 }
+59 -1
pkg/streamplace/cbor_gen.go
··· 28 28 } 29 29 30 30 cw := cbg.NewCborWriter(w) 31 + fieldCount := 4 31 32 32 - if _, err := cw.Write([]byte{163}); err != nil { 33 + if t.CreatedBy == nil { 34 + fieldCount-- 35 + } 36 + 37 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 33 38 return err 34 39 } 35 40 ··· 75 80 return err 76 81 } 77 82 83 + // t.CreatedBy (string) (string) 84 + if t.CreatedBy != nil { 85 + 86 + if len("createdBy") > 1000000 { 87 + return xerrors.Errorf("Value in field \"createdBy\" was too long") 88 + } 89 + 90 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdBy"))); err != nil { 91 + return err 92 + } 93 + if _, err := cw.WriteString(string("createdBy")); err != nil { 94 + return err 95 + } 96 + 97 + if t.CreatedBy == nil { 98 + if _, err := cw.Write(cbg.CborNull); err != nil { 99 + return err 100 + } 101 + } else { 102 + if len(*t.CreatedBy) > 1000000 { 103 + return xerrors.Errorf("Value in field t.CreatedBy was too long") 104 + } 105 + 106 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedBy))); err != nil { 107 + return err 108 + } 109 + if _, err := cw.WriteString(string(*t.CreatedBy)); err != nil { 110 + return err 111 + } 112 + } 113 + } 114 + 78 115 // t.SigningKey (string) (string) 79 116 if len("signingKey") > 1000000 { 80 117 return xerrors.Errorf("Value in field \"signingKey\" was too long") ··· 162 199 } 163 200 164 201 t.CreatedAt = string(sval) 202 + } 203 + // t.CreatedBy (string) (string) 204 + case "createdBy": 205 + 206 + { 207 + b, err := cr.ReadByte() 208 + if err != nil { 209 + return err 210 + } 211 + if b != cbg.CborNull[0] { 212 + if err := cr.UnreadByte(); err != nil { 213 + return err 214 + } 215 + 216 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 217 + if err != nil { 218 + return err 219 + } 220 + 221 + t.CreatedBy = (*string)(&sval) 222 + } 165 223 } 166 224 // t.SigningKey (string) (string) 167 225 case "signingKey":
+2
pkg/streamplace/streamkey.go
··· 16 16 LexiconTypeID string `json:"$type,const=place.stream.key" cborgen:"$type,const=place.stream.key"` 17 17 // createdAt: Client-declared timestamp when this key was created. 18 18 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 19 + // createdBy: The name of the client that created this key. 20 + CreatedBy *string `json:"createdBy,omitempty" cborgen:"createdBy,omitempty"` 19 21 // signingKey: The did:key signing key for the stream. 20 22 SigningKey string `json:"signingKey" cborgen:"signingKey"` 21 23 }