@streamplace/components 0.7.9 → 0.7.13

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 (48) hide show
  1. package/dist/assets/emoji-data.json +19371 -0
  2. package/dist/components/chat/chat-box.js +19 -2
  3. package/dist/components/chat/chat-message.js +12 -4
  4. package/dist/components/chat/chat.js +15 -4
  5. package/dist/components/chat/mod-view.js +15 -8
  6. package/dist/components/dashboard/chat-panel.js +38 -0
  7. package/dist/components/dashboard/header.js +80 -0
  8. package/dist/components/dashboard/index.js +14 -0
  9. package/dist/components/dashboard/information-widget.js +234 -0
  10. package/dist/components/dashboard/mod-actions.js +71 -0
  11. package/dist/components/dashboard/problems.js +74 -0
  12. package/dist/components/mobile-player/ui/viewer-context-menu.js +15 -6
  13. package/dist/components/ui/button.js +2 -2
  14. package/dist/components/ui/dropdown.js +20 -1
  15. package/dist/components/ui/index.js +2 -0
  16. package/dist/components/ui/info-box.js +31 -0
  17. package/dist/components/ui/info-row.js +23 -0
  18. package/dist/components/ui/toast.js +43 -0
  19. package/dist/index.js +3 -1
  20. package/dist/lib/theme/atoms.js +66 -45
  21. package/dist/lib/theme/tokens.js +285 -12
  22. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  23. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/56540125 +0 -0
  24. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/67b1eb60 +0 -0
  25. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/7c275f90 +0 -0
  26. package/package.json +2 -2
  27. package/src/assets/emoji-data.json +19371 -0
  28. package/src/components/chat/chat-box.tsx +19 -1
  29. package/src/components/chat/chat-message.tsx +22 -14
  30. package/src/components/chat/chat.tsx +21 -6
  31. package/src/components/chat/mod-view.tsx +24 -6
  32. package/src/components/dashboard/chat-panel.tsx +80 -0
  33. package/src/components/dashboard/header.tsx +170 -0
  34. package/src/components/dashboard/index.tsx +5 -0
  35. package/src/components/dashboard/information-widget.tsx +526 -0
  36. package/src/components/dashboard/mod-actions.tsx +133 -0
  37. package/src/components/dashboard/problems.tsx +151 -0
  38. package/src/components/mobile-player/ui/viewer-context-menu.tsx +67 -38
  39. package/src/components/ui/button.tsx +2 -2
  40. package/src/components/ui/dropdown.tsx +38 -3
  41. package/src/components/ui/index.ts +2 -0
  42. package/src/components/ui/info-box.tsx +60 -0
  43. package/src/components/ui/info-row.tsx +48 -0
  44. package/src/components/ui/toast.tsx +110 -0
  45. package/src/index.tsx +3 -0
  46. package/src/lib/theme/atoms.ts +97 -43
  47. package/src/lib/theme/tokens.ts +285 -12
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,526 @@
1
+ import {
2
+ Car,
3
+ ChevronDown,
4
+ ChevronUp,
5
+ Eye,
6
+ EyeClosed,
7
+ Monitor,
8
+ Signal,
9
+ Video,
10
+ Zap,
11
+ } from "lucide-react-native";
12
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
13
+ import { LayoutChangeEvent, Text, TouchableOpacity, View } from "react-native";
14
+ import Svg, { Path, Line as SvgLine, Text as SvgText } from "react-native-svg";
15
+ import {
16
+ useLivestreamStore,
17
+ useSegment,
18
+ useViewers,
19
+ } from "../../livestream-store";
20
+ import * as zero from "../../ui";
21
+ import { InfoBox, InfoRow } from "../ui";
22
+
23
+ interface InformationWidgetProps {
24
+ embedMode?: boolean;
25
+ wideMode?: boolean;
26
+ showChart?: boolean;
27
+ }
28
+
29
+ const BITRATE_HISTORY_LENGTH = 30;
30
+
31
+ const { bg, r, borders, px, py, text, layout, gap, flex, p } = zero;
32
+
33
+ export default function InformationWidget({
34
+ embedMode = false,
35
+ wideMode, // Optional override
36
+ showChart = true,
37
+ }: InformationWidgetProps) {
38
+ const [bitrateHistory, setBitrateHistory] = useState<number[]>(
39
+ Array.from({ length: BITRATE_HISTORY_LENGTH }, () => 0),
40
+ );
41
+ const [showViewers, setShowViewers] = useState(false);
42
+ const [componentWidth, setComponentWidth] = useState<number>(220);
43
+ const [componentHeight, setComponentHeight] = useState<number>(400);
44
+ const [streamStartTime, setStreamStartTime] = useState<Date | null>(null);
45
+ const [layoutMeasured, setLayoutMeasured] = useState(false);
46
+ const isWideMode =
47
+ wideMode !== undefined ? wideMode : layoutMeasured && componentWidth > 400;
48
+
49
+ const isCompactHeight = layoutMeasured && componentHeight < 350;
50
+
51
+ const seg = useSegment();
52
+ const livestream = useLivestreamStore((x) => x.livestream);
53
+ const viewers = useViewers();
54
+
55
+ const getBitrate = useCallback((): number => {
56
+ if (!seg?.size || !seg?.duration) return 0;
57
+ const kbps =
58
+ (seg.size * 8) / ((seg.duration || 1000000000) / 1000000000) / 1000;
59
+ return kbps;
60
+ }, [seg?.size, seg?.duration]);
61
+
62
+ const getMediaInfo = useMemo(() => {
63
+ const videoTrack = seg?.video?.[0];
64
+ const audioTrack = seg?.audio?.[0];
65
+ return {
66
+ resolution:
67
+ videoTrack?.width && videoTrack?.height
68
+ ? `${videoTrack.width}x${videoTrack.height}`
69
+ : "Unknown",
70
+ fps: videoTrack?.framerate
71
+ ? `${(videoTrack.framerate.num / videoTrack.framerate.den).toFixed(
72
+ 2,
73
+ )} FPS`
74
+ : "Unknown",
75
+ videoCodec: videoTrack?.codec
76
+ ? videoTrack.codec.toUpperCase()
77
+ : "Unknown",
78
+ };
79
+ }, [seg?.video, seg?.audio]);
80
+
81
+ const currentBitrate = getBitrate();
82
+
83
+ useEffect(() => {
84
+ setBitrateHistory((prev) => [...prev.slice(1), currentBitrate]);
85
+ }, [currentBitrate]);
86
+
87
+ useEffect(() => {
88
+ if (seg?.startTime && !streamStartTime) {
89
+ setStreamStartTime(new Date(seg.startTime));
90
+ }
91
+ }, [seg?.startTime, streamStartTime]);
92
+
93
+ const getBitrateStatus = (): "good" | "warning" | "error" | "neutral" => {
94
+ if (currentBitrate > 2000) return "good";
95
+ if (currentBitrate > 1000) return "warning";
96
+ if (currentBitrate > 0) return "error";
97
+ return "neutral";
98
+ };
99
+
100
+ const getConnectionStatus = (): "good" | "warning" | "error" | "neutral" => {
101
+ if (!seg) return "error";
102
+ if (currentBitrate > 1500) return "good";
103
+ if (currentBitrate > 500) return "warning";
104
+ return "error";
105
+ };
106
+
107
+ const avgBitrate =
108
+ bitrateHistory.length > 0
109
+ ? bitrateHistory.reduce((a, b) => a + b, 0) / bitrateHistory.length
110
+ : 0;
111
+ const peakBitrate = Math.max(...bitrateHistory, 0);
112
+ const uptimeMinutes = streamStartTime
113
+ ? Math.floor((Date.now() - streamStartTime.getTime()) / 60000)
114
+ : 0;
115
+ const estimatedLatency = seg?.duration
116
+ ? Math.floor((seg.duration / 1000000000) * 2)
117
+ : 0;
118
+
119
+ const displayBitrate = `${(currentBitrate / 1000).toFixed(2)} Mbps`;
120
+ const displayResolution = getMediaInfo.resolution;
121
+ const displayFps = getMediaInfo.fps;
122
+ const streamTitle = livestream?.record?.title || "Untitled Stream";
123
+ const viewerCount = viewers ?? 0;
124
+
125
+ const handleLayout = useCallback((event: LayoutChangeEvent) => {
126
+ const { width, height } = event.nativeEvent.layout;
127
+ console.log("InformationWidget onLayout - size:", `${width}x${height}`);
128
+ if (width > 0 && height > 0) {
129
+ setComponentWidth(width);
130
+ setComponentHeight(height);
131
+ setLayoutMeasured(true);
132
+ }
133
+ }, []);
134
+
135
+ return (
136
+ <View
137
+ onLayout={handleLayout}
138
+ style={[
139
+ embedMode
140
+ ? { backgroundColor: "rgba(23, 23, 23, 0.9)" }
141
+ : bg.neutral[900],
142
+ embedMode ? undefined : borders.width.thin,
143
+ embedMode ? undefined : borders.color.neutral[700],
144
+ r.lg,
145
+ px[4],
146
+ py[4],
147
+ gap.all[6],
148
+ flex.values[1],
149
+ {
150
+ minWidth: isWideMode ? 500 : 220,
151
+ width: "100%",
152
+ },
153
+ ]}
154
+ >
155
+ <View
156
+ style={[
157
+ layout.flex.row,
158
+ layout.flex.spaceBetween,
159
+ layout.flex.alignCenter,
160
+ ]}
161
+ >
162
+ <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[1]]}>
163
+ <Text
164
+ style={[text.white, { fontSize: 18, fontWeight: "700" }]}
165
+ numberOfLines={1}
166
+ >
167
+ Stream Health
168
+ </Text>
169
+ <View
170
+ style={[
171
+ {
172
+ width: 8,
173
+ height: 8,
174
+ borderRadius: 4,
175
+ backgroundColor:
176
+ getConnectionStatus() === "good"
177
+ ? "#22c55e"
178
+ : getConnectionStatus() === "warning"
179
+ ? "#f59e0b"
180
+ : "#ef4444",
181
+ },
182
+ ]}
183
+ />
184
+ </View>
185
+ <TouchableOpacity
186
+ onPress={() => setShowViewers(!showViewers)}
187
+ style={[
188
+ layout.flex.column,
189
+ layout.flex.alignCenter,
190
+ gap.all[1],
191
+ { minWidth: 120 },
192
+ ]}
193
+ >
194
+ <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}>
195
+ {showViewers ? (
196
+ <Eye size={14} color="#9ca3af" />
197
+ ) : (
198
+ <EyeClosed size={14} color="#9ca3af" />
199
+ )}
200
+ <Text
201
+ style={[
202
+ text.white,
203
+ { fontSize: 16, fontWeight: "600", textAlign: "center" },
204
+ ]}
205
+ >
206
+ {showViewers ? `${viewerCount}` : "..."} viewer
207
+ {showViewers && viewerCount !== 1 ? "s" : ""}
208
+ </Text>
209
+ </View>
210
+ </TouchableOpacity>
211
+ </View>
212
+
213
+ {isWideMode ? (
214
+ <View style={[gap.all[3]]}>
215
+ <View style={[layout.flex.row, gap.all[2]]}>
216
+ <InfoBox
217
+ icon={Car}
218
+ label="Bitrate"
219
+ value={displayBitrate}
220
+ status={getBitrateStatus()}
221
+ />
222
+ <InfoBox
223
+ icon={Monitor}
224
+ label="Resolution"
225
+ value={displayResolution}
226
+ />
227
+ <InfoBox icon={Video} label="FPS" value={displayFps} />
228
+ </View>
229
+
230
+ {showChart && (
231
+ <View style={[gap.all[2]]}>
232
+ {!isCompactHeight && (
233
+ <View
234
+ style={[
235
+ layout.flex.row,
236
+ layout.flex.spaceBetween,
237
+ layout.flex.alignCenter,
238
+ ]}
239
+ >
240
+ <Text
241
+ style={[
242
+ text.gray[200],
243
+ { fontSize: 14, fontWeight: "600" },
244
+ ]}
245
+ >
246
+ Live Performance
247
+ </Text>
248
+ <View style={[layout.flex.row, gap.all[4]]}>
249
+ <View style={[layout.flex.alignCenter]}>
250
+ <Text
251
+ style={[
252
+ text.gray[400],
253
+ { fontSize: 11, fontWeight: "500" },
254
+ ]}
255
+ >
256
+ AVG
257
+ </Text>
258
+ <Text
259
+ style={[
260
+ text.white,
261
+ { fontSize: 13, fontWeight: "600" },
262
+ ]}
263
+ >
264
+ {avgBitrate > 0
265
+ ? `${(avgBitrate / 1000).toFixed(1)}M`
266
+ : "0M"}
267
+ </Text>
268
+ </View>
269
+ <View style={[layout.flex.alignCenter]}>
270
+ <Text
271
+ style={[
272
+ text.gray[400],
273
+ { fontSize: 11, fontWeight: "500" },
274
+ ]}
275
+ >
276
+ PEAK
277
+ </Text>
278
+ <Text
279
+ style={[
280
+ text.white,
281
+ { fontSize: 13, fontWeight: "600" },
282
+ ]}
283
+ >
284
+ {peakBitrate > 0
285
+ ? `${(peakBitrate / 1000).toFixed(1)}M`
286
+ : "0M"}
287
+ </Text>
288
+ </View>
289
+ <View style={[layout.flex.alignCenter]}>
290
+ <Text
291
+ style={[
292
+ text.gray[400],
293
+ { fontSize: 11, fontWeight: "500" },
294
+ ]}
295
+ >
296
+ CAPTURED
297
+ </Text>
298
+ <Text
299
+ style={[
300
+ text.white,
301
+ { fontSize: 13, fontWeight: "600" },
302
+ ]}
303
+ >
304
+ {uptimeMinutes > 60
305
+ ? `${Math.floor(uptimeMinutes / 60)}h ${uptimeMinutes % 60}m`
306
+ : `${uptimeMinutes}m`}
307
+ </Text>
308
+ </View>
309
+ </View>
310
+ </View>
311
+ )}
312
+
313
+ <BitrateChart
314
+ data={bitrateHistory}
315
+ width={componentWidth - 40}
316
+ height={120}
317
+ />
318
+ </View>
319
+ )}
320
+ </View>
321
+ ) : (
322
+ <View style={[gap.all[3]]}>
323
+ {!isCompactHeight && (
324
+ <TouchableOpacity
325
+ onPress={() => setShowViewers(!showViewers)}
326
+ style={[
327
+ layout.flex.row,
328
+ layout.flex.spaceBetween,
329
+ layout.flex.alignCenter,
330
+ py[2],
331
+ ]}
332
+ >
333
+ <View
334
+ style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}
335
+ >
336
+ <Eye size={16} color="#9ca3af" />
337
+ <Text
338
+ style={[text.gray[300], { fontSize: 13, fontWeight: "500" }]}
339
+ >
340
+ Viewers
341
+ </Text>
342
+ </View>
343
+ <View
344
+ style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}
345
+ >
346
+ <Text
347
+ style={[
348
+ showViewers ? text.green[400] : text.white,
349
+ { fontSize: 13, fontWeight: "600" },
350
+ ]}
351
+ >
352
+ {showViewers ? `${viewerCount} watching` : "•••"}
353
+ </Text>
354
+ {showViewers ? (
355
+ <ChevronUp size={14} color="#9ca3af" />
356
+ ) : (
357
+ <ChevronDown size={14} color="#9ca3af" />
358
+ )}
359
+ </View>
360
+ </TouchableOpacity>
361
+ )}
362
+
363
+ {showChart && (
364
+ <View style={[gap.all[2]]}>
365
+ {!isCompactHeight && (
366
+ <View
367
+ style={[
368
+ layout.flex.row,
369
+ layout.flex.spaceBetween,
370
+ layout.flex.alignCenter,
371
+ ]}
372
+ >
373
+ <Text
374
+ style={[
375
+ text.gray[200],
376
+ { fontSize: 14, fontWeight: "600" },
377
+ ]}
378
+ >
379
+ Performance
380
+ </Text>
381
+ <Text
382
+ style={[
383
+ text.gray[400],
384
+ { fontSize: 11, fontWeight: "500" },
385
+ ]}
386
+ >
387
+ {avgBitrate > 0
388
+ ? `AVG ${(avgBitrate / 1000).toFixed(1)}M`
389
+ : "No data"}
390
+ </Text>
391
+ </View>
392
+ )}
393
+ <BitrateChart
394
+ data={bitrateHistory}
395
+ width={componentWidth - 40}
396
+ height={isCompactHeight ? 80 : 120}
397
+ />
398
+ </View>
399
+ )}
400
+
401
+ <View style={[gap.all[1]]}>
402
+ <InfoRow
403
+ icon={Signal}
404
+ label="Connection"
405
+ value={
406
+ getConnectionStatus() === "good"
407
+ ? "Excellent"
408
+ : getConnectionStatus() === "warning"
409
+ ? "Good"
410
+ : "Poor"
411
+ }
412
+ status={getConnectionStatus()}
413
+ />
414
+ <View style={[gap.all[1], layout.flex.row]}>
415
+ <InfoBox
416
+ icon={Zap}
417
+ label="Bitrate"
418
+ value={displayBitrate}
419
+ status={getBitrateStatus()}
420
+ />
421
+ <InfoBox icon={Video} label="FPS" value={displayFps} />
422
+ </View>
423
+ </View>
424
+ </View>
425
+ )}
426
+ </View>
427
+ );
428
+ }
429
+
430
+ function BitrateChart({
431
+ data,
432
+ width,
433
+ height,
434
+ }: {
435
+ data: number[];
436
+ width: number;
437
+ height: number;
438
+ }) {
439
+ const maxDataValue = Math.max(...data, 1);
440
+ const minDataValue = Math.min(...data);
441
+ const getSmartRange = (max: number) => {
442
+ if (max <= 1000) return { min: 0, max: 1000, step: 500 };
443
+ if (max <= 2000) return { min: 1000, max: 2000, step: 1000 };
444
+ if (max <= 7000) return { min: 4000, max: 7000, step: 1500 };
445
+ if (max <= 10000) return { min: 4000, max: 10000, step: 5000 };
446
+
447
+ const roundedMax = Math.ceil(max / 5000) * 5000;
448
+ return { min: 0, max: roundedMax, step: roundedMax / 2 };
449
+ };
450
+
451
+ const { min: minValue, max: maxValue, step } = getSmartRange(maxDataValue);
452
+ const range = maxValue - minValue;
453
+
454
+ const chartWidth = width - 40;
455
+ const chartStartX = 40;
456
+ const verticalPadding = 10;
457
+ const chartHeight = height - verticalPadding * 2;
458
+
459
+ const points = data
460
+ .map((value, index) => {
461
+ const x = chartStartX + (index / (data.length - 1)) * chartWidth;
462
+ // Clamp value to chart range and plot against the smart scale
463
+ const clampedValue = Math.max(minValue, Math.min(maxValue, value));
464
+ const y =
465
+ verticalPadding +
466
+ chartHeight -
467
+ ((clampedValue - minValue) / range) * chartHeight;
468
+ return `${x},${y}`;
469
+ })
470
+ .join(" ");
471
+
472
+ const pathData = `M ${points.replace(/ /g, " L ")}`;
473
+ const ticks = [
474
+ { value: minValue, y: height - verticalPadding },
475
+ { value: minValue + step, y: height / 2 },
476
+ { value: maxValue, y: verticalPadding },
477
+ ];
478
+
479
+ return (
480
+ <View style={{ height, width, marginVertical: 8 }}>
481
+ <Svg width={width} height={height}>
482
+ {ticks.map((tick, index) => (
483
+ <React.Fragment key={index}>
484
+ <SvgLine
485
+ x1={chartStartX}
486
+ y1={tick.y}
487
+ x2={width}
488
+ y2={tick.y}
489
+ stroke="rgba(255,255,255,0.1)"
490
+ strokeWidth="1"
491
+ />
492
+ <SvgText
493
+ x="35"
494
+ y={tick.y + 4}
495
+ fontSize={10}
496
+ fontFamily="sans-serif"
497
+ fill="#9ca3af"
498
+ textAnchor="end"
499
+ >
500
+ {(tick.value / 1000).toLocaleString()}
501
+ </SvgText>
502
+ </React.Fragment>
503
+ ))}
504
+ <SvgText
505
+ x={12}
506
+ y={height / 2}
507
+ transform={`rotate(-90, 12, ${height / 2})`}
508
+ fontSize={10}
509
+ fontFamily="sans-serif"
510
+ fill="#9ca3af"
511
+ textAnchor="middle"
512
+ >
513
+ mbits/s
514
+ </SvgText>
515
+ <Path
516
+ d={pathData}
517
+ stroke="#22c55e"
518
+ strokeWidth="2"
519
+ fill="none"
520
+ strokeLinecap="round"
521
+ strokeLinejoin="round"
522
+ />
523
+ </Svg>
524
+ </View>
525
+ );
526
+ }
@@ -0,0 +1,133 @@
1
+ import { AlertTriangle, Eye, MessageCircle, Shield } from "lucide-react-native";
2
+ import { Pressable, Text, View } from "react-native";
3
+ import * as zero from "../../ui";
4
+
5
+ const { flex, bg, r, borders, p, text, layout, gap, mb } = zero;
6
+
7
+ interface ModActionItem {
8
+ icon: any;
9
+ label: string;
10
+ color: string;
11
+ action: () => void;
12
+ }
13
+
14
+ interface ModActionsProps {
15
+ isLive: boolean;
16
+ isConnected: boolean;
17
+ messageCount?: number;
18
+ actions?: ModActionItem[];
19
+ }
20
+
21
+ const defaultActions: ModActionItem[] = [
22
+ {
23
+ icon: Shield,
24
+ label: "Ban User",
25
+ color: "red",
26
+ action: () => console.log("Ban user action"),
27
+ },
28
+ {
29
+ icon: MessageCircle,
30
+ label: "Timeout",
31
+ color: "yellow",
32
+ action: () => console.log("Timeout user action"),
33
+ },
34
+ {
35
+ icon: Eye,
36
+ label: "Monitor",
37
+ color: "blue",
38
+ action: () => console.log("Monitor stream action"),
39
+ },
40
+ {
41
+ icon: AlertTriangle,
42
+ label: "Report",
43
+ color: "orange",
44
+ action: () => console.log("Report content action"),
45
+ },
46
+ ];
47
+
48
+ export default function ModActions({
49
+ isLive,
50
+ isConnected,
51
+ messageCount = 0,
52
+ actions = defaultActions,
53
+ }: ModActionsProps) {
54
+ const canModerate = isLive && isConnected;
55
+
56
+ return (
57
+ <View
58
+ style={[
59
+ flex.values[1],
60
+ bg.gray[800],
61
+ r[3],
62
+ borders.width.thin,
63
+ borders.color.gray[700],
64
+ p[4],
65
+ ]}
66
+ >
67
+ <View
68
+ style={[
69
+ layout.flex.row,
70
+ layout.flex.spaceBetween,
71
+ layout.flex.alignCenter,
72
+ mb[4],
73
+ ]}
74
+ >
75
+ <Text style={[text.white, { fontSize: 18, fontWeight: "600" }]}>
76
+ Moderation
77
+ </Text>
78
+ <Text style={[text.gray[400], { fontSize: 12 }]}>
79
+ {messageCount} messages
80
+ </Text>
81
+ </View>
82
+
83
+ <View style={[layout.flex.row, gap.all[3]]}>
84
+ {actions.map((action, index) => (
85
+ <Pressable
86
+ key={index}
87
+ style={[
88
+ flex.grow[1],
89
+ bg.gray[700],
90
+ r[2],
91
+ p[3],
92
+ layout.flex.row,
93
+ layout.flex.alignCenter,
94
+ gap.all[2],
95
+ borders.width.thin,
96
+ borders.color.gray[600],
97
+ ]}
98
+ disabled={!canModerate}
99
+ onPress={action.action}
100
+ >
101
+ <action.icon
102
+ size={20}
103
+ color={canModerate ? "#ffffff" : "#6b7280"}
104
+ />
105
+ <Text
106
+ style={[
107
+ canModerate ? text.white : text.gray[400],
108
+ { fontSize: 14, fontWeight: "500" },
109
+ ]}
110
+ >
111
+ {action.label}
112
+ </Text>
113
+ </Pressable>
114
+ ))}
115
+ </View>
116
+
117
+ {!canModerate && (
118
+ <Text
119
+ style={[
120
+ text.gray[500],
121
+ { fontSize: 12, textAlign: "center", marginTop: 16 },
122
+ ]}
123
+ >
124
+ {!isLive
125
+ ? "Moderation tools available when live"
126
+ : !isConnected
127
+ ? "Waiting for stream connection..."
128
+ : "Moderation tools unavailable"}
129
+ </Text>
130
+ )}
131
+ </View>
132
+ );
133
+ }