@streamplace/components 0.8.17 → 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 (147) 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/mobile-player/video-async.native.d.ts.map +1 -1
  22. package/dist/components/mobile-player/video-async.native.js +1 -1
  23. package/dist/components/mobile-player/video-async.native.js.map +1 -1
  24. package/dist/components/ui/dialog.d.ts +1 -1
  25. package/dist/components/ui/menu.d.ts +14 -0
  26. package/dist/components/ui/menu.d.ts.map +1 -1
  27. package/dist/components/ui/menu.js +83 -4
  28. package/dist/components/ui/menu.js.map +1 -1
  29. package/dist/components/ui/text.d.ts +1 -1
  30. package/dist/crypto-polyfill.native.js +7 -1
  31. package/dist/crypto-polyfill.native.js.map +1 -1
  32. package/dist/hooks/index.d.ts +1 -0
  33. package/dist/hooks/index.d.ts.map +1 -1
  34. package/dist/hooks/index.js +1 -0
  35. package/dist/hooks/index.js.map +1 -1
  36. package/dist/hooks/useDocumentTitle.d.ts +6 -0
  37. package/dist/hooks/useDocumentTitle.d.ts.map +1 -0
  38. package/dist/hooks/useDocumentTitle.js +40 -0
  39. package/dist/hooks/useDocumentTitle.js.map +1 -0
  40. package/dist/lib/theme/atoms.d.ts +145 -145
  41. package/dist/lib/theme/branded-theme-provider.d.ts +13 -0
  42. package/dist/lib/theme/branded-theme-provider.d.ts.map +1 -0
  43. package/dist/lib/theme/branded-theme-provider.js +34 -0
  44. package/dist/lib/theme/branded-theme-provider.js.map +1 -0
  45. package/dist/lib/theme/index.d.ts +1 -0
  46. package/dist/lib/theme/index.d.ts.map +1 -1
  47. package/dist/lib/theme/index.js +4 -1
  48. package/dist/lib/theme/index.js.map +1 -1
  49. package/dist/lib/theme/theme.d.ts +1 -1
  50. package/dist/lib/theme/tokens.d.ts +1 -1
  51. package/dist/lib/theme/tokens.js +1 -1
  52. package/dist/livestream-provider/index.d.ts +2 -1
  53. package/dist/livestream-provider/index.d.ts.map +1 -1
  54. package/dist/livestream-provider/index.js +4 -2
  55. package/dist/livestream-provider/index.js.map +1 -1
  56. package/dist/livestream-provider/websocket.d.ts.map +1 -1
  57. package/dist/livestream-provider/websocket.js +15 -1
  58. package/dist/livestream-provider/websocket.js.map +1 -1
  59. package/dist/livestream-store/chat.d.ts +1 -1
  60. package/dist/livestream-store/chat.d.ts.map +1 -1
  61. package/dist/livestream-store/chat.js +1 -3
  62. package/dist/livestream-store/chat.js.map +1 -1
  63. package/dist/livestream-store/livestream-state.d.ts +5 -1
  64. package/dist/livestream-store/livestream-state.d.ts.map +1 -1
  65. package/dist/livestream-store/livestream-store.d.ts +1 -0
  66. package/dist/livestream-store/livestream-store.d.ts.map +1 -1
  67. package/dist/livestream-store/livestream-store.js +7 -1
  68. package/dist/livestream-store/livestream-store.js.map +1 -1
  69. package/dist/livestream-store/stream-key.d.ts +1 -0
  70. package/dist/livestream-store/stream-key.d.ts.map +1 -1
  71. package/dist/livestream-store/stream-key.js +2 -0
  72. package/dist/livestream-store/stream-key.js.map +1 -1
  73. package/dist/livestream-store/websocket-consumer.d.ts.map +1 -1
  74. package/dist/livestream-store/websocket-consumer.js +145 -75
  75. package/dist/livestream-store/websocket-consumer.js.map +1 -1
  76. package/dist/streamplace-provider/index.d.ts +3 -0
  77. package/dist/streamplace-provider/index.d.ts.map +1 -1
  78. package/dist/streamplace-provider/index.js +12 -1
  79. package/dist/streamplace-provider/index.js.map +1 -1
  80. package/dist/streamplace-store/block.d.ts +36 -2
  81. package/dist/streamplace-store/block.d.ts.map +1 -1
  82. package/dist/streamplace-store/block.js +121 -18
  83. package/dist/streamplace-store/block.js.map +1 -1
  84. package/dist/streamplace-store/branding.d.ts +27 -0
  85. package/dist/streamplace-store/branding.d.ts.map +1 -0
  86. package/dist/streamplace-store/branding.js +195 -0
  87. package/dist/streamplace-store/branding.js.map +1 -0
  88. package/dist/streamplace-store/index.d.ts +4 -0
  89. package/dist/streamplace-store/index.d.ts.map +1 -1
  90. package/dist/streamplace-store/index.js +4 -0
  91. package/dist/streamplace-store/index.js.map +1 -1
  92. package/dist/streamplace-store/moderation.d.ts +16 -0
  93. package/dist/streamplace-store/moderation.d.ts.map +1 -0
  94. package/dist/streamplace-store/moderation.js +141 -0
  95. package/dist/streamplace-store/moderation.js.map +1 -0
  96. package/dist/streamplace-store/moderator-management.d.ts +44 -0
  97. package/dist/streamplace-store/moderator-management.d.ts.map +1 -0
  98. package/dist/streamplace-store/moderator-management.js +136 -0
  99. package/dist/streamplace-store/moderator-management.js.map +1 -0
  100. package/dist/streamplace-store/streamplace-store.d.ts +6 -0
  101. package/dist/streamplace-store/streamplace-store.d.ts.map +1 -1
  102. package/dist/streamplace-store/streamplace-store.js +6 -0
  103. package/dist/streamplace-store/streamplace-store.js.map +1 -1
  104. package/dist/streamplace-store/xrpc.d.ts +1 -0
  105. package/dist/streamplace-store/xrpc.d.ts.map +1 -1
  106. package/dist/streamplace-store/xrpc.js +16 -0
  107. package/dist/streamplace-store/xrpc.js.map +1 -1
  108. package/locales/en-US/common.ftl +16 -0
  109. package/locales/en-US/settings.ftl +104 -0
  110. package/locales/es-ES/common.ftl +16 -0
  111. package/locales/es-ES/settings.ftl +1 -1
  112. package/locales/fr-FR/common.ftl +16 -0
  113. package/locales/pt-BR/common.ftl +16 -0
  114. package/locales/pt-BR/settings.ftl +1 -1
  115. package/locales/zh-Hant/common.ftl +16 -0
  116. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  117. package/package.json +3 -3
  118. package/src/components/chat/chat-box.tsx +3 -1
  119. package/src/components/chat/mod-view.tsx +431 -121
  120. package/src/components/dashboard/chat-panel.tsx +2 -1
  121. package/src/components/dashboard/index.tsx +1 -0
  122. package/src/components/dashboard/moderator-panel.tsx +632 -0
  123. package/src/components/mobile-player/video-async.native.tsx +2 -1
  124. package/src/components/ui/menu.tsx +181 -5
  125. package/src/crypto-polyfill.native.tsx +8 -1
  126. package/src/hooks/index.ts +1 -0
  127. package/src/hooks/useDocumentTitle.tsx +45 -0
  128. package/src/lib/theme/branded-theme-provider.tsx +58 -0
  129. package/src/lib/theme/index.ts +3 -0
  130. package/src/lib/theme/tokens.ts +1 -1
  131. package/src/livestream-provider/index.tsx +5 -1
  132. package/src/livestream-provider/websocket.tsx +15 -1
  133. package/src/livestream-store/chat.tsx +0 -2
  134. package/src/livestream-store/livestream-state.tsx +7 -0
  135. package/src/livestream-store/livestream-store.tsx +7 -0
  136. package/src/livestream-store/stream-key.tsx +3 -0
  137. package/src/livestream-store/websocket-consumer.tsx +155 -73
  138. package/src/streamplace-provider/index.tsx +23 -4
  139. package/src/streamplace-store/block.tsx +139 -19
  140. package/src/streamplace-store/branding.tsx +216 -0
  141. package/src/streamplace-store/index.tsx +4 -0
  142. package/src/streamplace-store/moderation.tsx +185 -0
  143. package/src/streamplace-store/moderator-management.tsx +175 -0
  144. package/src/streamplace-store/streamplace-store.tsx +15 -0
  145. package/src/streamplace-store/xrpc.tsx +18 -1
  146. package/dist/assets/emoji-data.json +0 -19371
  147. package/src/assets/emoji-data.json +0 -19371
@@ -1,27 +1,40 @@
1
1
  import { TriggerRef, useRootContext } from "@rn-primitives/dropdown-menu";
2
2
  import { forwardRef, useEffect, useRef, useState } from "react";
3
3
  import { gap, mr, w } from "../../lib/theme/atoms";
4
- import { useIsMyStream, usePlayerStore } from "../../player-store";
4
+ import { usePlayerStore } from "../../player-store";
5
5
  import {
6
6
  useCreateBlockRecord,
7
7
  useCreateHideChatRecord,
8
+ useUpdateLivestreamRecord,
8
9
  } from "../../streamplace-store/block";
10
+ import {
11
+ ModerationPermissions,
12
+ useCanModerate,
13
+ } from "../../streamplace-store/moderation";
9
14
  import { usePDSAgent } from "../../streamplace-store/xrpc";
10
15
 
11
16
  import { Linking } from "react-native";
12
17
  import { ChatMessageViewHydrated } from "streamplace";
13
- import { useDeleteChatMessage } from "../../livestream-store";
18
+ import {
19
+ useDeleteChatMessage,
20
+ useLivestream,
21
+ useLivestreamStore,
22
+ } from "../../livestream-store";
14
23
  import { useStreamplaceStore } from "../../streamplace-store";
15
24
  import { formatHandle, formatHandleWithAt } from "../../utils/format-handle";
16
25
  import {
17
26
  atoms,
27
+ Button,
28
+ DialogFooter,
18
29
  DropdownMenu,
19
30
  DropdownMenuGroup,
20
31
  DropdownMenuItem,
21
32
  DropdownMenuTrigger,
22
33
  layout,
34
+ ResponsiveDialog,
23
35
  ResponsiveDropdownMenuContent,
24
36
  Text,
37
+ Textarea,
25
38
  useToast,
26
39
  View,
27
40
  } from "../ui";
@@ -42,33 +55,37 @@ export type ModViewRef = {
42
55
  export const ModView = forwardRef<ModViewRef, ModViewProps>(() => {
43
56
  const triggerRef = useRef<TriggerRef>(null);
44
57
  const message = usePlayerStore((state) => state.modMessage);
58
+ const toast = useToast();
45
59
 
46
60
  let agent = usePDSAgent();
47
61
  let [messageRemoved, setMessageRemoved] = useState(false);
48
62
  let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord();
49
63
  let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord();
64
+ let { updateLivestream, isLoading: isUpdateTitleLoading } =
65
+ useUpdateLivestreamRecord();
66
+ const livestream = useLivestream();
67
+ const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false);
50
68
 
51
69
  const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen);
52
70
  const setReportSubject = usePlayerStore((x) => x.setReportSubject);
53
71
  const setModMessage = usePlayerStore((x) => x.setModMessage);
54
72
  const deleteChatMessage = useDeleteChatMessage();
55
- const isMyStream = useIsMyStream();
73
+
74
+ // Get the streamer's DID from the livestream profile
75
+ const streamerDID = useLivestreamStore((x) => x.profile?.did);
76
+ // Check moderation permissions for the current user on this streamer's channel
77
+ const modPermissions = useCanModerate(streamerDID);
56
78
 
57
79
  // get the channel did
58
80
  const channelId = usePlayerStore((state) => state.src);
59
81
  // get the logged in user's identity
60
82
  const handle = useStreamplaceStore((state) => state.handle);
61
83
 
62
- if (!agent?.did) {
63
- <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}>
64
- <Text>Log in to submit mod actions</Text>
65
- </View>;
66
- }
67
-
68
84
  const cleanup = () => {
69
85
  setModMessage(null);
70
86
  };
71
87
 
88
+ // Effect must be called unconditionally (before any early returns)
72
89
  useEffect(() => {
73
90
  if (message) {
74
91
  setMessageRemoved(false);
@@ -78,125 +95,258 @@ export const ModView = forwardRef<ModViewRef, ModViewProps>(() => {
78
95
  }
79
96
  }, [message]);
80
97
 
98
+ // Early return AFTER all hooks have been called
99
+ if (!agent?.did) {
100
+ return <></>;
101
+ }
102
+
103
+ // Can show moderation actions if user can hide, ban, or manage livestream
104
+ const canModerate =
105
+ modPermissions.canHide ||
106
+ modPermissions.canBan ||
107
+ modPermissions.canManageLivestream;
108
+
109
+ // Check if any moderation actions are actually available for this message
110
+ // This must match the individual action checks inside the DropdownMenuGroup
111
+ const hasAvailableActions = !!(
112
+ message &&
113
+ agent?.did &&
114
+ ((modPermissions.canHide && message.author.did !== streamerDID) ||
115
+ (modPermissions.canBan &&
116
+ message.author.did !== agent.did &&
117
+ message.author.did !== streamerDID))
118
+ );
119
+
81
120
  return (
82
- <DropdownMenu
83
- style={[layout.flex.row, layout.flex.alignCenter, gap.all[2], w[80]]}
84
- onOpenChange={(isOpen) => {
85
- if (!isOpen) {
86
- cleanup();
87
- }
88
- }}
89
- >
90
- <DropdownMenuTrigger ref={triggerRef}>
91
- {/* Hidden trigger */}
92
- <View />
93
- </DropdownMenuTrigger>
94
- <ResponsiveDropdownMenuContent>
95
- {message && (
96
- <>
97
- <DropdownMenuGroup>
98
- <DropdownMenuItem>
99
- <View
100
- style={[
101
- layout.flex.column,
102
- mr[5],
103
- { gap: 6, maxWidth: "100%" },
104
- ]}
105
- >
106
- <Text
107
- style={{
108
- fontVariant: ["tabular-nums"],
109
- color: atoms.colors.gray[300],
110
- }}
111
- >
112
- {new Date(message.record.createdAt).toLocaleTimeString([], {
113
- hour: "2-digit",
114
- minute: "2-digit",
115
- hour12: false,
116
- })}{" "}
117
- {formatHandleWithAt(message.author)}: {message.record.text}
118
- </Text>
119
- </View>
120
- </DropdownMenuItem>
121
- </DropdownMenuGroup>
122
-
123
- {/* TODO: Checking for non-owner moderators */}
124
- {isMyStream() && (
125
- <DropdownMenuGroup title={`Moderation actions`}>
126
- <DropdownMenuItem
127
- disabled={isHideLoading || messageRemoved}
128
- onPress={() => {
129
- if (isHideLoading || messageRemoved) return;
130
- createHideChat(message.uri)
131
- .then((r) => setMessageRemoved(true))
132
- .catch((e) => console.error(e));
133
- }}
134
- >
135
- <Text
136
- color={
137
- isHideLoading || messageRemoved ? "muted" : "destructive"
138
- }
139
- >
140
- {isHideLoading
141
- ? "Removing..."
142
- : messageRemoved
143
- ? "Message removed"
144
- : "Remove this message"}
145
- </Text>
146
- </DropdownMenuItem>
147
- <DropdownMenuItem
148
- disabled={message.author.did === agent?.did || isBlockLoading}
149
- onPress={() => {
150
- createBlock(message.author.did)
151
- .then((r) => console.log(r))
152
- .catch((e) => console.error(e));
153
- }}
154
- >
155
- {message.author.did === agent?.did ? (
156
- <Text color="muted">
157
- Block yourself (you can't block yourself)
158
- </Text>
159
- ) : (
160
- <Text color="destructive">
161
- {isBlockLoading
162
- ? "Blocking..."
163
- : `Block user ${formatHandleWithAt(message.author)} from this channel`}
164
- </Text>
165
- )}
166
- </DropdownMenuItem>
167
- </DropdownMenuGroup>
168
- )}
121
+ <>
122
+ <DropdownMenu
123
+ style={[layout.flex.row, layout.flex.alignCenter, gap.all[2], w[80]]}
124
+ onOpenChange={(isOpen) => {
125
+ if (!isOpen) {
126
+ cleanup();
127
+ }
128
+ }}
129
+ >
130
+ <DropdownMenuTrigger ref={triggerRef}>
131
+ {/* Hidden trigger */}
132
+ <View />
133
+ </DropdownMenuTrigger>
134
+ <ResponsiveDropdownMenuContent>
135
+ {message && (
136
+ <ModViewContent
137
+ message={message}
138
+ modPermissions={modPermissions}
139
+ agent={agent}
140
+ streamerDID={streamerDID}
141
+ hasAvailableActions={hasAvailableActions}
142
+ isHideLoading={isHideLoading}
143
+ isBlockLoading={isBlockLoading}
144
+ messageRemoved={messageRemoved}
145
+ setMessageRemoved={setMessageRemoved}
146
+ createHideChat={createHideChat}
147
+ createBlock={createBlock}
148
+ toast={toast}
149
+ setShowUpdateTitleDialog={setShowUpdateTitleDialog}
150
+ isUpdateTitleLoading={isUpdateTitleLoading}
151
+ livestream={livestream}
152
+ setReportModalOpen={setReportModalOpen}
153
+ setReportSubject={setReportSubject}
154
+ deleteChatMessage={deleteChatMessage}
155
+ />
156
+ )}
157
+ </ResponsiveDropdownMenuContent>
158
+ </DropdownMenu>
159
+
160
+ {/* Update Stream Title Dialog - rendered outside dropdown */}
161
+ {showUpdateTitleDialog && (
162
+ <UpdateStreamTitleDialog
163
+ livestream={livestream}
164
+ streamerDID={streamerDID}
165
+ updateLivestream={updateLivestream}
166
+ isLoading={isUpdateTitleLoading}
167
+ onClose={() => setShowUpdateTitleDialog(false)}
168
+ />
169
+ )}
170
+ </>
171
+ );
172
+ });
173
+
174
+ interface ModViewContentProps {
175
+ message: ChatMessageViewHydrated;
176
+ modPermissions: ModerationPermissions;
177
+ agent: ReturnType<typeof usePDSAgent>;
178
+ streamerDID?: string;
179
+ hasAvailableActions: boolean;
180
+ isHideLoading: boolean;
181
+ isBlockLoading: boolean;
182
+ messageRemoved: boolean;
183
+ setMessageRemoved: (removed: boolean) => void;
184
+ createHideChat: (uri: string, streamerDID?: string) => Promise<any>;
185
+ createBlock: (did: string, streamerDID?: string) => Promise<any>;
186
+ toast: ReturnType<typeof useToast>;
187
+ setShowUpdateTitleDialog: (show: boolean) => void;
188
+ isUpdateTitleLoading: boolean;
189
+ livestream: any;
190
+ setReportModalOpen: (open: boolean) => void;
191
+ setReportSubject: (subject: any) => void;
192
+ deleteChatMessage: (uri: string) => Promise<any>;
193
+ }
194
+
195
+ function ModViewContent({
196
+ message,
197
+ modPermissions,
198
+ agent,
199
+ streamerDID,
200
+ hasAvailableActions,
201
+ isHideLoading,
202
+ isBlockLoading,
203
+ messageRemoved,
204
+ setMessageRemoved,
205
+ createHideChat,
206
+ createBlock,
207
+ toast,
208
+ setShowUpdateTitleDialog,
209
+ isUpdateTitleLoading,
210
+ livestream,
211
+ setReportModalOpen,
212
+ setReportSubject,
213
+ deleteChatMessage,
214
+ }: ModViewContentProps) {
215
+ const { onOpenChange } = useRootContext();
216
+
217
+ return (
218
+ <>
219
+ <DropdownMenuGroup key="message-display">
220
+ <DropdownMenuItem>
221
+ <View
222
+ style={[layout.flex.column, mr[5], { gap: 6, maxWidth: "100%" }]}
223
+ >
224
+ <Text
225
+ style={{
226
+ fontVariant: ["tabular-nums"],
227
+ color: atoms.colors.gray[300],
228
+ }}
229
+ >
230
+ {new Date(message.record.createdAt).toLocaleTimeString([], {
231
+ hour: "2-digit",
232
+ minute: "2-digit",
233
+ hour12: false,
234
+ })}{" "}
235
+ {formatHandleWithAt(message.author)}: {message.record.text}
236
+ </Text>
237
+ </View>
238
+ </DropdownMenuItem>
239
+ </DropdownMenuGroup>
169
240
 
170
- <DropdownMenuGroup title={`User actions`}>
241
+ {hasAvailableActions && (
242
+ <DropdownMenuGroup
243
+ key="moderation-actions"
244
+ title={`Moderation actions`}
245
+ >
246
+ {modPermissions.canHide && message.author.did !== streamerDID && (
247
+ <DropdownMenuItem
248
+ disabled={isHideLoading || messageRemoved}
249
+ onPress={() => {
250
+ if (isHideLoading || messageRemoved) return;
251
+ createHideChat(message.uri, streamerDID ?? undefined)
252
+ .then((r) => setMessageRemoved(true))
253
+ .catch((e) => console.error(e));
254
+ }}
255
+ >
256
+ <Text
257
+ color={isHideLoading || messageRemoved ? "muted" : "warning"}
258
+ >
259
+ {isHideLoading
260
+ ? "Hiding..."
261
+ : messageRemoved
262
+ ? "Message hidden"
263
+ : "Hide this message"}
264
+ </Text>
265
+ </DropdownMenuItem>
266
+ )}
267
+ {modPermissions.canBan &&
268
+ agent?.did &&
269
+ message.author.did !== agent.did &&
270
+ message.author.did !== streamerDID && (
171
271
  <DropdownMenuItem
272
+ disabled={isBlockLoading}
172
273
  onPress={() => {
173
- Linking.openURL(
174
- `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`,
175
- );
274
+ if (isBlockLoading) return;
275
+ createBlock(message.author.did, streamerDID ?? undefined)
276
+ .then((r) => {
277
+ toast.show(
278
+ "User blocked",
279
+ `${formatHandleWithAt(message.author)} has been blocked from this channel.`,
280
+ { duration: 3 },
281
+ );
282
+ onOpenChange?.(false);
283
+ })
284
+ .catch((e) => {
285
+ console.error(e);
286
+ toast.show(
287
+ "Error blocking user",
288
+ e instanceof Error ? e.message : "Failed to block user",
289
+ { duration: 5 },
290
+ );
291
+ });
176
292
  }}
177
293
  >
178
- <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text>
294
+ <Text color="destructive">
295
+ {isBlockLoading
296
+ ? "Blocking..."
297
+ : `Block user ${formatHandleWithAt(message.author)} from this channel`}
298
+ </Text>
179
299
  </DropdownMenuItem>
180
- {message.author.did === agent?.did && (
181
- <DeleteButton
182
- message={message}
183
- deleteChatMessage={deleteChatMessage}
184
- />
185
- )}
186
- {message.author.did !== agent?.did && (
187
- <ReportButton
188
- message={message}
189
- setReportModalOpen={setReportModalOpen}
190
- setReportSubject={setReportSubject}
191
- />
192
- )}
193
- </DropdownMenuGroup>
194
- </>
300
+ )}
301
+ </DropdownMenuGroup>
302
+ )}
303
+
304
+ {modPermissions.canManageLivestream && (
305
+ <DropdownMenuGroup key="stream-actions" title={`Stream actions`}>
306
+ <DropdownMenuItem
307
+ onPress={() => {
308
+ setShowUpdateTitleDialog(true);
309
+ }}
310
+ disabled={isUpdateTitleLoading || !livestream}
311
+ >
312
+ <Text
313
+ color={isUpdateTitleLoading || !livestream ? "muted" : "primary"}
314
+ >
315
+ {isUpdateTitleLoading ? "Updating..." : "Update stream title"}
316
+ </Text>
317
+ </DropdownMenuItem>
318
+ </DropdownMenuGroup>
319
+ )}
320
+
321
+ <DropdownMenuGroup key="user-actions" title={`User actions`}>
322
+ <DropdownMenuItem
323
+ onPress={() => {
324
+ Linking.openURL(
325
+ `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`,
326
+ );
327
+ }}
328
+ >
329
+ <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text>
330
+ </DropdownMenuItem>
331
+ {message.author.did === agent?.did && (
332
+ <DeleteButton
333
+ message={message}
334
+ deleteChatMessage={deleteChatMessage}
335
+ onOpenChange={onOpenChange}
336
+ />
337
+ )}
338
+ {message.author.did !== agent?.did && (
339
+ <ReportButton
340
+ message={message}
341
+ setReportModalOpen={setReportModalOpen}
342
+ setReportSubject={setReportSubject}
343
+ onOpenChange={onOpenChange}
344
+ />
195
345
  )}
196
- </ResponsiveDropdownMenuContent>
197
- </DropdownMenu>
346
+ </DropdownMenuGroup>
347
+ </>
198
348
  );
199
- });
349
+ }
200
350
 
201
351
  enum DeleteState {
202
352
  None,
@@ -207,12 +357,13 @@ enum DeleteState {
207
357
  export function DeleteButton({
208
358
  message,
209
359
  deleteChatMessage,
360
+ onOpenChange,
210
361
  }: {
211
362
  message: ChatMessageViewHydrated;
212
363
  deleteChatMessage: (uri: string) => Promise<any>;
364
+ onOpenChange?: (open: boolean) => void;
213
365
  }) {
214
366
  const [confirming, setConfirming] = useState<DeleteState>(DeleteState.None);
215
- const { onOpenChange } = useRootContext();
216
367
  const toast = useToast();
217
368
  return (
218
369
  <DropdownMenuItem
@@ -253,12 +404,13 @@ export function ReportButton({
253
404
  message,
254
405
  setReportModalOpen,
255
406
  setReportSubject,
407
+ onOpenChange,
256
408
  }: {
257
409
  message: ChatMessageViewHydrated;
258
410
  setReportModalOpen: (open: boolean) => void;
259
411
  setReportSubject: (subject: any) => void;
412
+ onOpenChange?: (open: boolean) => void;
260
413
  }) {
261
- const { onOpenChange } = useRootContext();
262
414
  return (
263
415
  <DropdownMenuItem
264
416
  onPress={() => {
@@ -277,3 +429,161 @@ export function ReportButton({
277
429
  </DropdownMenuItem>
278
430
  );
279
431
  }
432
+
433
+ interface UpdateStreamTitleDialogProps {
434
+ livestream: any;
435
+ streamerDID?: string;
436
+ updateLivestream: (
437
+ livestreamUri: string,
438
+ title: string,
439
+ streamerDID?: string,
440
+ ) => Promise<any>;
441
+ isLoading: boolean;
442
+ onClose: () => void;
443
+ }
444
+
445
+ function UpdateStreamTitleDialog({
446
+ livestream,
447
+ streamerDID,
448
+ updateLivestream,
449
+ isLoading,
450
+ onClose,
451
+ }: UpdateStreamTitleDialogProps) {
452
+ const [title, setTitle] = useState(livestream?.record?.title || "");
453
+ const [error, setError] = useState<string | null>(null);
454
+ const toast = useToast();
455
+
456
+ useEffect(() => {
457
+ if (livestream?.record?.title) {
458
+ setTitle(livestream.record.title);
459
+ }
460
+ }, [livestream?.record?.title]);
461
+
462
+ const handleUpdate = async () => {
463
+ setError(null);
464
+
465
+ if (!title.trim()) {
466
+ setError("Please enter a stream title");
467
+ return;
468
+ }
469
+
470
+ if (!livestream?.uri) {
471
+ setError("No livestream found");
472
+ return;
473
+ }
474
+
475
+ try {
476
+ await updateLivestream(livestream.uri, title.trim(), streamerDID);
477
+ toast.show(
478
+ "Stream title updated",
479
+ "The stream title has been successfully updated.",
480
+ { duration: 3 },
481
+ );
482
+ onClose();
483
+ } catch (err) {
484
+ setError(
485
+ err instanceof Error ? err.message : "Failed to update stream title",
486
+ );
487
+ }
488
+ };
489
+
490
+ return (
491
+ <ResponsiveDialog
492
+ open={true}
493
+ onOpenChange={(open) => {
494
+ if (!open) {
495
+ onClose();
496
+ setError(null);
497
+ setTitle(livestream?.record?.title || "");
498
+ }
499
+ }}
500
+ title="Update Stream Title"
501
+ description="Update the title of the livestream."
502
+ size="md"
503
+ dismissible={false}
504
+ >
505
+ <View style={[{ padding: 16, paddingBottom: 0 }]}>
506
+ <View style={[{ marginBottom: 16 }]}>
507
+ <Text
508
+ style={[
509
+ { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 },
510
+ ]}
511
+ >
512
+ Stream Title
513
+ </Text>
514
+ <Textarea
515
+ value={title}
516
+ onChangeText={(text) => {
517
+ setTitle(text);
518
+ setError(null);
519
+ }}
520
+ placeholder="Enter stream title..."
521
+ maxLength={140}
522
+ multiline
523
+ style={[
524
+ {
525
+ padding: 12,
526
+ borderRadius: 8,
527
+ backgroundColor: atoms.colors.neutral[800],
528
+ color: atoms.colors.white,
529
+ borderWidth: 1,
530
+ borderColor: atoms.colors.neutral[600],
531
+ minHeight: 100,
532
+ fontSize: 16,
533
+ },
534
+ ]}
535
+ />
536
+ <Text
537
+ style={[
538
+ { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 },
539
+ ]}
540
+ >
541
+ {title.length}/140 characters
542
+ </Text>
543
+ </View>
544
+
545
+ {error && (
546
+ <View
547
+ style={[
548
+ {
549
+ backgroundColor: atoms.colors.red[900],
550
+ padding: 12,
551
+ borderRadius: 8,
552
+ borderWidth: 1,
553
+ borderColor: atoms.colors.red[700],
554
+ marginBottom: 16,
555
+ },
556
+ ]}
557
+ >
558
+ <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}>
559
+ {error}
560
+ </Text>
561
+ </View>
562
+ )}
563
+ </View>
564
+
565
+ <DialogFooter>
566
+ <Button
567
+ width="min"
568
+ variant="secondary"
569
+ onPress={() => {
570
+ onClose();
571
+ setError(null);
572
+ setTitle(livestream?.record?.title || "");
573
+ }}
574
+ disabled={isLoading}
575
+ >
576
+ <Text>Cancel</Text>
577
+ </Button>
578
+ <Button
579
+ variant="primary"
580
+ width="min"
581
+ onPress={handleUpdate}
582
+ disabled={isLoading || !title.trim()}
583
+ >
584
+ <Text>{isLoading ? "Updating..." : "Update Title"}</Text>
585
+ </Button>
586
+ </DialogFooter>
587
+ </ResponsiveDialog>
588
+ );
589
+ }
@@ -1,5 +1,4 @@
1
1
  import { Text, View } from "react-native";
2
- import emojiData from "../../assets/emoji-data.json";
3
2
  import * as zero from "../../ui";
4
3
  import { Chat } from "../chat/chat";
5
4
  import { ChatBox } from "../chat/chat-box";
@@ -11,6 +10,7 @@ interface ChatPanelProps {
11
10
  isConnected: boolean;
12
11
  messagesPerMinute?: number;
13
12
  shownMessages?: number;
13
+ emojiData?: any;
14
14
  }
15
15
 
16
16
  export default function ChatPanel({
@@ -18,6 +18,7 @@ export default function ChatPanel({
18
18
  isConnected,
19
19
  messagesPerMinute = 0,
20
20
  shownMessages = 50,
21
+ emojiData = null,
21
22
  }: ChatPanelProps) {
22
23
  return (
23
24
  <View
@@ -2,4 +2,5 @@ export { default as ChatPanel } from "./chat-panel";
2
2
  export { default as Header } from "./header";
3
3
  export { default as InformationWidget } from "./information-widget";
4
4
  export { default as ModActions } from "./mod-actions";
5
+ export { default as ModeratorPanel } from "./moderator-panel";
5
6
  export { default as Problems, ProblemsWrapperRef } from "./problems";