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