@streamplace/components 0.8.18 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/chat/chat-box.d.ts +1 -1
- package/dist/components/chat/chat-box.d.ts.map +1 -1
- package/dist/components/chat/chat-box.js +3 -0
- package/dist/components/chat/chat-box.js.map +1 -1
- package/dist/components/chat/mod-view.d.ts +4 -2
- package/dist/components/chat/mod-view.d.ts.map +1 -1
- package/dist/components/chat/mod-view.js +142 -42
- package/dist/components/chat/mod-view.js.map +1 -1
- package/dist/components/dashboard/chat-panel.d.ts +2 -1
- package/dist/components/dashboard/chat-panel.d.ts.map +1 -1
- package/dist/components/dashboard/chat-panel.js +2 -3
- package/dist/components/dashboard/chat-panel.js.map +1 -1
- package/dist/components/dashboard/index.d.ts +1 -0
- package/dist/components/dashboard/index.d.ts.map +1 -1
- package/dist/components/dashboard/index.js +3 -1
- package/dist/components/dashboard/index.js.map +1 -1
- package/dist/components/dashboard/moderator-panel.d.ts +7 -0
- package/dist/components/dashboard/moderator-panel.d.ts.map +1 -0
- package/dist/components/dashboard/moderator-panel.js +256 -0
- package/dist/components/dashboard/moderator-panel.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +1 -1
- package/dist/components/ui/menu.d.ts.map +1 -1
- package/dist/components/ui/menu.js +2 -2
- package/dist/components/ui/menu.js.map +1 -1
- package/dist/crypto-polyfill.native.js +7 -1
- package/dist/crypto-polyfill.native.js.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/useDocumentTitle.d.ts +6 -0
- package/dist/hooks/useDocumentTitle.d.ts.map +1 -0
- package/dist/hooks/useDocumentTitle.js +40 -0
- package/dist/hooks/useDocumentTitle.js.map +1 -0
- package/dist/lib/theme/atoms.d.ts +138 -138
- package/dist/lib/theme/branded-theme-provider.d.ts +13 -0
- package/dist/lib/theme/branded-theme-provider.d.ts.map +1 -0
- package/dist/lib/theme/branded-theme-provider.js +34 -0
- package/dist/lib/theme/branded-theme-provider.js.map +1 -0
- package/dist/lib/theme/index.d.ts +1 -0
- package/dist/lib/theme/index.d.ts.map +1 -1
- package/dist/lib/theme/index.js +4 -1
- package/dist/lib/theme/index.js.map +1 -1
- package/dist/livestream-store/chat.d.ts +1 -1
- package/dist/livestream-store/chat.d.ts.map +1 -1
- package/dist/livestream-store/chat.js +1 -3
- package/dist/livestream-store/chat.js.map +1 -1
- package/dist/livestream-store/livestream-state.d.ts +3 -1
- package/dist/livestream-store/livestream-state.d.ts.map +1 -1
- package/dist/livestream-store/livestream-store.d.ts.map +1 -1
- package/dist/livestream-store/livestream-store.js +2 -0
- package/dist/livestream-store/livestream-store.js.map +1 -1
- package/dist/livestream-store/stream-key.d.ts +1 -0
- package/dist/livestream-store/stream-key.d.ts.map +1 -1
- package/dist/livestream-store/stream-key.js +2 -0
- package/dist/livestream-store/stream-key.js.map +1 -1
- package/dist/livestream-store/websocket-consumer.d.ts.map +1 -1
- package/dist/livestream-store/websocket-consumer.js +48 -0
- package/dist/livestream-store/websocket-consumer.js.map +1 -1
- package/dist/streamplace-provider/index.d.ts +3 -0
- package/dist/streamplace-provider/index.d.ts.map +1 -1
- package/dist/streamplace-provider/index.js +12 -1
- package/dist/streamplace-provider/index.js.map +1 -1
- package/dist/streamplace-store/block.d.ts +36 -2
- package/dist/streamplace-store/block.d.ts.map +1 -1
- package/dist/streamplace-store/block.js +121 -18
- package/dist/streamplace-store/block.js.map +1 -1
- package/dist/streamplace-store/branding.d.ts +27 -0
- package/dist/streamplace-store/branding.d.ts.map +1 -0
- package/dist/streamplace-store/branding.js +195 -0
- package/dist/streamplace-store/branding.js.map +1 -0
- package/dist/streamplace-store/index.d.ts +4 -0
- package/dist/streamplace-store/index.d.ts.map +1 -1
- package/dist/streamplace-store/index.js +4 -0
- package/dist/streamplace-store/index.js.map +1 -1
- package/dist/streamplace-store/moderation.d.ts +16 -0
- package/dist/streamplace-store/moderation.d.ts.map +1 -0
- package/dist/streamplace-store/moderation.js +141 -0
- package/dist/streamplace-store/moderation.js.map +1 -0
- package/dist/streamplace-store/moderator-management.d.ts +44 -0
- package/dist/streamplace-store/moderator-management.d.ts.map +1 -0
- package/dist/streamplace-store/moderator-management.js +136 -0
- package/dist/streamplace-store/moderator-management.js.map +1 -0
- package/dist/streamplace-store/streamplace-store.d.ts +6 -0
- package/dist/streamplace-store/streamplace-store.d.ts.map +1 -1
- package/dist/streamplace-store/streamplace-store.js +6 -0
- package/dist/streamplace-store/streamplace-store.js.map +1 -1
- package/dist/streamplace-store/xrpc.d.ts +1 -0
- package/dist/streamplace-store/xrpc.d.ts.map +1 -1
- package/dist/streamplace-store/xrpc.js +16 -0
- package/dist/streamplace-store/xrpc.js.map +1 -1
- package/locales/en-US/settings.ftl +91 -0
- package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
- package/package.json +3 -3
- package/src/components/chat/chat-box.tsx +3 -1
- package/src/components/chat/mod-view.tsx +431 -121
- package/src/components/dashboard/chat-panel.tsx +2 -1
- package/src/components/dashboard/index.tsx +1 -0
- package/src/components/dashboard/moderator-panel.tsx +632 -0
- package/src/components/ui/menu.tsx +1 -2
- package/src/crypto-polyfill.native.tsx +8 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDocumentTitle.tsx +45 -0
- package/src/lib/theme/branded-theme-provider.tsx +58 -0
- package/src/lib/theme/index.ts +3 -0
- package/src/livestream-store/chat.tsx +0 -2
- package/src/livestream-store/livestream-state.tsx +5 -0
- package/src/livestream-store/livestream-store.tsx +2 -0
- package/src/livestream-store/stream-key.tsx +3 -0
- package/src/livestream-store/websocket-consumer.tsx +60 -0
- package/src/streamplace-provider/index.tsx +23 -4
- package/src/streamplace-store/block.tsx +139 -19
- package/src/streamplace-store/branding.tsx +216 -0
- package/src/streamplace-store/index.tsx +4 -0
- package/src/streamplace-store/moderation.tsx +185 -0
- package/src/streamplace-store/moderator-management.tsx +175 -0
- package/src/streamplace-store/streamplace-store.tsx +15 -0
- package/src/streamplace-store/xrpc.tsx +18 -1
- package/dist/assets/emoji-data.json +0 -19371
- package/src/assets/emoji-data.json +0 -19371
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useMemo, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useAccentColor,
|
|
4
|
+
usePrimaryColor,
|
|
5
|
+
useStreamplaceStore,
|
|
6
|
+
} from "../../streamplace-store";
|
|
7
|
+
import { ThemeProvider, type Theme } from "./theme";
|
|
8
|
+
|
|
9
|
+
interface BrandedThemeProviderProps {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
defaultTheme?: "light" | "dark" | "system";
|
|
12
|
+
forcedTheme?: "light" | "dark";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ThemeProvider wrapper that automatically applies branding colors from the
|
|
17
|
+
* broadcaster's branding configuration.
|
|
18
|
+
*/
|
|
19
|
+
export function BrandedThemeProvider({
|
|
20
|
+
children,
|
|
21
|
+
defaultTheme,
|
|
22
|
+
forcedTheme,
|
|
23
|
+
}: BrandedThemeProviderProps) {
|
|
24
|
+
const primaryColor = usePrimaryColor();
|
|
25
|
+
const accentColor = useAccentColor();
|
|
26
|
+
const brandingLoading = useStreamplaceStore((state) => state.brandingLoading);
|
|
27
|
+
|
|
28
|
+
// Build color theme overrides from branding
|
|
29
|
+
const colorTheme = useMemo<Partial<Theme["colors"]>>(() => {
|
|
30
|
+
// don't override until branding is loaded
|
|
31
|
+
if (brandingLoading) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const overrides: Partial<Theme["colors"]> = {};
|
|
36
|
+
|
|
37
|
+
if (primaryColor) {
|
|
38
|
+
overrides.primary = primaryColor;
|
|
39
|
+
overrides.ring = primaryColor;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (accentColor) {
|
|
43
|
+
overrides.accent = accentColor;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return overrides;
|
|
47
|
+
}, [primaryColor, accentColor, brandingLoading]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ThemeProvider
|
|
51
|
+
defaultTheme={defaultTheme}
|
|
52
|
+
forcedTheme={forcedTheme}
|
|
53
|
+
colorTheme={colorTheme}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</ThemeProvider>
|
|
57
|
+
);
|
|
58
|
+
}
|
package/src/lib/theme/index.ts
CHANGED
|
@@ -374,8 +374,6 @@ export const useSubmitReport = () => {
|
|
|
374
374
|
subject: ComAtprotoModerationCreateReport.InputSchema["subject"],
|
|
375
375
|
reasonType: string,
|
|
376
376
|
reason?: string,
|
|
377
|
-
// no clue about this
|
|
378
|
-
moderationSvcDid: string = "did:web:stream.place",
|
|
379
377
|
) => {
|
|
380
378
|
if (!pdsAgent || !userDID) {
|
|
381
379
|
throw new Error("No PDS agent or user DID found");
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
ChatMessageViewHydrated,
|
|
4
4
|
LivestreamViewHydrated,
|
|
5
5
|
PlaceStreamDefs,
|
|
6
|
+
PlaceStreamModerationPermission,
|
|
6
7
|
PlaceStreamSegment,
|
|
7
8
|
} from "streamplace";
|
|
8
9
|
|
|
@@ -23,6 +24,10 @@ export interface LivestreamState {
|
|
|
23
24
|
setStreamKey: (key: string | null) => void;
|
|
24
25
|
websocketConnected: boolean;
|
|
25
26
|
hasReceivedSegment: boolean;
|
|
27
|
+
moderationPermissions: PlaceStreamModerationPermission.Record[];
|
|
28
|
+
setModerationPermissions: (
|
|
29
|
+
permissions: PlaceStreamModerationPermission.Record[],
|
|
30
|
+
) => void;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export interface LivestreamProblem {
|
|
@@ -24,6 +24,8 @@ export const makeLivestreamStore = (): StoreApi<LivestreamState> => {
|
|
|
24
24
|
problems: [],
|
|
25
25
|
websocketConnected: false,
|
|
26
26
|
hasReceivedSegment: false,
|
|
27
|
+
moderationPermissions: [],
|
|
28
|
+
setModerationPermissions: (perms) => set({ moderationPermissions: perms }),
|
|
27
29
|
}));
|
|
28
30
|
};
|
|
29
31
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
PlaceStreamChatMessage,
|
|
8
8
|
PlaceStreamDefs,
|
|
9
9
|
PlaceStreamLivestream,
|
|
10
|
+
PlaceStreamModerationPermission,
|
|
10
11
|
PlaceStreamSegment,
|
|
11
12
|
} from "streamplace";
|
|
12
13
|
import { SystemMessages } from "../lib/system-messages";
|
|
@@ -120,6 +121,65 @@ export const handleWebSocketMessages = (
|
|
|
120
121
|
pendingHides: newPendingHides,
|
|
121
122
|
};
|
|
122
123
|
state = reduceChat(state, [], [], [hiddenMessageUri]);
|
|
124
|
+
} else if (
|
|
125
|
+
PlaceStreamModerationPermission.isRecord(message) ||
|
|
126
|
+
(message &&
|
|
127
|
+
typeof message === "object" &&
|
|
128
|
+
"$type" in message &&
|
|
129
|
+
(message as { $type?: string }).$type ===
|
|
130
|
+
"place.stream.moderation.permission")
|
|
131
|
+
) {
|
|
132
|
+
// Handle moderation permission record updates
|
|
133
|
+
// This can be a new permission or a deletion marker
|
|
134
|
+
const permRecord = message as
|
|
135
|
+
| PlaceStreamModerationPermission.Record
|
|
136
|
+
| { deleted?: boolean; rkey?: string; streamer?: string };
|
|
137
|
+
|
|
138
|
+
if ((permRecord as any).deleted) {
|
|
139
|
+
// Handle deletion: clear permissions to trigger refetch
|
|
140
|
+
// The useCanModerate hook will refetch and repopulate
|
|
141
|
+
state = {
|
|
142
|
+
...state,
|
|
143
|
+
moderationPermissions: [],
|
|
144
|
+
};
|
|
145
|
+
} else {
|
|
146
|
+
// Handle new/updated permission: add or update in the list
|
|
147
|
+
// Use createdAt as a unique identifier since multiple records can exist for the same moderator
|
|
148
|
+
// (e.g., one record with "ban" permission, another with "hide" permission)
|
|
149
|
+
// Note: rkey would be ideal but isn't always present in the WebSocket message
|
|
150
|
+
const newPerm =
|
|
151
|
+
permRecord as PlaceStreamModerationPermission.Record & {
|
|
152
|
+
rkey?: string;
|
|
153
|
+
};
|
|
154
|
+
const existingIndex = state.moderationPermissions.findIndex((p) => {
|
|
155
|
+
const pWithRkey = p as PlaceStreamModerationPermission.Record & {
|
|
156
|
+
rkey?: string;
|
|
157
|
+
};
|
|
158
|
+
// Prefer matching by rkey if available, fall back to createdAt
|
|
159
|
+
if (newPerm.rkey && pWithRkey.rkey) {
|
|
160
|
+
return pWithRkey.rkey === newPerm.rkey;
|
|
161
|
+
}
|
|
162
|
+
return (
|
|
163
|
+
p.moderator === newPerm.moderator &&
|
|
164
|
+
p.createdAt === newPerm.createdAt
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
let newPermissions: PlaceStreamModerationPermission.Record[];
|
|
169
|
+
if (existingIndex >= 0) {
|
|
170
|
+
// Update existing record with same moderator AND createdAt
|
|
171
|
+
newPermissions = [...state.moderationPermissions];
|
|
172
|
+
newPermissions[existingIndex] = newPerm;
|
|
173
|
+
} else {
|
|
174
|
+
// Add new record (could be a new record for an existing moderator with different permissions)
|
|
175
|
+
newPermissions = [...state.moderationPermissions, newPerm];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
state = {
|
|
179
|
+
...state,
|
|
180
|
+
moderationPermissions: newPermissions,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
123
183
|
}
|
|
124
184
|
}
|
|
125
185
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { SessionManager } from "@atproto/api/dist/session-manager";
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { useDocumentTitle } from "../hooks";
|
|
4
|
+
import {
|
|
5
|
+
useBrandingAutoFetch,
|
|
6
|
+
useFetchBroadcasterDID,
|
|
7
|
+
useGetChatProfile,
|
|
8
|
+
} from "../streamplace-store";
|
|
4
9
|
import { makeStreamplaceStore } from "../streamplace-store/streamplace-store";
|
|
5
10
|
import { StreamplaceContext } from "./context";
|
|
6
11
|
import Poller from "./poller";
|
|
@@ -27,13 +32,27 @@ export function StreamplaceProvider({
|
|
|
27
32
|
|
|
28
33
|
return (
|
|
29
34
|
<StreamplaceContext.Provider value={{ store: store }}>
|
|
30
|
-
<
|
|
31
|
-
<
|
|
32
|
-
|
|
35
|
+
<BrandingFetcher>
|
|
36
|
+
<ChatProfileCreator oauthSession={oauthSession}>
|
|
37
|
+
<Poller>{children}</Poller>
|
|
38
|
+
</ChatProfileCreator>
|
|
39
|
+
</BrandingFetcher>
|
|
33
40
|
</StreamplaceContext.Provider>
|
|
34
41
|
);
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
export function BrandingFetcher({ children }: { children: React.ReactNode }) {
|
|
45
|
+
const fetchBroadcasterDID = useFetchBroadcasterDID();
|
|
46
|
+
useBrandingAutoFetch();
|
|
47
|
+
useDocumentTitle();
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
fetchBroadcasterDID();
|
|
51
|
+
}, [fetchBroadcasterDID]);
|
|
52
|
+
|
|
53
|
+
return <>{children}</>;
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
export function ChatProfileCreator({
|
|
38
57
|
oauthSession,
|
|
39
58
|
children,
|
|
@@ -2,11 +2,21 @@ import { AppBskyGraphBlock } from "@atproto/api";
|
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { usePDSAgent } from "./xrpc";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Hook to create a block record (ban user from chat).
|
|
7
|
+
*
|
|
8
|
+
* When the caller is the stream owner (agent.did === streamerDID), creates the
|
|
9
|
+
* block record directly via ATProto writes to their repo.
|
|
10
|
+
*
|
|
11
|
+
* When the caller is a delegated moderator, uses the place.stream.moderation.createBlock
|
|
12
|
+
* XRPC endpoint which validates permissions and creates the record using the
|
|
13
|
+
* streamer's OAuth session.
|
|
14
|
+
*/
|
|
5
15
|
export function useCreateBlockRecord() {
|
|
6
16
|
let agent = usePDSAgent();
|
|
7
17
|
const [isLoading, setIsLoading] = useState(false);
|
|
8
18
|
|
|
9
|
-
const createBlock = async (subjectDID: string) => {
|
|
19
|
+
const createBlock = async (subjectDID: string, streamerDID?: string) => {
|
|
10
20
|
if (!agent) {
|
|
11
21
|
throw new Error("No PDS agent found");
|
|
12
22
|
}
|
|
@@ -17,15 +27,25 @@ export function useCreateBlockRecord() {
|
|
|
17
27
|
|
|
18
28
|
setIsLoading(true);
|
|
19
29
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
// If no streamerDID provided or caller is the streamer, use direct ATProto write
|
|
31
|
+
if (!streamerDID || agent.did === streamerDID) {
|
|
32
|
+
const record: AppBskyGraphBlock.Record = {
|
|
33
|
+
$type: "app.bsky.graph.block",
|
|
34
|
+
subject: subjectDID,
|
|
35
|
+
createdAt: new Date().toISOString(),
|
|
36
|
+
};
|
|
37
|
+
const result = await agent.com.atproto.repo.createRecord({
|
|
38
|
+
repo: agent.did,
|
|
39
|
+
collection: "app.bsky.graph.block",
|
|
40
|
+
record,
|
|
41
|
+
});
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Otherwise, use delegated moderation endpoint
|
|
46
|
+
const result = await agent.place.stream.moderation.createBlock({
|
|
47
|
+
streamer: streamerDID,
|
|
22
48
|
subject: subjectDID,
|
|
23
|
-
createdAt: new Date().toISOString(),
|
|
24
|
-
};
|
|
25
|
-
const result = await agent.com.atproto.repo.createRecord({
|
|
26
|
-
repo: agent.did,
|
|
27
|
-
collection: "app.bsky.graph.block",
|
|
28
|
-
record,
|
|
29
49
|
});
|
|
30
50
|
return result;
|
|
31
51
|
} finally {
|
|
@@ -36,11 +56,24 @@ export function useCreateBlockRecord() {
|
|
|
36
56
|
return { createBlock, isLoading };
|
|
37
57
|
}
|
|
38
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Hook to create a gate record (hide a chat message).
|
|
61
|
+
*
|
|
62
|
+
* When the caller is the stream owner (agent.did === streamerDID), creates the
|
|
63
|
+
* gate record directly via ATProto writes to their repo.
|
|
64
|
+
*
|
|
65
|
+
* When the caller is a delegated moderator, uses the place.stream.moderation.createGate
|
|
66
|
+
* XRPC endpoint which validates permissions and creates the record using the
|
|
67
|
+
* streamer's OAuth session.
|
|
68
|
+
*/
|
|
39
69
|
export function useCreateHideChatRecord() {
|
|
40
70
|
let agent = usePDSAgent();
|
|
41
71
|
const [isLoading, setIsLoading] = useState(false);
|
|
42
72
|
|
|
43
|
-
const createHideChat = async (
|
|
73
|
+
const createHideChat = async (
|
|
74
|
+
chatMessageUri: string,
|
|
75
|
+
streamerDID?: string,
|
|
76
|
+
) => {
|
|
44
77
|
if (!agent) {
|
|
45
78
|
throw new Error("No PDS agent found");
|
|
46
79
|
}
|
|
@@ -51,15 +84,25 @@ export function useCreateHideChatRecord() {
|
|
|
51
84
|
|
|
52
85
|
setIsLoading(true);
|
|
53
86
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
87
|
+
// If no streamerDID provided or caller is the streamer, use direct ATProto write
|
|
88
|
+
if (!streamerDID || agent.did === streamerDID) {
|
|
89
|
+
const record = {
|
|
90
|
+
$type: "place.stream.chat.gate",
|
|
91
|
+
hiddenMessage: chatMessageUri,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = await agent.com.atproto.repo.createRecord({
|
|
95
|
+
repo: agent.did,
|
|
96
|
+
collection: "place.stream.chat.gate",
|
|
97
|
+
record,
|
|
98
|
+
});
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Otherwise, use delegated moderation endpoint
|
|
103
|
+
const result = await agent.place.stream.moderation.createGate({
|
|
104
|
+
streamer: streamerDID,
|
|
105
|
+
messageUri: chatMessageUri,
|
|
63
106
|
});
|
|
64
107
|
return result;
|
|
65
108
|
} finally {
|
|
@@ -69,3 +112,80 @@ export function useCreateHideChatRecord() {
|
|
|
69
112
|
|
|
70
113
|
return { createHideChat, isLoading };
|
|
71
114
|
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Hook to update a livestream record (update stream title).
|
|
118
|
+
*
|
|
119
|
+
* When the caller is the stream owner (agent.did === streamerDID), updates the
|
|
120
|
+
* livestream record directly via ATProto writes to their repo.
|
|
121
|
+
*
|
|
122
|
+
* When the caller is a delegated moderator, uses the place.stream.moderation.updateLivestream
|
|
123
|
+
* XRPC endpoint which validates permissions and updates the record using the
|
|
124
|
+
* streamer's OAuth session.
|
|
125
|
+
*/
|
|
126
|
+
export function useUpdateLivestreamRecord() {
|
|
127
|
+
let agent = usePDSAgent();
|
|
128
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
129
|
+
|
|
130
|
+
const updateLivestream = async (
|
|
131
|
+
livestreamUri: string,
|
|
132
|
+
title: string,
|
|
133
|
+
streamerDID?: string,
|
|
134
|
+
) => {
|
|
135
|
+
if (!agent) {
|
|
136
|
+
throw new Error("No PDS agent found");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!agent.did) {
|
|
140
|
+
throw new Error("No user DID found, assuming not logged in");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setIsLoading(true);
|
|
144
|
+
try {
|
|
145
|
+
// If no streamerDID provided or caller is the streamer, use direct ATProto write
|
|
146
|
+
if (!streamerDID || agent.did === streamerDID) {
|
|
147
|
+
// Extract rkey from URI
|
|
148
|
+
const rkey = livestreamUri.split("/").pop();
|
|
149
|
+
if (!rkey) {
|
|
150
|
+
throw new Error("Invalid livestream URI");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Get existing record to copy fields
|
|
154
|
+
const getResult = await agent.com.atproto.repo.getRecord({
|
|
155
|
+
repo: agent.did,
|
|
156
|
+
collection: "place.stream.livestream",
|
|
157
|
+
rkey,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const oldRecord = getResult.data.value as any;
|
|
161
|
+
|
|
162
|
+
// Create new record (don't edit - old records are "chapter markers")
|
|
163
|
+
// Spread entire record to preserve all fields (agent, canonicalUrl, notificationSettings, etc.)
|
|
164
|
+
const record = {
|
|
165
|
+
...oldRecord,
|
|
166
|
+
title: title, // Override title
|
|
167
|
+
createdAt: new Date().toISOString(), // Override timestamp for new chapter marker
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const result = await agent.com.atproto.repo.createRecord({
|
|
171
|
+
repo: agent.did,
|
|
172
|
+
collection: "place.stream.livestream",
|
|
173
|
+
record,
|
|
174
|
+
});
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Otherwise, use delegated moderation endpoint
|
|
179
|
+
const result = await agent.place.stream.moderation.updateLivestream({
|
|
180
|
+
streamer: streamerDID,
|
|
181
|
+
livestreamUri: livestreamUri,
|
|
182
|
+
title: title,
|
|
183
|
+
});
|
|
184
|
+
return result;
|
|
185
|
+
} finally {
|
|
186
|
+
setIsLoading(false);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return { updateLivestream, isLoading };
|
|
191
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import storage from "../storage";
|
|
3
|
+
import {
|
|
4
|
+
getStreamplaceStoreFromContext,
|
|
5
|
+
useStreamplaceStore,
|
|
6
|
+
} from "./streamplace-store";
|
|
7
|
+
import { usePossiblyUnauthedPDSAgent } from "./xrpc";
|
|
8
|
+
|
|
9
|
+
export interface BrandingAsset {
|
|
10
|
+
key: string;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
url?: string; // URL for images
|
|
13
|
+
data?: string; // inline data for text, or base64 for images
|
|
14
|
+
width?: number; // image width in pixels
|
|
15
|
+
height?: number; // image height in pixels
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// helper to convert blob to base64
|
|
19
|
+
const blobToBase64 = (blob: Blob): Promise<string> => {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const reader = new FileReader();
|
|
22
|
+
reader.onloadend = () => resolve(reader.result as string);
|
|
23
|
+
reader.onerror = reject;
|
|
24
|
+
reader.readAsDataURL(blob);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// hook to fetch broadcaster DID (unauthenticated)
|
|
29
|
+
export function useFetchBroadcasterDID() {
|
|
30
|
+
const streamplaceAgent = usePossiblyUnauthedPDSAgent();
|
|
31
|
+
const store = getStreamplaceStoreFromContext();
|
|
32
|
+
|
|
33
|
+
return useCallback(async () => {
|
|
34
|
+
try {
|
|
35
|
+
if (!streamplaceAgent) {
|
|
36
|
+
throw new Error("Streamplace agent not available");
|
|
37
|
+
}
|
|
38
|
+
const result =
|
|
39
|
+
await streamplaceAgent.place.stream.broadcast.getBroadcaster();
|
|
40
|
+
store.setState({ broadcasterDID: result.data.broadcaster });
|
|
41
|
+
if (result.data.server) {
|
|
42
|
+
store.setState({ serverDID: result.data.server });
|
|
43
|
+
}
|
|
44
|
+
if (result.data.admins) {
|
|
45
|
+
store.setState({ adminDIDs: result.data.admins });
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error("Failed to fetch broadcaster DID:", err);
|
|
49
|
+
}
|
|
50
|
+
}, [streamplaceAgent, store]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// hook to fetch branding data from the server
|
|
54
|
+
export function useFetchBranding() {
|
|
55
|
+
const streamplaceAgent = usePossiblyUnauthedPDSAgent();
|
|
56
|
+
const broadcasterDID = useStreamplaceStore((state) => state.broadcasterDID);
|
|
57
|
+
const url = useStreamplaceStore((state) => state.url);
|
|
58
|
+
const store = getStreamplaceStoreFromContext();
|
|
59
|
+
|
|
60
|
+
return useCallback(
|
|
61
|
+
async ({ force = true } = {}) => {
|
|
62
|
+
if (!broadcasterDID) return;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
store.setState({ brandingLoading: true });
|
|
66
|
+
|
|
67
|
+
// check localStorage first
|
|
68
|
+
const cacheKey = `branding:${broadcasterDID}`;
|
|
69
|
+
const cached = await storage.getItem(cacheKey);
|
|
70
|
+
if (!force && cached) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(cached);
|
|
73
|
+
// check if cache is less than 1 hour old
|
|
74
|
+
if (Date.now() - parsed.timestamp < 60 * 60 * 1000) {
|
|
75
|
+
store.setState({
|
|
76
|
+
branding: parsed.data,
|
|
77
|
+
brandingLoading: false,
|
|
78
|
+
brandingError: null,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// invalid cache, continue to fetch
|
|
84
|
+
console.warn("Invalid branding cache, refetching", e);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// fetch branding metadata from server
|
|
89
|
+
if (!streamplaceAgent) {
|
|
90
|
+
throw new Error("Streamplace agent not available");
|
|
91
|
+
}
|
|
92
|
+
const res = await streamplaceAgent.place.stream.branding.getBranding({
|
|
93
|
+
broadcaster: broadcasterDID,
|
|
94
|
+
});
|
|
95
|
+
const assets = res.data.assets;
|
|
96
|
+
|
|
97
|
+
// convert assets array to keyed object and fetch blob data
|
|
98
|
+
const brandingMap: Record<string, BrandingAsset> = {};
|
|
99
|
+
|
|
100
|
+
for (const asset of assets) {
|
|
101
|
+
brandingMap[asset.key] = { ...asset };
|
|
102
|
+
|
|
103
|
+
// if data is already inline (text assets), use it directly
|
|
104
|
+
if (asset.data) {
|
|
105
|
+
brandingMap[asset.key].data = asset.data;
|
|
106
|
+
} else if (asset.url) {
|
|
107
|
+
// for images, construct full URL and fetch blob
|
|
108
|
+
const fullUrl = `${url}${asset.url}`;
|
|
109
|
+
const blobRes = await fetch(fullUrl);
|
|
110
|
+
const blob = await blobRes.blob();
|
|
111
|
+
brandingMap[asset.key].data = await blobToBase64(blob);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// cache in localStorage
|
|
116
|
+
storage.setItem(
|
|
117
|
+
cacheKey,
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
timestamp: Date.now(),
|
|
120
|
+
data: brandingMap,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
store.setState({
|
|
125
|
+
branding: brandingMap,
|
|
126
|
+
brandingLoading: false,
|
|
127
|
+
brandingError: null,
|
|
128
|
+
});
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
console.error("Failed to fetch branding:", err);
|
|
131
|
+
store.setState({
|
|
132
|
+
brandingLoading: false,
|
|
133
|
+
brandingError: err.message || "Failed to fetch branding",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
[broadcasterDID, streamplaceAgent, url, store],
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// hook to get a specific branding asset by key
|
|
142
|
+
export function useBrandingAsset(key: string): BrandingAsset | undefined {
|
|
143
|
+
return useStreamplaceStore((state) => state.branding?.[key]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// convenience hook for main logo
|
|
147
|
+
export function useMainLogo(): string | undefined {
|
|
148
|
+
const asset = useBrandingAsset("mainLogo");
|
|
149
|
+
return asset?.data;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// convenience hook for favicon
|
|
153
|
+
export function useFavicon(): string | undefined {
|
|
154
|
+
const asset = useBrandingAsset("favicon");
|
|
155
|
+
return asset?.data;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// convenience hook for site title
|
|
159
|
+
export function useSiteTitle(): string {
|
|
160
|
+
const asset = useBrandingAsset("siteTitle");
|
|
161
|
+
return asset?.data || "My Streamplace Station";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// convenience hook for site description
|
|
165
|
+
export function useSiteDescription(): string {
|
|
166
|
+
const asset = useBrandingAsset("siteDescription");
|
|
167
|
+
return asset?.data || "Live streaming platform";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// convenience hook for primary color
|
|
171
|
+
export function usePrimaryColor(): string {
|
|
172
|
+
const asset = useBrandingAsset("primaryColor");
|
|
173
|
+
return asset?.data || "#6366f1";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// convenience hook for accent color
|
|
177
|
+
export function useAccentColor(): string {
|
|
178
|
+
const asset = useBrandingAsset("accentColor");
|
|
179
|
+
return asset?.data || "#8b5cf6";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// convenience hook for default streamer
|
|
183
|
+
export function useDefaultStreamer(): string | undefined {
|
|
184
|
+
const asset = useBrandingAsset("defaultStreamer");
|
|
185
|
+
return asset?.data || undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// convenience hook for sidebar background image
|
|
189
|
+
export function useSidebarBackgroundImage(): BrandingAsset | undefined {
|
|
190
|
+
return useBrandingAsset("sidebarBackgroundImage");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// convenience hook for legal links
|
|
194
|
+
export function useLegalLinks(): { text: string; url: string }[] {
|
|
195
|
+
const asset = useBrandingAsset("legalLinks");
|
|
196
|
+
if (!asset?.data) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(asset.data);
|
|
201
|
+
} catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// hook to auto-fetch branding when broadcaster changes
|
|
207
|
+
export function useBrandingAutoFetch() {
|
|
208
|
+
const fetchBranding = useFetchBranding();
|
|
209
|
+
const broadcasterDID = useStreamplaceStore((state) => state.broadcasterDID);
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (broadcasterDID) {
|
|
213
|
+
fetchBranding();
|
|
214
|
+
}
|
|
215
|
+
}, [broadcasterDID, fetchBranding]);
|
|
216
|
+
}
|