@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,632 @@
1
+ import { Check, Shield, Trash2, UserPlus } from "lucide-react-native";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Pressable, ScrollView, Text, View } from "react-native";
4
+ import {
5
+ Button,
6
+ Dialog,
7
+ DialogFooter,
8
+ Input,
9
+ ResponsiveDialog,
10
+ useToast,
11
+ } from "../../components/ui";
12
+ import { useAvatars } from "../../hooks/useAvatars";
13
+ import { atoms } from "../../lib/theme";
14
+ import {
15
+ useAddModerator,
16
+ useListModerators,
17
+ useRemoveModerator,
18
+ } from "../../streamplace-store/moderator-management";
19
+ import { formatHandleWithAt } from "../../utils/format-handle";
20
+
21
+ const {
22
+ flex,
23
+ bg,
24
+ r,
25
+ borders,
26
+ p,
27
+ text: textStyle,
28
+ layout,
29
+ gap,
30
+ mb,
31
+ my,
32
+ px,
33
+ py,
34
+ } = atoms;
35
+
36
+ interface ModeratorPanelProps {
37
+ isLive?: boolean;
38
+ embedded?: boolean; // If true, removes outer container styling (for use inside other panels)
39
+ }
40
+
41
+ export default function ModeratorPanel({
42
+ isLive,
43
+ embedded = false,
44
+ }: ModeratorPanelProps) {
45
+ const { moderators, isLoading, error, refresh } = useListModerators();
46
+ const [showAddDialog, setShowAddDialog] = useState(false);
47
+
48
+ // Collect all moderator DIDs for batch fetching profiles
49
+ const moderatorDIDs = useMemo(
50
+ () => moderators.map((mod) => mod.value.moderator),
51
+ [moderators],
52
+ );
53
+
54
+ const containerStyle = embedded
55
+ ? [flex.values[1], layout.flex.column]
56
+ : [
57
+ flex.values[1],
58
+ bg.neutral[900],
59
+ r.lg,
60
+ borders.width.thin,
61
+ borders.color.neutral[700],
62
+ layout.flex.column,
63
+ ];
64
+
65
+ return (
66
+ <View style={containerStyle}>
67
+ {/* Header */}
68
+ <View
69
+ style={[
70
+ layout.flex.row,
71
+ layout.flex.spaceBetween,
72
+ layout.flex.alignCenter,
73
+ borders.bottom.width.thin,
74
+ borders.bottom.color.neutral[700],
75
+ p[4],
76
+ ]}
77
+ >
78
+ <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}>
79
+ <Shield size={18} color="#ffffff" />
80
+ <Text style={[textStyle.white, { fontSize: 18, fontWeight: "600" }]}>
81
+ Moderators
82
+ </Text>
83
+ </View>
84
+ <Pressable
85
+ onPress={() => setShowAddDialog(true)}
86
+ style={[
87
+ layout.flex.row,
88
+ layout.flex.alignCenter,
89
+ gap.all[2],
90
+ bg.blue[600],
91
+ px[3],
92
+ py[2],
93
+ r.md,
94
+ ]}
95
+ >
96
+ <UserPlus size={16} color="#ffffff" />
97
+ <Text style={[textStyle.white, { fontSize: 13, fontWeight: "500" }]}>
98
+ Add
99
+ </Text>
100
+ </Pressable>
101
+ </View>
102
+
103
+ {/* Content */}
104
+ <ScrollView style={[flex.values[1], p[4]]}>
105
+ {isLoading && moderators.length === 0 && (
106
+ <Text
107
+ style={[textStyle.gray[400], { fontSize: 14, textAlign: "center" }]}
108
+ >
109
+ Loading moderators...
110
+ </Text>
111
+ )}
112
+
113
+ {error && (
114
+ <View
115
+ style={[
116
+ bg.red[900],
117
+ p[3],
118
+ r.md,
119
+ borders.width.thin,
120
+ borders.color.red[700],
121
+ ]}
122
+ >
123
+ <Text style={[textStyle.red[400], { fontSize: 13 }]}>{error}</Text>
124
+ </View>
125
+ )}
126
+
127
+ {!isLoading && moderators.length === 0 && !error && (
128
+ <View style={[layout.flex.center, p[6]]}>
129
+ <Shield size={48} color="#6b7280" style={[mb[4]]} />
130
+ <Text
131
+ style={[
132
+ textStyle.gray[400],
133
+ { fontSize: 14, textAlign: "center", marginBottom: 8 },
134
+ ]}
135
+ >
136
+ No moderators yet
137
+ </Text>
138
+ <Text
139
+ style={[
140
+ textStyle.gray[500],
141
+ { fontSize: 12, textAlign: "center" },
142
+ ]}
143
+ >
144
+ Add moderators to help manage your chat
145
+ </Text>
146
+ </View>
147
+ )}
148
+
149
+ {moderators.map((mod, index) => (
150
+ <ModeratorCard
151
+ key={mod.rkey}
152
+ moderator={mod}
153
+ onRemove={refresh}
154
+ isLast={index === moderators.length - 1}
155
+ moderatorDIDs={moderatorDIDs}
156
+ />
157
+ ))}
158
+ </ScrollView>
159
+
160
+ {/* Add Moderator Dialog */}
161
+ <AddModeratorDialog
162
+ visible={showAddDialog}
163
+ onClose={() => setShowAddDialog(false)}
164
+ onSuccess={() => {
165
+ setShowAddDialog(false);
166
+ refresh();
167
+ }}
168
+ />
169
+ </View>
170
+ );
171
+ }
172
+
173
+ interface ModeratorCardProps {
174
+ moderator: {
175
+ uri: string;
176
+ cid: string;
177
+ value: {
178
+ moderator: string;
179
+ permissions: string[];
180
+ createdAt: string;
181
+ expirationTime?: string;
182
+ };
183
+ rkey: string;
184
+ };
185
+ onRemove: () => void;
186
+ isLast: boolean;
187
+ moderatorDIDs: string[]; // All moderator DIDs for batch fetching
188
+ }
189
+
190
+ function ModeratorCard({
191
+ moderator,
192
+ onRemove,
193
+ isLast,
194
+ moderatorDIDs,
195
+ }: ModeratorCardProps) {
196
+ const { removeModerator, isLoading } = useRemoveModerator();
197
+ const [showConfirm, setShowConfirm] = useState(false);
198
+ const toast = useToast();
199
+
200
+ // Use useAvatars hook to batch-fetch profiles for all moderators
201
+ const profiles = useAvatars(moderatorDIDs);
202
+ const profile = profiles[moderator.value.moderator];
203
+
204
+ // Format display name using existing utility
205
+ const displayName = useMemo(() => {
206
+ if (profile) {
207
+ return formatHandleWithAt(profile);
208
+ }
209
+ return moderator.value.moderator; // Fall back to DID
210
+ }, [profile, moderator.value.moderator]);
211
+
212
+ const handleRemove = async () => {
213
+ try {
214
+ await removeModerator(moderator.rkey);
215
+ setShowConfirm(false);
216
+ toast.show(
217
+ "Moderator removed",
218
+ "The moderator has been successfully removed.",
219
+ { duration: 3 },
220
+ );
221
+ onRemove();
222
+ } catch (err) {
223
+ console.error("Failed to remove moderator:", err);
224
+ toast.show(
225
+ "Error removing moderator",
226
+ err instanceof Error ? err.message : "Failed to remove moderator",
227
+ { duration: 5 },
228
+ );
229
+ }
230
+ };
231
+
232
+ const isExpired =
233
+ moderator.value.expirationTime &&
234
+ new Date(moderator.value.expirationTime) < new Date();
235
+
236
+ return (
237
+ <View
238
+ style={[
239
+ layout.flex.row,
240
+ layout.flex.spaceBetween,
241
+ layout.flex.alignCenter,
242
+ p[3],
243
+ bg.neutral[800],
244
+ r.md,
245
+ !isLast && mb[2],
246
+ borders.width.thin,
247
+ borders.color.neutral[700],
248
+ ]}
249
+ >
250
+ <View style={[flex.values[1]]}>
251
+ <Text
252
+ style={[
253
+ textStyle.white,
254
+ { fontSize: 14, fontWeight: "500", marginBottom: 4 },
255
+ ]}
256
+ numberOfLines={1}
257
+ >
258
+ {displayName}
259
+ </Text>
260
+ <View style={[layout.flex.row, { flexWrap: "wrap" }, gap.all[1]]}>
261
+ {moderator.value.permissions.map((perm) => (
262
+ <View
263
+ key={perm}
264
+ style={[
265
+ bg.blue[900],
266
+ px[2],
267
+ py[1],
268
+ r.sm,
269
+ borders.width.thin,
270
+ borders.color.blue[700],
271
+ ]}
272
+ >
273
+ <Text
274
+ style={[
275
+ textStyle.blue[300],
276
+ { fontSize: 11, fontWeight: "500" },
277
+ ]}
278
+ >
279
+ {perm}
280
+ </Text>
281
+ </View>
282
+ ))}
283
+ {isExpired && (
284
+ <View
285
+ style={[
286
+ bg.red[900],
287
+ px[2],
288
+ py[1],
289
+ r.sm,
290
+ borders.width.thin,
291
+ borders.color.red[700],
292
+ ]}
293
+ >
294
+ <Text
295
+ style={[
296
+ textStyle.red[300],
297
+ { fontSize: 11, fontWeight: "500" },
298
+ ]}
299
+ >
300
+ Expired
301
+ </Text>
302
+ </View>
303
+ )}
304
+ </View>
305
+ {moderator.value.expirationTime && !isExpired && (
306
+ <Text style={[textStyle.gray[400], { fontSize: 11, marginTop: 4 }]}>
307
+ Expires:{" "}
308
+ {new Date(moderator.value.expirationTime).toLocaleDateString()}
309
+ </Text>
310
+ )}
311
+ </View>
312
+ <Pressable
313
+ onPress={() => setShowConfirm(true)}
314
+ disabled={isLoading}
315
+ style={[
316
+ p[2],
317
+ r.md,
318
+ bg.red[900],
319
+ borders.width.thin,
320
+ borders.color.red[700],
321
+ isLoading && { opacity: 0.5 },
322
+ ]}
323
+ >
324
+ <Trash2 size={16} color="#f87171" />
325
+ </Pressable>
326
+
327
+ {/* Confirm Delete Dialog */}
328
+ <Dialog
329
+ open={showConfirm}
330
+ onOpenChange={setShowConfirm}
331
+ title="Remove Moderator?"
332
+ description="This will revoke all moderation permissions for this user."
333
+ dismissible={false}
334
+ >
335
+ <DialogFooter>
336
+ <Button
337
+ width="min"
338
+ variant="secondary"
339
+ onPress={() => setShowConfirm(false)}
340
+ disabled={isLoading}
341
+ >
342
+ <Text>Cancel</Text>
343
+ </Button>
344
+ <Button
345
+ width="min"
346
+ variant="destructive"
347
+ onPress={handleRemove}
348
+ disabled={isLoading}
349
+ >
350
+ <Text>{isLoading ? "Removing..." : "Remove"}</Text>
351
+ </Button>
352
+ </DialogFooter>
353
+ </Dialog>
354
+ </View>
355
+ );
356
+ }
357
+
358
+ interface AddModeratorDialogProps {
359
+ visible: boolean;
360
+ onClose: () => void;
361
+ onSuccess: () => void;
362
+ }
363
+
364
+ function AddModeratorDialog({
365
+ visible,
366
+ onClose,
367
+ onSuccess,
368
+ }: AddModeratorDialogProps) {
369
+ const { addModerator, isLoading } = useAddModerator();
370
+ const [moderatorDID, setModeratorDID] = useState("");
371
+ const [permissions, setPermissions] = useState({
372
+ ban: false,
373
+ hide: false,
374
+ "livestream.manage": false,
375
+ });
376
+ const [error, setError] = useState<string | null>(null);
377
+ const toast = useToast();
378
+
379
+ // Reset form when dialog closes
380
+ useEffect(() => {
381
+ if (!visible) {
382
+ setModeratorDID("");
383
+ setPermissions({ ban: false, hide: false, "livestream.manage": false });
384
+ setError(null);
385
+ }
386
+ }, [visible]);
387
+
388
+ // Clear error when user starts typing
389
+ const handleDIDChange = (text: string) => {
390
+ setModeratorDID(text);
391
+ if (error) setError(null);
392
+ };
393
+
394
+ const handleAdd = async () => {
395
+ setError(null);
396
+
397
+ if (!moderatorDID.trim()) {
398
+ setError("Please enter a DID or handle");
399
+ return;
400
+ }
401
+
402
+ const selectedPermissions = Object.entries(permissions)
403
+ .filter(([_, enabled]) => enabled)
404
+ .map(([perm]) => perm) as ("ban" | "hide" | "livestream.manage")[];
405
+
406
+ if (selectedPermissions.length === 0) {
407
+ setError("Please select at least one permission");
408
+ return;
409
+ }
410
+
411
+ try {
412
+ await addModerator({
413
+ moderatorDID: moderatorDID.trim(),
414
+ permissions: selectedPermissions,
415
+ });
416
+ toast.show(
417
+ "Moderator added",
418
+ "The new moderator has been added successfully.",
419
+ { duration: 3 },
420
+ );
421
+ onSuccess();
422
+ } catch (err) {
423
+ setError(err instanceof Error ? err.message : "Failed to add moderator");
424
+ }
425
+ };
426
+
427
+ return (
428
+ <ResponsiveDialog
429
+ open={visible}
430
+ onOpenChange={(open) => {
431
+ if (!open) onClose();
432
+ }}
433
+ title="Add Moderator"
434
+ description="Enter the DID or handle of the user you want to add as a moderator and select their permissions."
435
+ size="md"
436
+ dismissible={false}
437
+ >
438
+ {/* DID Input */}
439
+ <View style={[my[4]]}>
440
+ <Text style={[textStyle.gray[300], { fontSize: 13, marginBottom: 8 }]}>
441
+ Moderator DID or Handle
442
+ </Text>
443
+ <Input
444
+ value={moderatorDID}
445
+ onChangeText={handleDIDChange}
446
+ placeholder="did:plc:... or @handle.bsky.social"
447
+ onSubmitEditing={handleAdd}
448
+ returnKeyType="done"
449
+ />
450
+ </View>
451
+
452
+ {/* Permissions */}
453
+ <View style={[mb[4]]}>
454
+ <Text style={[textStyle.gray[300], { fontSize: 13, marginBottom: 8 }]}>
455
+ Permissions
456
+ </Text>
457
+ <View style={[gap.all[2]]}>
458
+ <Pressable
459
+ onPress={() => setPermissions((p) => ({ ...p, ban: !p.ban }))}
460
+ style={[
461
+ layout.flex.row,
462
+ layout.flex.alignCenter,
463
+ p[3],
464
+ r.md,
465
+ bg.neutral[800],
466
+ borders.width.thin,
467
+ borders.color.neutral[700],
468
+ ]}
469
+ >
470
+ <View
471
+ style={[
472
+ {
473
+ width: 20,
474
+ height: 20,
475
+ borderRadius: 4,
476
+ },
477
+ borders.width.thin,
478
+ borders.color.neutral[600],
479
+ permissions.ban ? bg.blue[600] : bg.neutral[900],
480
+ layout.flex.center,
481
+ { marginRight: 12 },
482
+ ]}
483
+ >
484
+ {permissions.ban && <Check size={12} color="white" />}
485
+ </View>
486
+ <View>
487
+ <Text
488
+ style={[textStyle.white, { fontSize: 14, fontWeight: "500" }]}
489
+ >
490
+ Ban Users
491
+ </Text>
492
+ <Text style={[textStyle.gray[400], { fontSize: 12 }]}>
493
+ Block users from chat
494
+ </Text>
495
+ </View>
496
+ </Pressable>
497
+
498
+ <Pressable
499
+ onPress={() => setPermissions((p) => ({ ...p, hide: !p.hide }))}
500
+ style={[
501
+ layout.flex.row,
502
+ layout.flex.alignCenter,
503
+ p[3],
504
+ r.md,
505
+ bg.neutral[800],
506
+ borders.width.thin,
507
+ borders.color.neutral[700],
508
+ ]}
509
+ >
510
+ <View
511
+ style={[
512
+ {
513
+ width: 20,
514
+ height: 20,
515
+ borderRadius: 4,
516
+ },
517
+ borders.width.thin,
518
+ borders.color.neutral[600],
519
+ permissions.hide ? bg.blue[600] : bg.neutral[900],
520
+ layout.flex.center,
521
+ { marginRight: 12 },
522
+ ]}
523
+ >
524
+ {permissions.hide && (
525
+ <Text style={[textStyle.white, { fontSize: 12 }]}>✓</Text>
526
+ )}
527
+ </View>
528
+ <View>
529
+ <Text
530
+ style={[textStyle.white, { fontSize: 14, fontWeight: "500" }]}
531
+ >
532
+ Hide Messages
533
+ </Text>
534
+ <Text style={[textStyle.gray[400], { fontSize: 12 }]}>
535
+ Hide individual chat messages
536
+ </Text>
537
+ </View>
538
+ </Pressable>
539
+
540
+ <Pressable
541
+ onPress={() =>
542
+ setPermissions((p) => ({
543
+ ...p,
544
+ "livestream.manage": !p["livestream.manage"],
545
+ }))
546
+ }
547
+ style={[
548
+ layout.flex.row,
549
+ layout.flex.alignCenter,
550
+ p[3],
551
+ r.md,
552
+ bg.neutral[800],
553
+ borders.width.thin,
554
+ borders.color.neutral[700],
555
+ ]}
556
+ >
557
+ <View
558
+ style={[
559
+ {
560
+ width: 20,
561
+ height: 20,
562
+ borderRadius: 4,
563
+ },
564
+ borders.width.thin,
565
+ borders.color.neutral[600],
566
+ permissions["livestream.manage"]
567
+ ? bg.blue[600]
568
+ : bg.neutral[900],
569
+ layout.flex.center,
570
+ { marginRight: 12 },
571
+ ]}
572
+ >
573
+ {permissions["livestream.manage"] && (
574
+ <Text style={[textStyle.white, { fontSize: 12 }]}>✓</Text>
575
+ )}
576
+ </View>
577
+ <View>
578
+ <Text
579
+ style={[textStyle.white, { fontSize: 14, fontWeight: "500" }]}
580
+ >
581
+ Manage Livestream
582
+ </Text>
583
+ <Text style={[textStyle.gray[400], { fontSize: 12 }]}>
584
+ Update stream title
585
+ </Text>
586
+ </View>
587
+ </Pressable>
588
+ </View>
589
+ </View>
590
+
591
+ {/* Error */}
592
+ {error && (
593
+ <View
594
+ style={[
595
+ bg.red[900],
596
+ p[3],
597
+ r.md,
598
+ borders.width.thin,
599
+ borders.color.red[700],
600
+ mb[4],
601
+ ]}
602
+ >
603
+ <Text style={[textStyle.red[400], { fontSize: 13 }]}>{error}</Text>
604
+ </View>
605
+ )}
606
+
607
+ {/* Actions */}
608
+ <DialogFooter>
609
+ <Button
610
+ width="min"
611
+ variant="secondary"
612
+ onPress={onClose}
613
+ disabled={isLoading}
614
+ >
615
+ Cancel
616
+ </Button>
617
+ <Button
618
+ width="min"
619
+ variant="primary"
620
+ onPress={handleAdd}
621
+ disabled={
622
+ isLoading ||
623
+ !moderatorDID.trim() ||
624
+ Object.values(permissions).every((p) => !p)
625
+ }
626
+ >
627
+ {isLoading ? "Adding..." : "Add Moderator"}
628
+ </Button>
629
+ </DialogFooter>
630
+ </ResponsiveDialog>
631
+ );
632
+ }
@@ -224,7 +224,7 @@ export const MenuLabel = forwardRef<View, MenuLabelProps>(
224
224
  ref={ref as any}
225
225
  style={mergeStyles(
226
226
  px[4],
227
- py[2],
227
+ pt[2],
228
228
  { color: theme.colors.textMuted },
229
229
  a.fontSize.base,
230
230
  style,
@@ -274,7 +274,6 @@ export const MenuInfo = forwardRef<View, MenuInfoProps>(
274
274
  style={mergeStyles(
275
275
  { color: theme.colors.textMuted, marginTop: -8 },
276
276
  pt[1],
277
- pl[4],
278
277
  pb[2],
279
278
  fontSize.sm,
280
279
  style,
@@ -13,7 +13,14 @@ if (!rnqc && !expoCrypto) {
13
13
  throw new Error(
14
14
  "Livestreaming requires one of react-native-quick-crypto or expo-crypto",
15
15
  );
16
- } else if (!rnqc && expoCrypto) {
16
+ }
17
+ if (rnqc) {
18
+ console.log("Using react-native-quick-crypto for crypto polyfill");
19
+ // we import this in the main app, but in case this file is used standalone:
20
+ globalThis.crypto = rnqc as any as Crypto;
21
+ }
22
+ if (expoCrypto) {
23
+ console.log("Using expo-crypto for crypto polyfill");
17
24
  // @atproto/crypto dependencies expect crypto.getRandomValues to be a function
18
25
  if (typeof globalThis.crypto === "undefined") {
19
26
  globalThis.crypto = {} as any;
@@ -1,6 +1,7 @@
1
1
  // barrel file :)
2
2
  export * from "./useAvatars";
3
3
  export * from "./useCameraToggle";
4
+ export * from "./useDocumentTitle";
4
5
  export * from "./useKeyboard";
5
6
  export * from "./useKeyboardSlide";
6
7
  export * from "./useLivestreamInfo";
@@ -0,0 +1,45 @@
1
+ import { useEffect } from "react";
2
+ import { Platform } from "react-native";
3
+ import {
4
+ useFavicon,
5
+ useSiteDescription,
6
+ useSiteTitle,
7
+ } from "../streamplace-store";
8
+
9
+ /**
10
+ * Hook to set the document title, description, and favicon on web based on branding.
11
+ * No-op on native platforms.
12
+ */
13
+ export function useDocumentTitle() {
14
+ const siteTitle = useSiteTitle();
15
+ const siteDescription = useSiteDescription();
16
+ const favicon = useFavicon();
17
+
18
+ useEffect(() => {
19
+ if (Platform.OS === "web" && typeof document !== "undefined") {
20
+ // set title
21
+ document.title = siteTitle;
22
+
23
+ // set or update meta description
24
+ let metaDescription = document.querySelector('meta[name="description"]');
25
+ if (!metaDescription) {
26
+ metaDescription = document.createElement("meta");
27
+ metaDescription.setAttribute("name", "description");
28
+ document.head.appendChild(metaDescription);
29
+ }
30
+ metaDescription.setAttribute("content", siteDescription);
31
+
32
+ // set or update favicon
33
+ if (favicon) {
34
+ let link: HTMLLinkElement | null =
35
+ document.querySelector('link[rel="icon"]');
36
+ if (!link) {
37
+ link = document.createElement("link");
38
+ link.rel = "icon";
39
+ document.head.appendChild(link);
40
+ }
41
+ link.href = favicon;
42
+ }
43
+ }
44
+ }, [siteTitle, siteDescription, favicon]);
45
+ }