tangled
alpha
login
or
join now
stream.place
/
streamplace
Live video on the AT Protocol
74
fork
atom
overview
issues
1
pulls
pipelines
simple key manager
Natalie B
8 months ago
087e2d7a
c451b8c6
+272
-1
5 changed files
expand all
collapse all
unified
split
js
app
components
settings
keymgr.tsx
features
bluesky
blueskySlice.tsx
blueskyTypes.tsx
src
router.tsx
utils
timeAgo.ts
+121
js/app/components/settings/keymgr.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
} 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 { hydrate, STORED_KEY_KEY } from "features/base/baseSlice";
12
import { openLoginLink } from "features/platform/platformSlice";
13
import {
14
LivestreamViewHydrated,
···
62
},
63
newKey: null,
64
storedKey: null,
0
0
65
newLivestream: null,
66
};
67
···
814
};
815
}),
816
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
817
setPDS: create.asyncThunk(
818
async (pds: string, thunkAPI) => {
819
await Storage.setItem("pdsURL", pds);
···
1259
selectLogin: (bluesky) => bluesky.login,
1260
selectProfiles: (bluesky) => bluesky.profiles,
1261
selectStoredKey: (bluesky) => bluesky.storedKey,
0
1262
selectUserProfile: (bluesky) => {
1263
const did = bluesky.oauthSession?.did;
1264
if (!did) return null;
···
1299
oauthError,
1300
createStreamKeyRecord,
1301
clearStreamKeyRecord,
0
0
1302
createLivestreamRecord,
1303
updateLivestreamRecord,
1304
createChatProfileRecord,
···
1318
selectPDS,
1319
selectLogin,
1320
selectStoredKey,
0
1321
selectIsReady,
1322
selectNewLivestream,
1323
selectChatProfile,
···
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 { hydrate, STORED_KEY_KEY, StreamKey } from "features/base/baseSlice";
12
import { openLoginLink } from "features/platform/platformSlice";
13
import {
14
LivestreamViewHydrated,
···
62
},
63
newKey: null,
64
storedKey: null,
65
+
isDeletingKey: false,
66
+
streamKeysResponse: null,
67
newLivestream: null,
68
};
69
···
816
};
817
}),
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
+
913
setPDS: create.asyncThunk(
914
async (pds: string, thunkAPI) => {
915
await Storage.setItem("pdsURL", pds);
···
1355
selectLogin: (bluesky) => bluesky.login,
1356
selectProfiles: (bluesky) => bluesky.profiles,
1357
selectStoredKey: (bluesky) => bluesky.storedKey,
1358
+
selectKeyRecords: (bluesky) => bluesky.streamKeysResponse,
1359
selectUserProfile: (bluesky) => {
1360
const did = bluesky.oauthSession?.did;
1361
if (!did) return null;
···
1396
oauthError,
1397
createStreamKeyRecord,
1398
clearStreamKeyRecord,
1399
+
getStreamKeyRecords,
1400
+
deleteStreamKeyRecord,
1401
createLivestreamRecord,
1402
updateLivestreamRecord,
1403
createChatProfileRecord,
···
1417
selectPDS,
1418
selectLogin,
1419
selectStoredKey,
1420
+
selectKeyRecords,
1421
selectIsReady,
1422
selectNewLivestream,
1423
selectChatProfile,
+3
js/app/features/bluesky/blueskyTypes.tsx
···
1
import { OAuthSession } from "@streamplace/atproto-oauth-client-react-native";
2
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
0
3
import { StreamKey } from "features/base/baseSlice";
4
import { PlaceStreamChatProfile, PlaceStreamLivestream } from "streamplace";
5
import { StreamplaceOAuthClient } from "./oauthClient";
···
32
};
33
newKey: null | StreamKey;
34
storedKey: null | StreamKey;
0
0
35
newLivestream: null | NewLivestream;
36
chatProfile: {
37
loading: boolean;
···
1
import { OAuthSession } from "@streamplace/atproto-oauth-client-react-native";
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";
4
import { StreamKey } from "features/base/baseSlice";
5
import { PlaceStreamChatProfile, PlaceStreamLivestream } from "streamplace";
6
import { StreamplaceOAuthClient } from "./oauthClient";
···
33
};
34
newKey: null | StreamKey;
35
storedKey: null | StreamKey;
36
+
isDeletingKey: boolean;
37
+
streamKeysResponse: null | OutputSchema;
38
newLivestream: null | NewLivestream;
39
chatProfile: {
40
loading: boolean;
+11
js/app/src/router.tsx
···
77
import { store } from "store/store";
78
import { loadStateFromStorage } from "features/base/sidebarSlice";
79
import HomeScreen from "./screens/home";
0
80
81
store.dispatch(loadStateFromStorage());
82
···
92
Multi: { config: string };
93
Support: undefined;
94
Settings: undefined;
0
95
GoLive: undefined;
96
LiveDashboard: undefined;
97
Login: undefined;
···
124
Multi: "multi/:config",
125
Support: "support",
126
Settings: "settings",
0
127
GoLive: "golive",
128
LiveDashboard: "live",
129
Login: "login",
···
421
options={{
422
drawerIcon: () => <SettingsIcon />,
423
drawerLabel: () => <Text>Settings</Text>,
0
0
0
0
0
0
0
0
424
}}
425
/>
426
<Drawer.Screen
···
77
import { store } from "store/store";
78
import { loadStateFromStorage } from "features/base/sidebarSlice";
79
import HomeScreen from "./screens/home";
80
+
import KeyManager from "components/settings/keymgr";
81
82
store.dispatch(loadStateFromStorage());
83
···
93
Multi: { config: string };
94
Support: undefined;
95
Settings: undefined;
96
+
KeyManagement: undefined;
97
GoLive: undefined;
98
LiveDashboard: undefined;
99
Login: undefined;
···
126
Multi: "multi/:config",
127
Support: "support",
128
Settings: "settings",
129
+
KeyManagement: "settings/key-management",
130
GoLive: "golive",
131
LiveDashboard: "live",
132
Login: "login",
···
424
options={{
425
drawerIcon: () => <SettingsIcon />,
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" },
435
}}
436
/>
437
<Drawer.Screen
+36
js/app/utils/timeAgo.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
+
}