@streamplace/components 0.9.9 → 0.9.10

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 (166) hide show
  1. package/assets/badges/live.png +0 -0
  2. package/assets/badges/live_2x.png +0 -0
  3. package/assets/badges/mod.png +0 -0
  4. package/assets/badges/mod_2x.png +0 -0
  5. package/assets/badges/vip.png +0 -0
  6. package/assets/badges/vip_2x.png +0 -0
  7. package/dist/components/chat/badge.d.ts +10 -0
  8. package/dist/components/chat/badge.d.ts.map +1 -0
  9. package/dist/components/chat/badge.js +29 -0
  10. package/dist/components/chat/badge.js.map +1 -0
  11. package/dist/components/chat/chat-box.d.ts +5 -1
  12. package/dist/components/chat/chat-box.d.ts.map +1 -1
  13. package/dist/components/chat/chat-box.js +55 -50
  14. package/dist/components/chat/chat-box.js.map +1 -1
  15. package/dist/components/chat/chat-message.d.ts.map +1 -1
  16. package/dist/components/chat/chat-message.js +9 -11
  17. package/dist/components/chat/chat-message.js.map +1 -1
  18. package/dist/components/chat/chat.d.ts.map +1 -1
  19. package/dist/components/chat/chat.js +35 -41
  20. package/dist/components/chat/chat.js.map +1 -1
  21. package/dist/components/chat/emoji-suggestions.d.ts +7 -18
  22. package/dist/components/chat/emoji-suggestions.d.ts.map +1 -1
  23. package/dist/components/chat/emoji-suggestions.js +6 -2
  24. package/dist/components/chat/emoji-suggestions.js.map +1 -1
  25. package/dist/components/chat/system-message.d.ts.map +1 -1
  26. package/dist/components/chat/system-message.js +9 -1
  27. package/dist/components/chat/system-message.js.map +1 -1
  28. package/dist/components/chat/teleport-modal.d.ts +9 -0
  29. package/dist/components/chat/teleport-modal.d.ts.map +1 -0
  30. package/dist/components/chat/teleport-modal.js +148 -0
  31. package/dist/components/chat/teleport-modal.js.map +1 -0
  32. package/dist/components/chat/user-profile-card.d.ts +12 -0
  33. package/dist/components/chat/user-profile-card.d.ts.map +1 -0
  34. package/dist/components/chat/user-profile-card.js +135 -0
  35. package/dist/components/chat/user-profile-card.js.map +1 -0
  36. package/dist/components/dashboard/chat-panel.d.ts +3 -1
  37. package/dist/components/dashboard/chat-panel.d.ts.map +1 -1
  38. package/dist/components/dashboard/chat-panel.js +2 -2
  39. package/dist/components/dashboard/chat-panel.js.map +1 -1
  40. package/dist/components/dashboard/header.d.ts +1 -1
  41. package/dist/components/dashboard/header.d.ts.map +1 -1
  42. package/dist/components/dashboard/header.js +4 -0
  43. package/dist/components/dashboard/header.js.map +1 -1
  44. package/dist/components/dashboard/information-widget.d.ts.map +1 -1
  45. package/dist/components/dashboard/information-widget.js +13 -11
  46. package/dist/components/dashboard/information-widget.js.map +1 -1
  47. package/dist/components/mobile-player/fullscreen.d.ts.map +1 -1
  48. package/dist/components/mobile-player/fullscreen.js +2 -1
  49. package/dist/components/mobile-player/fullscreen.js.map +1 -1
  50. package/dist/components/mobile-player/fullscreen.native.d.ts.map +1 -1
  51. package/dist/components/mobile-player/fullscreen.native.js +3 -2
  52. package/dist/components/mobile-player/fullscreen.native.js.map +1 -1
  53. package/dist/components/mobile-player/ui/audio-only-overlay.d.ts +2 -0
  54. package/dist/components/mobile-player/ui/audio-only-overlay.d.ts.map +1 -0
  55. package/dist/components/mobile-player/ui/audio-only-overlay.js +29 -0
  56. package/dist/components/mobile-player/ui/audio-only-overlay.js.map +1 -0
  57. package/dist/components/mobile-player/ui/index.d.ts +1 -0
  58. package/dist/components/mobile-player/ui/index.d.ts.map +1 -1
  59. package/dist/components/mobile-player/ui/index.js +1 -0
  60. package/dist/components/mobile-player/ui/index.js.map +1 -1
  61. package/dist/components/mobile-player/ui/input.d.ts +1 -2
  62. package/dist/components/mobile-player/ui/input.d.ts.map +1 -1
  63. package/dist/components/mobile-player/ui/input.js +2 -2
  64. package/dist/components/mobile-player/ui/input.js.map +1 -1
  65. package/dist/components/mobile-player/ui/metrics.d.ts.map +1 -1
  66. package/dist/components/mobile-player/ui/metrics.js +20 -2
  67. package/dist/components/mobile-player/ui/metrics.js.map +1 -1
  68. package/dist/components/mobile-player/ui/viewer-context-menu.d.ts.map +1 -1
  69. package/dist/components/mobile-player/ui/viewer-context-menu.js +29 -1
  70. package/dist/components/mobile-player/ui/viewer-context-menu.js.map +1 -1
  71. package/dist/components/mobile-player/use-webrtc.d.ts +4 -2
  72. package/dist/components/mobile-player/use-webrtc.d.ts.map +1 -1
  73. package/dist/components/mobile-player/use-webrtc.js +89 -15
  74. package/dist/components/mobile-player/use-webrtc.js.map +1 -1
  75. package/dist/components/mobile-player/video-async.native.d.ts.map +1 -1
  76. package/dist/components/mobile-player/video-async.native.js +15 -5
  77. package/dist/components/mobile-player/video-async.native.js.map +1 -1
  78. package/dist/components/mobile-player/video.d.ts.map +1 -1
  79. package/dist/components/mobile-player/video.js +10 -7
  80. package/dist/components/mobile-player/video.js.map +1 -1
  81. package/dist/components/ui/dialog.d.ts.map +1 -1
  82. package/dist/components/ui/dialog.js +8 -0
  83. package/dist/components/ui/dialog.js.map +1 -1
  84. package/dist/hooks/useLivestreamInfo.d.ts +0 -2
  85. package/dist/hooks/useLivestreamInfo.d.ts.map +1 -1
  86. package/dist/hooks/useLivestreamInfo.js +13 -24
  87. package/dist/hooks/useLivestreamInfo.js.map +1 -1
  88. package/dist/hooks/useSegmentTiming.d.ts +1 -1
  89. package/dist/hooks/useSegmentTiming.d.ts.map +1 -1
  90. package/dist/hooks/useSegmentTiming.js +4 -0
  91. package/dist/hooks/useSegmentTiming.js.map +1 -1
  92. package/dist/i18n/i18n-loader.native.d.ts.map +1 -1
  93. package/dist/i18n/i18n-loader.native.js +13 -4
  94. package/dist/i18n/i18n-loader.native.js.map +1 -1
  95. package/dist/lib/slash-commands/teleport.d.ts +5 -1
  96. package/dist/lib/slash-commands/teleport.d.ts.map +1 -1
  97. package/dist/lib/slash-commands/teleport.js +57 -1
  98. package/dist/lib/slash-commands/teleport.js.map +1 -1
  99. package/dist/livestream-store/chat.d.ts +1 -0
  100. package/dist/livestream-store/chat.d.ts.map +1 -1
  101. package/dist/livestream-store/chat.js +10 -1
  102. package/dist/livestream-store/chat.js.map +1 -1
  103. package/dist/livestream-store/livestream-state.d.ts +2 -0
  104. package/dist/livestream-store/livestream-state.d.ts.map +1 -1
  105. package/dist/livestream-store/livestream-store.d.ts +1 -1
  106. package/dist/livestream-store/livestream-store.d.ts.map +1 -1
  107. package/dist/livestream-store/livestream-store.js +10 -1
  108. package/dist/livestream-store/livestream-store.js.map +1 -1
  109. package/dist/livestream-store/websocket-consumer.d.ts.map +1 -1
  110. package/dist/livestream-store/websocket-consumer.js +1 -0
  111. package/dist/livestream-store/websocket-consumer.js.map +1 -1
  112. package/dist/player-store/player-state.d.ts +1 -5
  113. package/dist/player-store/player-state.d.ts.map +1 -1
  114. package/dist/player-store/player-store.d.ts.map +1 -1
  115. package/dist/player-store/player-store.js +16 -5
  116. package/dist/player-store/player-store.js.map +1 -1
  117. package/dist/player-store/single-player-provider.d.ts +0 -2
  118. package/dist/player-store/single-player-provider.d.ts.map +1 -1
  119. package/dist/player-store/single-player-provider.js +0 -2
  120. package/dist/player-store/single-player-provider.js.map +1 -1
  121. package/dist/streamplace-store/stream.d.ts +4 -2
  122. package/dist/streamplace-store/stream.d.ts.map +1 -1
  123. package/dist/streamplace-store/stream.js +36 -74
  124. package/dist/streamplace-store/stream.js.map +1 -1
  125. package/locales/manifest.json +21 -1
  126. package/locales/ro-RO/common.ftl +74 -0
  127. package/locales/ro-RO/settings.ftl +233 -0
  128. package/locales/zh-Hans/common.ftl +57 -0
  129. package/locales/zh-Hans/settings.ftl +222 -0
  130. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  131. package/package.json +2 -2
  132. package/src/components/chat/badge.tsx +45 -0
  133. package/src/components/chat/chat-box.tsx +84 -54
  134. package/src/components/chat/chat-message.tsx +25 -21
  135. package/src/components/chat/chat.tsx +105 -88
  136. package/src/components/chat/emoji-suggestions.tsx +12 -21
  137. package/src/components/chat/system-message.tsx +12 -2
  138. package/src/components/chat/teleport-modal.tsx +310 -0
  139. package/src/components/chat/user-profile-card.tsx +275 -0
  140. package/src/components/dashboard/chat-panel.tsx +8 -0
  141. package/src/components/dashboard/header.tsx +7 -3
  142. package/src/components/dashboard/information-widget.tsx +15 -9
  143. package/src/components/mobile-player/fullscreen.native.tsx +3 -0
  144. package/src/components/mobile-player/fullscreen.tsx +2 -0
  145. package/src/components/mobile-player/ui/audio-only-overlay.tsx +48 -0
  146. package/src/components/mobile-player/ui/index.ts +1 -0
  147. package/src/components/mobile-player/ui/input.tsx +1 -5
  148. package/src/components/mobile-player/ui/metrics.tsx +17 -2
  149. package/src/components/mobile-player/ui/viewer-context-menu.tsx +40 -3
  150. package/src/components/mobile-player/use-webrtc.tsx +118 -17
  151. package/src/components/mobile-player/video-async.native.tsx +18 -5
  152. package/src/components/mobile-player/video.tsx +10 -7
  153. package/src/components/ui/dialog.tsx +8 -0
  154. package/src/hooks/useLivestreamInfo.ts +15 -24
  155. package/src/hooks/useSegmentTiming.tsx +7 -2
  156. package/src/i18n/i18n-loader.native.ts +9 -0
  157. package/src/lib/slash-commands/teleport.ts +68 -0
  158. package/src/livestream-store/chat.tsx +12 -0
  159. package/src/livestream-store/livestream-state.tsx +2 -0
  160. package/src/livestream-store/livestream-store.tsx +9 -1
  161. package/src/livestream-store/websocket-consumer.tsx +1 -0
  162. package/src/player-store/player-state.tsx +1 -7
  163. package/src/player-store/player-store.tsx +16 -7
  164. package/src/player-store/single-player-provider.tsx +0 -4
  165. package/src/streamplace-store/stream.tsx +42 -99
  166. package/node-compile-cache/v22.15.0-x64-92db9086-0/37be0eec +0 -0
@@ -0,0 +1,310 @@
1
+ import { Check, X } from "lucide-react-native";
2
+ import React, { useEffect, useMemo, useState } from "react";
3
+ import { Image, Pressable, ScrollView, View } from "react-native";
4
+ import { PlaceStreamLivestream } from "streamplace";
5
+ import { useAvatars, zero } from "../..";
6
+ import { useStreamplaceStore } from "../../streamplace-store";
7
+ import { Button, Input, ResponsiveDialog, Text, useTheme } from "../ui";
8
+
9
+ interface TeleportModalProps {
10
+ open: boolean;
11
+ onOpenChange: (open: boolean) => void;
12
+ onSubmit: (targetHandle: string, countdownSeconds: number) => void;
13
+ }
14
+
15
+ export const TeleportModal: React.FC<TeleportModalProps> = ({
16
+ open,
17
+ onOpenChange,
18
+ onSubmit,
19
+ }) => {
20
+ const [searchQuery, setSearchQuery] = useState("");
21
+ const [selectedStream, setSelectedStream] =
22
+ useState<PlaceStreamLivestream.LivestreamView | null>(null);
23
+ const [countdownSeconds, setCountdownSeconds] = useState("10");
24
+
25
+ const { theme } = useTheme();
26
+
27
+ const liveUsersCache = useStreamplaceStore((state) => state.liveUsers);
28
+ const liveUsersLoading = useStreamplaceStore(
29
+ (state) => state.liveUsersLoading,
30
+ );
31
+
32
+ const [liveUsers, setLiveUsers] = useState(liveUsersCache);
33
+
34
+ useEffect(() => {
35
+ setLiveUsers(liveUsersCache);
36
+ }, [liveUsersCache]);
37
+
38
+ const profiles = useAvatars(liveUsers?.map((u) => u.author?.did || "") || []);
39
+
40
+ const filteredStreams = useMemo(() => {
41
+ if (!liveUsers) return [];
42
+ if (!searchQuery.trim()) return liveUsers;
43
+
44
+ const query = searchQuery.toLowerCase();
45
+ // filter by handle or stream title
46
+ return liveUsers.filter(
47
+ (stream) =>
48
+ stream.author?.handle?.toLowerCase().includes(query) ||
49
+ stream.record.title?.toString().toLowerCase().includes(query),
50
+ );
51
+ }, [liveUsers, searchQuery]);
52
+
53
+ const handleCancel = () => {
54
+ setSearchQuery("");
55
+ setSelectedStream(null);
56
+ setCountdownSeconds("10");
57
+ onOpenChange(false);
58
+ };
59
+
60
+ const handleSubmit = () => {
61
+ if (!selectedStream?.author?.handle) return;
62
+
63
+ const countdown = parseInt(countdownSeconds, 10);
64
+ if (isNaN(countdown) || countdown < 5 || countdown > 300) {
65
+ return;
66
+ }
67
+
68
+ onSubmit(selectedStream.author.handle, countdown);
69
+ handleCancel();
70
+ };
71
+
72
+ return (
73
+ <ResponsiveDialog
74
+ open={open}
75
+ onOpenChange={onOpenChange}
76
+ showCloseButton={false}
77
+ variant="default"
78
+ size="xl"
79
+ dismissible={false}
80
+ >
81
+ <View style={[zero.py[2]]}>
82
+ <View style={[zero.layout.flex.row, zero.layout.flex.justify.between]}>
83
+ <View style={[zero.mb[4], zero.gap.all[1], zero.layout.flex.column]}>
84
+ <Text size="2xl">Teleport to another live streamer</Text>
85
+ <Text color="muted">
86
+ Select a streamer to teleport your viewers to their stream.
87
+ </Text>
88
+ </View>
89
+ <Pressable onPress={handleCancel} style={[{ padding: 8 }]}>
90
+ <X color={theme.colors.mutedForeground} />
91
+ </Pressable>
92
+ </View>
93
+ <View style={[zero.mb[4]]}>
94
+ <Input
95
+ value={searchQuery}
96
+ onChangeText={setSearchQuery}
97
+ placeholder="Search by handle..."
98
+ autoCapitalize="none"
99
+ autoCorrect={false}
100
+ />
101
+ </View>
102
+
103
+ {liveUsersLoading && !liveUsers ? (
104
+ <View style={[zero.py[8], { alignItems: "center" }]}>
105
+ <Text color="muted">Loading live users...</Text>
106
+ </View>
107
+ ) : filteredStreams.length === 0 ? (
108
+ <View style={[zero.py[8], { alignItems: "center" }]}>
109
+ <Text color="muted">
110
+ {searchQuery
111
+ ? "No matching live users found"
112
+ : "No live users found"}
113
+ </Text>
114
+ </View>
115
+ ) : (
116
+ <ScrollView style={[{ maxHeight: 400 }]}>
117
+ <View
118
+ style={[
119
+ {
120
+ flexDirection: "row",
121
+ flexWrap: "wrap",
122
+ gap: 12,
123
+ },
124
+ ]}
125
+ >
126
+ {filteredStreams.map((stream) => {
127
+ const isSelected = selectedStream?.uri === stream.uri;
128
+ const profile = profiles[stream.author?.did];
129
+
130
+ return (
131
+ <Pressable
132
+ key={stream.uri}
133
+ onPress={() => setSelectedStream(stream)}
134
+ style={[
135
+ {
136
+ width: "49.2%",
137
+ minWidth: 200,
138
+ },
139
+ ]}
140
+ >
141
+ <View
142
+ style={[
143
+ {
144
+ backgroundColor: theme.colors.muted,
145
+ borderRadius: 12,
146
+ overflow: "hidden",
147
+ borderWidth: 2,
148
+ borderColor: isSelected
149
+ ? theme.colors.primary
150
+ : "transparent",
151
+ },
152
+ ]}
153
+ >
154
+ <View
155
+ style={[
156
+ {
157
+ width: "100%",
158
+ aspectRatio: 16 / 9,
159
+ backgroundColor: theme.colors.card,
160
+ position: "relative",
161
+ },
162
+ ]}
163
+ >
164
+ <Image
165
+ source={{
166
+ uri:
167
+ "/api/playback/" +
168
+ stream.author.did +
169
+ "/stream.jpg",
170
+ }}
171
+ style={{
172
+ width: "100%",
173
+ height: "100%",
174
+ }}
175
+ resizeMode="cover"
176
+ />
177
+ {isSelected && (
178
+ <View
179
+ style={[
180
+ {
181
+ position: "absolute",
182
+ top: 8,
183
+ right: 8,
184
+ backgroundColor: theme.colors.primary,
185
+ borderRadius: 999,
186
+ width: 24,
187
+ height: 24,
188
+ alignItems: "center",
189
+ justifyContent: "center",
190
+ boxShadow: "0 2px 4px rgba(0, 0, 0, 0.6)",
191
+ },
192
+ ]}
193
+ >
194
+ <Check size={16} color="white" />
195
+ </View>
196
+ )}
197
+ {stream.viewerCount && (
198
+ <View
199
+ style={[
200
+ {
201
+ position: "absolute",
202
+ top: 8,
203
+ left: 8,
204
+ backgroundColor: "rgba(0, 0, 0, 0.75)",
205
+ borderRadius: 999,
206
+ paddingHorizontal: 8,
207
+ paddingVertical: 4,
208
+ },
209
+ ]}
210
+ >
211
+ <Text style={[{ fontSize: 12, color: "white" }]}>
212
+ {stream.viewerCount.count} viewer
213
+ {stream.viewerCount.count !== 1 ? "s" : ""}
214
+ </Text>
215
+ </View>
216
+ )}
217
+ </View>
218
+ <View
219
+ style={[
220
+ {
221
+ padding: 12,
222
+ flexDirection: "row",
223
+ gap: 8,
224
+ alignItems: "center",
225
+ },
226
+ ]}
227
+ >
228
+ <View
229
+ style={[
230
+ {
231
+ width: 40,
232
+ height: 40,
233
+ borderRadius: 20,
234
+ overflow: "hidden",
235
+ backgroundColor: theme.colors.card,
236
+ flexShrink: 0,
237
+ },
238
+ ]}
239
+ >
240
+ {profile?.avatar ? (
241
+ <Image
242
+ source={{
243
+ uri: profile.avatar,
244
+ }}
245
+ style={{ width: "100%", height: "100%" }}
246
+ resizeMode="cover"
247
+ />
248
+ ) : (
249
+ <View
250
+ style={{
251
+ width: "100%",
252
+ height: "100%",
253
+ backgroundColor: theme.colors.muted,
254
+ }}
255
+ />
256
+ )}
257
+ </View>
258
+
259
+ {/* Text */}
260
+ <View style={[{ flex: 1, minWidth: 0 }]}>
261
+ <Text numberOfLines={1} ellipsizeMode="tail">
262
+ {stream.author?.handle}
263
+ </Text>
264
+ {stream.record.title ? (
265
+ <Text
266
+ numberOfLines={1}
267
+ ellipsizeMode="tail"
268
+ style={[
269
+ {
270
+ fontSize: 12,
271
+ color: theme.colors.textMuted,
272
+ },
273
+ ]}
274
+ >
275
+ {stream.record.title as any}
276
+ </Text>
277
+ ) : null}
278
+ </View>
279
+ </View>
280
+ </View>
281
+ </Pressable>
282
+ );
283
+ })}
284
+ </View>
285
+ </ScrollView>
286
+ )}
287
+ </View>
288
+ <View
289
+ style={[
290
+ zero.mt[8],
291
+ zero.layout.flex.row,
292
+ zero.layout.flex.justify.end,
293
+ zero.gap.all[2],
294
+ ]}
295
+ >
296
+ <Button width="min" variant="secondary" onPress={handleCancel}>
297
+ <Text>Cancel</Text>
298
+ </Button>
299
+ <Button
300
+ width="min"
301
+ variant="primary"
302
+ onPress={handleSubmit}
303
+ disabled={!selectedStream}
304
+ >
305
+ <Text>Teleport</Text>
306
+ </Button>
307
+ </View>
308
+ </ResponsiveDialog>
309
+ );
310
+ };
@@ -0,0 +1,275 @@
1
+ import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
2
+ import { TriggerRef } from "@rn-primitives/dropdown-menu";
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { Image, Platform, Pressable, View } from "react-native";
13
+ import { ChatMessageViewHydrated } from "streamplace";
14
+ import { useAvatars } from "../../hooks/useAvatars";
15
+ import { useLivestreamStore } from "../../livestream-store";
16
+ import { useUrl } from "../../streamplace-store";
17
+ import { useTheme } from "../../ui";
18
+ import { formatHandleWithAt } from "../../utils/format-handle";
19
+ import {
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuTrigger,
23
+ } from "../ui/dropdown";
24
+ import { Text } from "../ui/text";
25
+ import { Badge } from "./badge";
26
+
27
+ interface BadgeMeta {
28
+ label: string;
29
+ description: string;
30
+ issuedBy?: string;
31
+ }
32
+
33
+ const BADGE_META: Record<string, BadgeMeta> = {
34
+ "place.stream.badge.defs#mod": {
35
+ label: "Moderator",
36
+ description: "This user is a moderator.",
37
+ issuedBy: "{issuer} for {streamer}",
38
+ },
39
+ "place.stream.badge.defs#streamer": {
40
+ label: "Streamer",
41
+ description: "This user is the streamer.",
42
+ },
43
+ "place.stream.badge.defs#vip": {
44
+ label: "VIP",
45
+ description: "This user is a very important person.",
46
+ },
47
+ };
48
+
49
+ interface OpenCardContextValue {
50
+ openUri: string | null;
51
+ setOpenUri: (uri: string | null) => void;
52
+ }
53
+
54
+ const OpenCardContext = createContext<OpenCardContextValue>({
55
+ openUri: null,
56
+ setOpenUri: () => {},
57
+ });
58
+
59
+ export const ProfileCardProvider = ({
60
+ children,
61
+ }: {
62
+ children: React.ReactNode;
63
+ }) => {
64
+ const [openUri, setOpenUri] = useState<string | null>(null);
65
+ return (
66
+ <OpenCardContext.Provider value={{ openUri, setOpenUri }}>
67
+ {children}
68
+ </OpenCardContext.Provider>
69
+ );
70
+ };
71
+
72
+ const BadgeRow = ({
73
+ badge,
74
+ serviceDid,
75
+ }: {
76
+ badge: NonNullable<ChatMessageViewHydrated["badges"]>[number];
77
+ serviceDid: string;
78
+ }) => {
79
+ const streamer = useLivestreamStore((x) => x.livestream?.author);
80
+ const isServiceIssued = badge.issuer === serviceDid;
81
+ const issuerDids = useMemo(
82
+ () => (isServiceIssued ? [] : [badge.issuer]),
83
+ [isServiceIssued, badge.issuer],
84
+ );
85
+ const issuerProfiles = useAvatars(issuerDids);
86
+ const meta = BADGE_META[badge.badgeType];
87
+
88
+ if (!meta) return null;
89
+
90
+ let issuerLabel = isServiceIssued
91
+ ? "Streamplace"
92
+ : issuerProfiles[badge.issuer]?.handle
93
+ ? `@${issuerProfiles[badge.issuer].handle}`
94
+ : badge.issuer;
95
+ if (meta.issuedBy) {
96
+ issuerLabel = meta.issuedBy
97
+ .replace("{issuer}", issuerLabel)
98
+ .replace(
99
+ "{streamer}",
100
+ streamer?.handle ? formatHandleWithAt(streamer) : "the streamer",
101
+ );
102
+ }
103
+
104
+ return (
105
+ <View
106
+ style={{
107
+ flexDirection: "row",
108
+ alignItems: "center",
109
+ gap: 12,
110
+ paddingLeft: 6,
111
+ }}
112
+ >
113
+ <Badge badgeType={badge.badgeType} size={32} />
114
+ <View style={{ flex: 1, gap: 2 }}>
115
+ <Text size="xs">{meta.label}</Text>
116
+ <Text size="xs" color="muted">
117
+ Issued by {issuerLabel}
118
+ </Text>
119
+ <Text size="xs" color="muted">
120
+ {meta.description}
121
+ </Text>
122
+ </View>
123
+ </View>
124
+ );
125
+ };
126
+
127
+ export const UserProfileCard = ({
128
+ uri,
129
+ author,
130
+ badges,
131
+ children,
132
+ }: {
133
+ uri: string;
134
+ author: ProfileViewBasic;
135
+ badges: ChatMessageViewHydrated["badges"];
136
+ children: React.ReactNode;
137
+ }) => {
138
+ const { theme } = useTheme();
139
+ const nodeUrl = useUrl();
140
+ const serviceDid = nodeUrl
141
+ ? `did:web:${nodeUrl.replace(/^https?:\/\//, "")}`
142
+ : null;
143
+
144
+ const { openUri, setOpenUri } = useContext(OpenCardContext);
145
+ const isOpen = openUri === uri;
146
+ const thisRef = useRef<TriggerRef>(null);
147
+ const [hovered, setHovered] = useState(false);
148
+
149
+ const profiles = useAvatars(author.did ? [author.did] : []);
150
+ const profile = profiles[author.did];
151
+
152
+ useEffect(() => {
153
+ isOpen ? thisRef.current?.open() : thisRef.current?.close();
154
+ }, [isOpen]);
155
+
156
+ const onOpenChange = useCallback(
157
+ (open: boolean) => {
158
+ setOpenUri(open ? uri : null);
159
+ },
160
+ [uri, setOpenUri],
161
+ );
162
+
163
+ const serviceBadges = useMemo(
164
+ () => badges?.filter((b) => serviceDid && b.issuer === serviceDid) ?? [],
165
+ [badges, serviceDid],
166
+ );
167
+
168
+ return (
169
+ <DropdownMenu onOpenChange={onOpenChange}>
170
+ <DropdownMenuTrigger ref={thisRef} asChild>
171
+ <Pressable
172
+ onPress={() => {}}
173
+ {...(Platform.OS === "web"
174
+ ? {
175
+ onHoverIn: () => setHovered(true),
176
+ onHoverOut: () => setHovered(false),
177
+ }
178
+ : {})}
179
+ style={{
180
+ paddingHorizontal: 3,
181
+ flexDirection: "row",
182
+ gap: 4,
183
+ marginLeft: -3,
184
+ paddingLeft: 3,
185
+ marginRight: -2,
186
+ ...(Platform.OS === "web" && hovered
187
+ ? { backgroundColor: "rgba(255,255,255,0.15)", borderRadius: 6 }
188
+ : {}),
189
+ }}
190
+ >
191
+ {children}
192
+ </Pressable>
193
+ </DropdownMenuTrigger>
194
+ <DropdownMenuContent style={{ minWidth: 280, maxWidth: 320 }}>
195
+ <View>
196
+ {profile?.banner ? (
197
+ <Image
198
+ source={{ uri: profile.banner }}
199
+ style={{
200
+ width: "100%",
201
+ height: 80,
202
+ borderRadius: theme.borderRadius.md,
203
+ }}
204
+ />
205
+ ) : (
206
+ <View
207
+ style={{
208
+ width: "100%",
209
+ height: 80,
210
+ borderRadius: theme.borderRadius.md,
211
+ backgroundColor: theme.colors.muted,
212
+ }}
213
+ />
214
+ )}
215
+ <View
216
+ style={{
217
+ flexDirection: "row",
218
+ alignItems: "flex-end",
219
+ marginTop: -24,
220
+ paddingHorizontal: 12,
221
+ }}
222
+ >
223
+ {profile?.avatar ? (
224
+ <Image
225
+ source={{ uri: profile.avatar }}
226
+ style={{
227
+ width: 48,
228
+ height: 48,
229
+ borderRadius: 24,
230
+ borderWidth: 2,
231
+ borderColor: theme.colors.card,
232
+ }}
233
+ />
234
+ ) : (
235
+ <View
236
+ style={{
237
+ width: 48,
238
+ height: 48,
239
+ borderRadius: 24,
240
+ backgroundColor: theme.colors.mutedForeground,
241
+ borderWidth: 2,
242
+ borderColor: theme.colors.card,
243
+ }}
244
+ />
245
+ )}
246
+ </View>
247
+ <View style={{ paddingHorizontal: 12 }}>
248
+ <Text>@{author.handle}</Text>
249
+ {profile?.description ? (
250
+ <Text size="sm" color="muted" numberOfLines={4}>
251
+ {profile.description}
252
+ </Text>
253
+ ) : null}
254
+ </View>
255
+ {serviceBadges.length > 0 && serviceDid ? (
256
+ <View
257
+ style={{
258
+ marginTop: 12,
259
+ paddingHorizontal: 12,
260
+ paddingBottom: 8,
261
+ borderTopWidth: 1,
262
+ borderTopColor: theme.colors.border,
263
+ paddingTop: 8,
264
+ }}
265
+ >
266
+ {serviceBadges.map((badge, i) => (
267
+ <BadgeRow key={i} badge={badge} serviceDid={serviceDid} />
268
+ ))}
269
+ </View>
270
+ ) : null}
271
+ </View>
272
+ </DropdownMenuContent>
273
+ </DropdownMenu>
274
+ );
275
+ };
@@ -1,3 +1,4 @@
1
+ import React from "react";
1
2
  import { Text, View } from "react-native";
2
3
  import * as zero from "../../ui";
3
4
  import { Chat } from "../chat/chat";
@@ -11,6 +12,11 @@ interface ChatPanelProps {
11
12
  messagesPerMinute?: number;
12
13
  shownMessages?: number;
13
14
  emojiData?: any;
15
+ emojiPicker?: (
16
+ isOpen: boolean,
17
+ onClose: () => void,
18
+ onSelect: (emoji: any) => void,
19
+ ) => React.ReactNode;
14
20
  }
15
21
 
16
22
  export default function ChatPanel({
@@ -19,6 +25,7 @@ export default function ChatPanel({
19
25
  messagesPerMinute = 0,
20
26
  shownMessages = 50,
21
27
  emojiData = null,
28
+ emojiPicker,
22
29
  }: ChatPanelProps) {
23
30
  return (
24
31
  <View
@@ -63,6 +70,7 @@ export default function ChatPanel({
63
70
  <View style={[{ flexShrink: 0 }]}>
64
71
  <ChatBox
65
72
  emojiData={emojiData}
73
+ emojiPicker={emojiPicker}
66
74
  chatBoxStyle={[
67
75
  bg.gray[700],
68
76
  borders.width.thin,
@@ -8,7 +8,7 @@ interface MetricItemProps {
8
8
  icon: any;
9
9
  label: string;
10
10
  value: string;
11
- status?: "good" | "warning" | "error";
11
+ status?: "good" | "warning" | "error" | "pre-live";
12
12
  }
13
13
 
14
14
  function MetricItem({ icon: Icon, label, value, status }: MetricItemProps) {
@@ -36,7 +36,7 @@ function MetricItem({ icon: Icon, label, value, status }: MetricItemProps) {
36
36
  }
37
37
 
38
38
  interface StatusIndicatorProps {
39
- status: "excellent" | "good" | "poor" | "offline";
39
+ status: "excellent" | "good" | "poor" | "offline" | "pre-live";
40
40
  isLive: boolean;
41
41
  }
42
42
 
@@ -44,6 +44,8 @@ function StatusIndicator({ status, isLive }: StatusIndicatorProps) {
44
44
  const getStatusColor = () => {
45
45
  if (!isLive) return bg.gray[500];
46
46
  switch (status) {
47
+ case "pre-live":
48
+ return bg.blue[500];
47
49
  case "excellent":
48
50
  return bg.green[500];
49
51
  case "good":
@@ -60,6 +62,8 @@ function StatusIndicator({ status, isLive }: StatusIndicatorProps) {
60
62
  const getStatusText = () => {
61
63
  if (!isLive) return "OFFLINE";
62
64
  switch (status) {
65
+ case "pre-live":
66
+ return "NOT LIVE";
63
67
  case "excellent":
64
68
  return "EXCELLENT";
65
69
  case "good":
@@ -101,7 +105,7 @@ interface HeaderProps {
101
105
  uptime?: string;
102
106
  bitrate?: string;
103
107
  timeBetweenSegments?: number;
104
- connectionStatus?: "excellent" | "good" | "poor" | "offline";
108
+ connectionStatus?: "excellent" | "good" | "poor" | "offline" | "pre-live";
105
109
  problemsCount?: number;
106
110
  onProblemsPress?: () => void;
107
111
  }