@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.
Files changed (120) hide show
  1. package/dist/components/chat/chat-box.d.ts +1 -1
  2. package/dist/components/chat/chat-box.d.ts.map +1 -1
  3. package/dist/components/chat/chat-box.js +3 -0
  4. package/dist/components/chat/chat-box.js.map +1 -1
  5. package/dist/components/chat/mod-view.d.ts +4 -2
  6. package/dist/components/chat/mod-view.d.ts.map +1 -1
  7. package/dist/components/chat/mod-view.js +142 -42
  8. package/dist/components/chat/mod-view.js.map +1 -1
  9. package/dist/components/dashboard/chat-panel.d.ts +2 -1
  10. package/dist/components/dashboard/chat-panel.d.ts.map +1 -1
  11. package/dist/components/dashboard/chat-panel.js +2 -3
  12. package/dist/components/dashboard/chat-panel.js.map +1 -1
  13. package/dist/components/dashboard/index.d.ts +1 -0
  14. package/dist/components/dashboard/index.d.ts.map +1 -1
  15. package/dist/components/dashboard/index.js +3 -1
  16. package/dist/components/dashboard/index.js.map +1 -1
  17. package/dist/components/dashboard/moderator-panel.d.ts +7 -0
  18. package/dist/components/dashboard/moderator-panel.d.ts.map +1 -0
  19. package/dist/components/dashboard/moderator-panel.js +256 -0
  20. package/dist/components/dashboard/moderator-panel.js.map +1 -0
  21. package/dist/components/ui/dialog.d.ts +1 -1
  22. package/dist/components/ui/menu.d.ts.map +1 -1
  23. package/dist/components/ui/menu.js +2 -2
  24. package/dist/components/ui/menu.js.map +1 -1
  25. package/dist/crypto-polyfill.native.js +7 -1
  26. package/dist/crypto-polyfill.native.js.map +1 -1
  27. package/dist/hooks/index.d.ts +1 -0
  28. package/dist/hooks/index.d.ts.map +1 -1
  29. package/dist/hooks/index.js +1 -0
  30. package/dist/hooks/index.js.map +1 -1
  31. package/dist/hooks/useDocumentTitle.d.ts +6 -0
  32. package/dist/hooks/useDocumentTitle.d.ts.map +1 -0
  33. package/dist/hooks/useDocumentTitle.js +40 -0
  34. package/dist/hooks/useDocumentTitle.js.map +1 -0
  35. package/dist/lib/theme/atoms.d.ts +138 -138
  36. package/dist/lib/theme/branded-theme-provider.d.ts +13 -0
  37. package/dist/lib/theme/branded-theme-provider.d.ts.map +1 -0
  38. package/dist/lib/theme/branded-theme-provider.js +34 -0
  39. package/dist/lib/theme/branded-theme-provider.js.map +1 -0
  40. package/dist/lib/theme/index.d.ts +1 -0
  41. package/dist/lib/theme/index.d.ts.map +1 -1
  42. package/dist/lib/theme/index.js +4 -1
  43. package/dist/lib/theme/index.js.map +1 -1
  44. package/dist/livestream-store/chat.d.ts +1 -1
  45. package/dist/livestream-store/chat.d.ts.map +1 -1
  46. package/dist/livestream-store/chat.js +1 -3
  47. package/dist/livestream-store/chat.js.map +1 -1
  48. package/dist/livestream-store/livestream-state.d.ts +3 -1
  49. package/dist/livestream-store/livestream-state.d.ts.map +1 -1
  50. package/dist/livestream-store/livestream-store.d.ts.map +1 -1
  51. package/dist/livestream-store/livestream-store.js +2 -0
  52. package/dist/livestream-store/livestream-store.js.map +1 -1
  53. package/dist/livestream-store/stream-key.d.ts +1 -0
  54. package/dist/livestream-store/stream-key.d.ts.map +1 -1
  55. package/dist/livestream-store/stream-key.js +2 -0
  56. package/dist/livestream-store/stream-key.js.map +1 -1
  57. package/dist/livestream-store/websocket-consumer.d.ts.map +1 -1
  58. package/dist/livestream-store/websocket-consumer.js +48 -0
  59. package/dist/livestream-store/websocket-consumer.js.map +1 -1
  60. package/dist/streamplace-provider/index.d.ts +3 -0
  61. package/dist/streamplace-provider/index.d.ts.map +1 -1
  62. package/dist/streamplace-provider/index.js +12 -1
  63. package/dist/streamplace-provider/index.js.map +1 -1
  64. package/dist/streamplace-store/block.d.ts +36 -2
  65. package/dist/streamplace-store/block.d.ts.map +1 -1
  66. package/dist/streamplace-store/block.js +121 -18
  67. package/dist/streamplace-store/block.js.map +1 -1
  68. package/dist/streamplace-store/branding.d.ts +27 -0
  69. package/dist/streamplace-store/branding.d.ts.map +1 -0
  70. package/dist/streamplace-store/branding.js +195 -0
  71. package/dist/streamplace-store/branding.js.map +1 -0
  72. package/dist/streamplace-store/index.d.ts +4 -0
  73. package/dist/streamplace-store/index.d.ts.map +1 -1
  74. package/dist/streamplace-store/index.js +4 -0
  75. package/dist/streamplace-store/index.js.map +1 -1
  76. package/dist/streamplace-store/moderation.d.ts +16 -0
  77. package/dist/streamplace-store/moderation.d.ts.map +1 -0
  78. package/dist/streamplace-store/moderation.js +141 -0
  79. package/dist/streamplace-store/moderation.js.map +1 -0
  80. package/dist/streamplace-store/moderator-management.d.ts +44 -0
  81. package/dist/streamplace-store/moderator-management.d.ts.map +1 -0
  82. package/dist/streamplace-store/moderator-management.js +136 -0
  83. package/dist/streamplace-store/moderator-management.js.map +1 -0
  84. package/dist/streamplace-store/streamplace-store.d.ts +6 -0
  85. package/dist/streamplace-store/streamplace-store.d.ts.map +1 -1
  86. package/dist/streamplace-store/streamplace-store.js +6 -0
  87. package/dist/streamplace-store/streamplace-store.js.map +1 -1
  88. package/dist/streamplace-store/xrpc.d.ts +1 -0
  89. package/dist/streamplace-store/xrpc.d.ts.map +1 -1
  90. package/dist/streamplace-store/xrpc.js +16 -0
  91. package/dist/streamplace-store/xrpc.js.map +1 -1
  92. package/locales/en-US/settings.ftl +91 -0
  93. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  94. package/package.json +3 -3
  95. package/src/components/chat/chat-box.tsx +3 -1
  96. package/src/components/chat/mod-view.tsx +431 -121
  97. package/src/components/dashboard/chat-panel.tsx +2 -1
  98. package/src/components/dashboard/index.tsx +1 -0
  99. package/src/components/dashboard/moderator-panel.tsx +632 -0
  100. package/src/components/ui/menu.tsx +1 -2
  101. package/src/crypto-polyfill.native.tsx +8 -1
  102. package/src/hooks/index.ts +1 -0
  103. package/src/hooks/useDocumentTitle.tsx +45 -0
  104. package/src/lib/theme/branded-theme-provider.tsx +58 -0
  105. package/src/lib/theme/index.ts +3 -0
  106. package/src/livestream-store/chat.tsx +0 -2
  107. package/src/livestream-store/livestream-state.tsx +5 -0
  108. package/src/livestream-store/livestream-store.tsx +2 -0
  109. package/src/livestream-store/stream-key.tsx +3 -0
  110. package/src/livestream-store/websocket-consumer.tsx +60 -0
  111. package/src/streamplace-provider/index.tsx +23 -4
  112. package/src/streamplace-store/block.tsx +139 -19
  113. package/src/streamplace-store/branding.tsx +216 -0
  114. package/src/streamplace-store/index.tsx +4 -0
  115. package/src/streamplace-store/moderation.tsx +185 -0
  116. package/src/streamplace-store/moderator-management.tsx +175 -0
  117. package/src/streamplace-store/streamplace-store.tsx +15 -0
  118. package/src/streamplace-store/xrpc.tsx +18 -1
  119. package/dist/assets/emoji-data.json +0 -19371
  120. 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
+ }
@@ -12,6 +12,9 @@ export {
12
12
  type ThemeIcons,
13
13
  } from "./theme";
14
14
 
15
+ // Branded theme provider
16
+ export { BrandedThemeProvider } from "./branded-theme-provider";
17
+
15
18
  // Design tokens
16
19
  export {
17
20
  animations,
@@ -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
 
@@ -1,3 +1,6 @@
1
+ // Just to be 100% sure it's imported successfully
2
+ import "../crypto-polyfill";
3
+
1
4
  import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto";
2
5
  import { useEffect, useState } from "react";
3
6
  import { Platform } from "react-native";
@@ -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 { useGetChatProfile } from "../streamplace-store";
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
- <ChatProfileCreator oauthSession={oauthSession}>
31
- <Poller>{children}</Poller>
32
- </ChatProfileCreator>
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
- const record: AppBskyGraphBlock.Record = {
21
- $type: "app.bsky.graph.block",
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 (chatMessageUri: string) => {
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
- const record = {
55
- $type: "place.stream.chat.gate",
56
- hiddenMessage: chatMessageUri,
57
- };
58
-
59
- const result = await agent.com.atproto.repo.createRecord({
60
- repo: agent.did,
61
- collection: "place.stream.chat.gate",
62
- record,
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
+ }
@@ -1,3 +1,7 @@
1
+ export * from "./block";
2
+ export * from "./branding";
3
+ export * from "./moderation";
4
+ export * from "./moderator-management";
1
5
  export * from "./stream";
2
6
  export * from "./streamplace-store";
3
7
  export * from "./user";