@streamplace/components 0.10.6 → 0.10.8

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 (79) hide show
  1. package/dist/components/chat/chat.js +1 -1
  2. package/dist/components/chat/mod-view.d.ts.map +1 -1
  3. package/dist/components/chat/mod-view.js +31 -6
  4. package/dist/components/chat/mod-view.js.map +1 -1
  5. package/dist/components/dashboard/moderator-panel.js +7 -1
  6. package/dist/components/dashboard/moderator-panel.js.map +1 -1
  7. package/dist/components/mobile-player/props.d.ts +1 -0
  8. package/dist/components/mobile-player/props.d.ts.map +1 -1
  9. package/dist/components/mobile-player/rotation-lock.js +2 -2
  10. package/dist/components/mobile-player/rotation-lock.js.map +1 -1
  11. package/dist/components/mobile-player/use-webrtc.d.ts +2 -1
  12. package/dist/components/mobile-player/use-webrtc.d.ts.map +1 -1
  13. package/dist/components/mobile-player/use-webrtc.js +30 -15
  14. package/dist/components/mobile-player/use-webrtc.js.map +1 -1
  15. package/dist/components/mobile-player/video-async.native.d.ts.map +1 -1
  16. package/dist/components/mobile-player/video-async.native.js +5 -1
  17. package/dist/components/mobile-player/video-async.native.js.map +1 -1
  18. package/dist/components/stream-notification/pin-notification.d.ts +7 -0
  19. package/dist/components/stream-notification/pin-notification.d.ts.map +1 -0
  20. package/dist/components/stream-notification/pin-notification.js +63 -0
  21. package/dist/components/stream-notification/pin-notification.js.map +1 -0
  22. package/dist/components/ui/dropdown.d.ts.map +1 -1
  23. package/dist/components/ui/dropdown.js +3 -2
  24. package/dist/components/ui/dropdown.js.map +1 -1
  25. package/dist/components/ui/resizeable.js +2 -2
  26. package/dist/components/ui/resizeable.js.map +1 -1
  27. package/dist/lib/stream-notifications.d.ts +7 -0
  28. package/dist/lib/stream-notifications.d.ts.map +1 -1
  29. package/dist/lib/stream-notifications.js +21 -0
  30. package/dist/lib/stream-notifications.js.map +1 -1
  31. package/dist/lib/theme/atoms.d.ts +141 -141
  32. package/dist/livestream-provider/index.d.ts +1 -0
  33. package/dist/livestream-provider/index.d.ts.map +1 -1
  34. package/dist/livestream-provider/index.js +35 -3
  35. package/dist/livestream-provider/index.js.map +1 -1
  36. package/dist/livestream-store/chat.d.ts +2 -0
  37. package/dist/livestream-store/chat.d.ts.map +1 -1
  38. package/dist/livestream-store/chat.js +80 -1
  39. package/dist/livestream-store/chat.js.map +1 -1
  40. package/dist/livestream-store/livestream-state.d.ts +2 -1
  41. package/dist/livestream-store/livestream-state.d.ts.map +1 -1
  42. package/dist/livestream-store/livestream-store.d.ts +1 -0
  43. package/dist/livestream-store/livestream-store.d.ts.map +1 -1
  44. package/dist/livestream-store/livestream-store.js +4 -1
  45. package/dist/livestream-store/livestream-store.js.map +1 -1
  46. package/dist/livestream-store/websocket-consumer.d.ts.map +1 -1
  47. package/dist/livestream-store/websocket-consumer.js +14 -0
  48. package/dist/livestream-store/websocket-consumer.js.map +1 -1
  49. package/dist/streamplace-store/moderation.d.ts +1 -0
  50. package/dist/streamplace-store/moderation.d.ts.map +1 -1
  51. package/dist/streamplace-store/moderation.js +1 -0
  52. package/dist/streamplace-store/moderation.js.map +1 -1
  53. package/dist/streamplace-store/moderator-management.d.ts +1 -1
  54. package/dist/streamplace-store/moderator-management.d.ts.map +1 -1
  55. package/dist/streamplace-store/xrpc.d.ts +2 -0
  56. package/dist/streamplace-store/xrpc.d.ts.map +1 -1
  57. package/dist/streamplace-store/xrpc.js +18 -0
  58. package/dist/streamplace-store/xrpc.js.map +1 -1
  59. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  60. package/package.json +3 -3
  61. package/src/components/chat/chat.tsx +1 -1
  62. package/src/components/chat/mod-view.tsx +82 -3
  63. package/src/components/dashboard/moderator-panel.tsx +13 -2
  64. package/src/components/mobile-player/props.tsx +1 -0
  65. package/src/components/mobile-player/rotation-lock.tsx +2 -2
  66. package/src/components/mobile-player/use-webrtc.tsx +47 -12
  67. package/src/components/mobile-player/video-async.native.tsx +5 -0
  68. package/src/components/stream-notification/pin-notification.tsx +135 -0
  69. package/src/components/ui/dropdown.tsx +3 -2
  70. package/src/components/ui/resizeable.tsx +2 -2
  71. package/src/lib/stream-notifications.ts +28 -0
  72. package/src/livestream-provider/index.tsx +38 -2
  73. package/src/livestream-store/chat.tsx +92 -0
  74. package/src/livestream-store/livestream-state.tsx +2 -0
  75. package/src/livestream-store/livestream-store.tsx +4 -0
  76. package/src/livestream-store/websocket-consumer.tsx +15 -0
  77. package/src/streamplace-store/moderation.tsx +2 -0
  78. package/src/streamplace-store/moderator-management.tsx +1 -1
  79. package/src/streamplace-store/xrpc.tsx +22 -0
@@ -17,6 +17,7 @@ import { ChatMessageViewHydrated } from "streamplace";
17
17
  import {
18
18
  useDeleteChatMessage,
19
19
  useLivestreamStore,
20
+ usePinChatMessage,
20
21
  } from "../../livestream-store";
21
22
  import { useStreamplaceStore } from "../../streamplace-store";
22
23
  import { formatHandle, formatHandleWithAt } from "../../utils/format-handle";
@@ -25,6 +26,9 @@ import {
25
26
  DropdownMenu,
26
27
  DropdownMenuGroup,
27
28
  DropdownMenuItem,
29
+ DropdownMenuSub,
30
+ DropdownMenuSubContent,
31
+ DropdownMenuSubTrigger,
28
32
  DropdownMenuTrigger,
29
33
  layout,
30
34
  ResponsiveDropdownMenuContent,
@@ -55,6 +59,7 @@ export const ModView = forwardRef<ModViewRef, ModViewProps>(() => {
55
59
  let [messageRemoved, setMessageRemoved] = useState(false);
56
60
  let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord();
57
61
  let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord();
62
+ const pinChatMessage = usePinChatMessage();
58
63
 
59
64
  const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen);
60
65
  const setReportSubject = usePlayerStore((x) => x.setReportSubject);
@@ -91,9 +96,8 @@ export const ModView = forwardRef<ModViewRef, ModViewProps>(() => {
91
96
  message &&
92
97
  agent?.did &&
93
98
  ((modPermissions.canHide && message.author.did !== streamerDID) ||
94
- (modPermissions.canBan &&
95
- message.author.did !== agent.did &&
96
- message.author.did !== streamerDID))
99
+ (modPermissions.canPin && message.author.did !== streamerDID) ||
100
+ modPermissions.canBan)
97
101
  );
98
102
 
99
103
  return (
@@ -124,6 +128,7 @@ export const ModView = forwardRef<ModViewRef, ModViewProps>(() => {
124
128
  setMessageRemoved={setMessageRemoved}
125
129
  createHideChat={createHideChat}
126
130
  createBlock={createBlock}
131
+ pinChatMessage={pinChatMessage}
127
132
  toast={toast}
128
133
  setReportModalOpen={setReportModalOpen}
129
134
  setReportSubject={setReportSubject}
@@ -148,6 +153,11 @@ interface ModViewContentProps {
148
153
  setMessageRemoved: (removed: boolean) => void;
149
154
  createHideChat: (uri: string, streamerDID?: string) => Promise<any>;
150
155
  createBlock: (did: string, streamerDID?: string) => Promise<any>;
156
+ pinChatMessage: (
157
+ messageUri: string,
158
+ streamerDID: string,
159
+ expiresAt?: string,
160
+ ) => Promise<any>;
151
161
  toast: ReturnType<typeof useToast>;
152
162
  setReportModalOpen: (open: boolean) => void;
153
163
  setReportSubject: (subject: any) => void;
@@ -166,6 +176,7 @@ function ModViewContent({
166
176
  setMessageRemoved,
167
177
  createHideChat,
168
178
  createBlock,
179
+ pinChatMessage,
169
180
  toast,
170
181
  setReportModalOpen,
171
182
  setReportSubject,
@@ -223,6 +234,74 @@ function ModViewContent({
223
234
  </Text>
224
235
  </DropdownMenuItem>
225
236
  )}
237
+ {modPermissions.canPin && (
238
+ <DropdownMenuGroup key="pin-actions">
239
+ <DropdownMenuSub>
240
+ <DropdownMenuSubTrigger
241
+ subMenuTitle="Pin message"
242
+ style={{ padding: 0, margin: 0 }}
243
+ >
244
+ <Text color="primary">Pin this message</Text>
245
+ </DropdownMenuSubTrigger>
246
+ <DropdownMenuSubContent>
247
+ <DropdownMenuGroup title="Pin duration">
248
+ <DropdownMenuItem
249
+ onPress={() => {
250
+ if (!streamerDID) return;
251
+ pinChatMessage(message.uri, streamerDID)
252
+ .then(() => {
253
+ toast.show("Comment pinned", "", { duration: 3 });
254
+ onOpenChange?.(false);
255
+ })
256
+ .catch((e) => {
257
+ toast.show(
258
+ "Error pinning comment",
259
+ e instanceof Error ? e.message : "Failed to pin",
260
+ { duration: 5 },
261
+ );
262
+ });
263
+ }}
264
+ >
265
+ <Text color="primary">Until stream end</Text>
266
+ </DropdownMenuItem>
267
+ {[5, 10, 15, 30, 60].map((minutes) => (
268
+ <DropdownMenuItem
269
+ key={minutes}
270
+ onPress={() => {
271
+ if (!streamerDID) return;
272
+ const expiresAt = new Date(
273
+ Date.now() + minutes * 60 * 1000,
274
+ );
275
+ pinChatMessage(
276
+ message.uri,
277
+ streamerDID,
278
+ expiresAt.toISOString(),
279
+ )
280
+ .then(() => {
281
+ toast.show("Comment pinned", "", { duration: 3 });
282
+ onOpenChange?.(false);
283
+ })
284
+ .catch((e) => {
285
+ toast.show(
286
+ "Error pinning comment",
287
+ e instanceof Error
288
+ ? e.message
289
+ : "Failed to pin",
290
+ { duration: 5 },
291
+ );
292
+ });
293
+ }}
294
+ >
295
+ <Text color="primary">
296
+ {minutes < 60 ? `${minutes} min` : "1 hour"}
297
+ </Text>
298
+ </DropdownMenuItem>
299
+ ))}
300
+ </DropdownMenuGroup>
301
+ </DropdownMenuSubContent>
302
+ </DropdownMenuSub>
303
+ </DropdownMenuGroup>
304
+ )}
226
305
  {modPermissions.canBan &&
227
306
  agent?.did &&
228
307
  message.author.did !== agent.did &&
@@ -372,6 +372,7 @@ function AddModeratorDialog({
372
372
  ban: false,
373
373
  hide: false,
374
374
  "livestream.manage": false,
375
+ "message.pin": false,
375
376
  });
376
377
  const [error, setError] = useState<string | null>(null);
377
378
  const toast = useToast();
@@ -380,7 +381,12 @@ function AddModeratorDialog({
380
381
  useEffect(() => {
381
382
  if (!visible) {
382
383
  setModeratorDID("");
383
- setPermissions({ ban: false, hide: false, "livestream.manage": false });
384
+ setPermissions({
385
+ ban: false,
386
+ hide: false,
387
+ "livestream.manage": false,
388
+ "message.pin": false,
389
+ });
384
390
  setError(null);
385
391
  }
386
392
  }, [visible]);
@@ -401,7 +407,12 @@ function AddModeratorDialog({
401
407
 
402
408
  const selectedPermissions = Object.entries(permissions)
403
409
  .filter(([_, enabled]) => enabled)
404
- .map(([perm]) => perm) as ("ban" | "hide" | "livestream.manage")[];
410
+ .map(([perm]) => perm) as (
411
+ | "ban"
412
+ | "hide"
413
+ | "livestream.manage"
414
+ | "message.pin"
415
+ )[];
405
416
 
406
417
  if (selectedPermissions.length === 0) {
407
418
  setError("Please select at least one permission");
@@ -2,6 +2,7 @@ export type PlayerProps = {
2
2
  name: string;
3
3
  playerId?: string;
4
4
  src: string;
5
+ mode?: "live" | "vod";
5
6
  muted: boolean;
6
7
  telemetry: boolean;
7
8
  fullscreen: boolean;
@@ -64,12 +64,12 @@ export const RotationProvider: React.FC<RotationProviderProps> = ({
64
64
  try {
65
65
  await ScreenOrientation.unlockAsync();
66
66
  await ScreenOrientation.lockAsync(
67
- ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
67
+ ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
68
68
  );
69
69
  setIsLocked(true);
70
70
 
71
71
  // set current orientation to landscape right
72
- setCurrentOrientation(ScreenOrientation.Orientation.LANDSCAPE_RIGHT);
72
+ setCurrentOrientation(ScreenOrientation.Orientation.LANDSCAPE_LEFT);
73
73
 
74
74
  if (__DEV__) {
75
75
  console.log("📲 Manual landscape");
@@ -1,11 +1,14 @@
1
+ import { SessionManager } from "@atproto/api/dist/session-manager";
1
2
  import { useEffect, useRef, useState } from "react";
2
3
  import * as sdpTransform from "sdp-transform";
3
4
  import { StreamplaceAgent } from "streamplace";
4
5
  import {
6
+ createAgentForServer,
5
7
  PlayerStatus,
6
8
  usePlayerStore,
7
9
  usePossiblyUnauthedPDSAgent,
8
10
  useStreamKey,
11
+ useStreamplaceStore,
9
12
  } from "../..";
10
13
  import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives";
11
14
 
@@ -16,6 +19,7 @@ export default function useWebRTC(
16
19
  const [stuck, setStuck] = useState<boolean>(false);
17
20
  const setStatus = usePlayerStore((x) => x.setStatus);
18
21
  let agent = usePossiblyUnauthedPDSAgent();
22
+ const oauthSession = useStreamplaceStore((state) => state.oauthSession);
19
23
 
20
24
  const lastChange = useRef<number>(0);
21
25
 
@@ -59,6 +63,7 @@ export default function useWebRTC(
59
63
  streamer,
60
64
  undefined,
61
65
  agent,
66
+ oauthSession,
62
67
  );
63
68
  });
64
69
 
@@ -97,7 +102,7 @@ export default function useWebRTC(
97
102
  clearInterval(handle);
98
103
  peerConnection.close();
99
104
  };
100
- }, [streamer, agent]);
105
+ }, [streamer, agent, oauthSession]);
101
106
  return [mediaStream, stuck];
102
107
  }
103
108
 
@@ -118,6 +123,7 @@ export async function negotiateConnectionWithClientOffer(
118
123
  streamer: string,
119
124
  bearerToken?: string,
120
125
  agent?: StreamplaceAgent,
126
+ oauthSession?: SessionManager | null,
121
127
  ) {
122
128
  /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */
123
129
  const offer = await peerConnection.createOffer({
@@ -150,7 +156,13 @@ export async function negotiateConnectionWithClientOffer(
150
156
  * This specifies how the client should communicate,
151
157
  * and what kind of media client and server have negotiated to exchange.
152
158
  */
153
- let response = await postSDPOffer(streamer, ofr.sdp, bearerToken, agent);
159
+ let response = await postSDPOffer(
160
+ streamer,
161
+ ofr.sdp,
162
+ bearerToken,
163
+ agent,
164
+ oauthSession,
165
+ );
154
166
  let text = new TextDecoder().decode(response.data);
155
167
  if (response.success) {
156
168
  if ((peerConnection.connectionState as string) === "closed") {
@@ -233,30 +245,53 @@ export async function negotiateIngestConnectionWithClientOffer(
233
245
  }
234
246
  }
235
247
 
248
+ async function getPlaybackServerAgent(
249
+ agent: StreamplaceAgent,
250
+ oauthSession: SessionManager | null | undefined,
251
+ streamer: string,
252
+ ): Promise<StreamplaceAgent> {
253
+ const workerUrl = (window as any).PLAYBACK_WORKER_URL as string | undefined;
254
+ if (!workerUrl) {
255
+ return agent;
256
+ }
257
+
258
+ try {
259
+ const lookupAgent = new StreamplaceAgent(workerUrl);
260
+ const res = await lookupAgent.place.stream.playback.getPlaybackServer({
261
+ stream: streamer,
262
+ });
263
+ if (res.data.servers.length > 0) {
264
+ const serverUrl = res.data.servers[0];
265
+ console.log(`Using playback server: ${serverUrl}`);
266
+ return createAgentForServer(oauthSession, serverUrl);
267
+ }
268
+ } catch (e) {
269
+ console.error("getPlaybackServer failed, using default agent:", e);
270
+ }
271
+ return agent;
272
+ }
273
+
236
274
  async function postSDPOffer(
237
275
  streamer: string,
238
276
  data: string,
239
277
  bearerToken?: string,
240
278
  agent?: StreamplaceAgent,
279
+ oauthSession?: SessionManager | null,
241
280
  ) {
242
281
  if (!agent) {
243
282
  throw new Error("No agent found");
244
283
  }
245
- return await agent.place.stream.playback.whep(data, {
284
+ const playbackAgent = await getPlaybackServerAgent(
285
+ agent,
286
+ oauthSession,
287
+ streamer,
288
+ );
289
+ return await playbackAgent.place.stream.playback.whep(data, {
246
290
  qp: {
247
291
  rendition: "source",
248
292
  streamer: streamer,
249
293
  },
250
294
  });
251
- // return await fetch(endpoint, {
252
- // method: "POST",
253
- // mode: "cors",
254
- // headers: {
255
- // "content-type": "application/sdp",
256
- // ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
257
- // },
258
- // body: data,
259
- // });
260
295
  }
261
296
 
262
297
  async function postSDPIngestOffer(
@@ -177,6 +177,11 @@ export function NativeVideo(props?: {
177
177
  }}
178
178
  allowsPictureInPicture={props?.pictureInPictureEnabled !== false}
179
179
  onLayout={handleLayout}
180
+ style={{
181
+ minWidth: "100%",
182
+ minHeight: "100%",
183
+ flex: 1,
184
+ }}
180
185
  />
181
186
  </>
182
187
  );
@@ -0,0 +1,135 @@
1
+ import { EyeOff, Pin, X } from "lucide-react-native";
2
+ import { useEffect, useState } from "react";
3
+ import { Linking, Pressable, View } from "react-native";
4
+ import { PinnedRecordViewHydrated, PlaceStreamChatProfile } from "streamplace";
5
+ import {
6
+ Text,
7
+ useCanModerate,
8
+ useLivestreamStore,
9
+ useTheme,
10
+ zero,
11
+ } from "../../";
12
+ import { RichtextSegment, segmentize } from "../../lib/facet";
13
+ import { formatHandleWithAt } from "../../utils/format-handle";
14
+
15
+ const getRgbColor = (color?: PlaceStreamChatProfile.Color) =>
16
+ color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : undefined;
17
+
18
+ function renderSegment(segment: RichtextSegment, index: number) {
19
+ if (segment.features && segment.features.length > 0) {
20
+ const ftr = segment.features[0];
21
+ if (ftr.$type === "app.bsky.richtext.facet#link") {
22
+ return (
23
+ <Text
24
+ key={index}
25
+ style={[{ color: "#007AFF" }]}
26
+ onPress={() => Linking.openURL((ftr as any).uri || "")}
27
+ >
28
+ {segment.text}
29
+ </Text>
30
+ );
31
+ }
32
+ if (ftr.$type === "app.bsky.richtext.facet#mention") {
33
+ return (
34
+ <Text key={index} style={[{ color: "#007AFF" }]}>
35
+ {segment.text}
36
+ </Text>
37
+ );
38
+ }
39
+ }
40
+ return <Text key={index}>{segment.text}</Text>;
41
+ }
42
+
43
+ export function PinnedCommentNotification({
44
+ pinnedComment,
45
+ onDismiss,
46
+ onUnpin,
47
+ }: {
48
+ pinnedComment: PinnedRecordViewHydrated;
49
+ onDismiss: () => void;
50
+ onUnpin: () => void;
51
+ }) {
52
+ const z = useTheme();
53
+ const message = pinnedComment.message;
54
+ const pinnedByColor = (pinnedComment.pinnedBy as any)?.color || "#bebebe";
55
+ const record = pinnedComment.record;
56
+
57
+ const currentStreamer = useLivestreamStore((state) => state.profile?.did);
58
+
59
+ console.log("checking if we can mod", currentStreamer);
60
+
61
+ const canActuallyPin = useCanModerate(currentStreamer)?.canPin;
62
+
63
+ const messageRecord = message?.record as any;
64
+
65
+ const [expiresAt] = useState<Date | null>(
66
+ record.expiresAt ? new Date(record.expiresAt) : null,
67
+ );
68
+
69
+ useEffect(() => {
70
+ if (!expiresAt) return;
71
+ const remaining = expiresAt.getTime() - Date.now();
72
+ if (remaining <= 0) {
73
+ onDismiss();
74
+ return;
75
+ }
76
+ const timeout = setTimeout(onDismiss, remaining);
77
+ return () => clearTimeout(timeout);
78
+ }, [expiresAt, onDismiss]);
79
+
80
+ const authorName = message ? formatHandleWithAt(message.author) : "unknown";
81
+ const authorColor = getRgbColor((message as any)?.chatProfile?.color);
82
+
83
+ const segments = messageRecord
84
+ ? segmentize(messageRecord.text, messageRecord.facets)
85
+ : [];
86
+
87
+ return (
88
+ <View style={[zero.bg.neutral[900], zero.r.lg, { overflow: "hidden" }]}>
89
+ <View
90
+ style={[
91
+ zero.layout.flex.row,
92
+ zero.layout.flex.alignCenter,
93
+ zero.px[3],
94
+ zero.py[2],
95
+ { gap: 8 },
96
+ ]}
97
+ >
98
+ <View style={{ transform: [{ rotate: "-25deg" }] }}>
99
+ <Pin
100
+ size={24}
101
+ color={authorColor || z.theme.colors.primary}
102
+ fill={authorColor || z.theme.colors.primary}
103
+ />
104
+ </View>
105
+ <View
106
+ style={[zero.layout.flex.column, zero.flex.values[1], { gap: 4 }]}
107
+ >
108
+ <View style={[zero.layout.flex.row, { gap: 4, flexWrap: "wrap" }]}>
109
+ <Text
110
+ style={[
111
+ {
112
+ fontWeight: "600",
113
+ color: authorColor || zero.colors.gray[200],
114
+ },
115
+ ]}
116
+ >
117
+ {authorName}
118
+ </Text>
119
+ {segments.map((seg, i) => renderSegment(seg, i))}
120
+ </View>
121
+ </View>
122
+ <View style={[zero.layout.flex.row, { gap: 4 }]}>
123
+ {canActuallyPin && (
124
+ <Pressable onPress={onUnpin} style={{ padding: 4 }}>
125
+ <X size={16} color={zero.colors.gray[400]} />
126
+ </Pressable>
127
+ )}
128
+ <Pressable onPress={onDismiss} style={{ padding: 4 }}>
129
+ <EyeOff size={16} color={zero.colors.gray[400]} />
130
+ </Pressable>
131
+ </View>
132
+ </View>
133
+ </View>
134
+ );
135
+ }
@@ -74,7 +74,7 @@ export const DropdownMenuSubTrigger = forwardRef<
74
74
  inset?: boolean;
75
75
  children?: React.ReactNode;
76
76
  }
77
- >(({ inset, children, subMenuTitle, ...props }, ref) => {
77
+ >(({ inset, children, subMenuTitle, style, ...props }, ref) => {
78
78
  const { icons } = useTheme();
79
79
  const { open } = DropdownMenuPrimitive.useSubContext();
80
80
  const Icon =
@@ -96,6 +96,7 @@ export const DropdownMenuSubTrigger = forwardRef<
96
96
  layout.flex.alignCenter,
97
97
  p[2],
98
98
  pr[8],
99
+ style,
99
100
  ]}
100
101
  >
101
102
  {children}
@@ -513,7 +514,7 @@ export const DropdownMenuGroup = forwardRef<
513
514
  const { theme } = useTheme();
514
515
  const { inset, title, children, ...rest } = props;
515
516
  return (
516
- <View style={[pt[2], inset && gap[2]]} ref={ref} {...rest}>
517
+ <View style={[inset && gap[2]]} ref={ref} {...rest}>
517
518
  {title && (
518
519
  <Text style={[{ color: theme.colors.textMuted }, pb[1], pl[2]]}>
519
520
  {title}
@@ -26,8 +26,8 @@ const AnimatedView = Animated.createAnimatedComponent(View);
26
26
  const { height: SCREEN_HEIGHT } = Dimensions.get("window");
27
27
 
28
28
  const TIMING_CONFIG = {
29
- duration: 300,
30
- easing: Easing.inOut(Easing.quad),
29
+ duration: 400,
30
+ easing: Easing.out(Easing.quad),
31
31
  };
32
32
 
33
33
  type ResizableChatSheetProps = {
@@ -1,8 +1,36 @@
1
1
  import React from "react";
2
+ import { PinnedRecordViewHydrated } from "streamplace";
2
3
  import { streamNotification } from "../components/stream-notification";
4
+ import { PinnedCommentNotification } from "../components/stream-notification/pin-notification";
3
5
  import { TeleportNotification } from "../components/stream-notification/teleport-notification";
4
6
 
5
7
  export const StreamNotifications = {
8
+ pinnedComment: (params: {
9
+ pinnedComment: PinnedRecordViewHydrated;
10
+ onDismiss?: (reason?: "user" | "auto") => void;
11
+ onUnpin?: () => void;
12
+ }) => {
13
+ streamNotification.show({
14
+ id: "pinned-comment",
15
+ render: (isExiting, onDismiss) => {
16
+ return React.createElement(PinnedCommentNotification, {
17
+ pinnedComment: params.pinnedComment,
18
+ onDismiss: () => onDismiss("user"),
19
+ onUnpin: () => {
20
+ params.onUnpin?.();
21
+ onDismiss("user");
22
+ },
23
+ });
24
+ },
25
+ duration: 0, // manually dismissed or auto-dismissed by TTL
26
+ onDismiss: params.onDismiss,
27
+ });
28
+ },
29
+
30
+ pinnedCommentDismiss: () => {
31
+ streamNotification.hide("pinned-comment");
32
+ },
33
+
6
34
  teleport: (params: {
7
35
  targetHandle: string;
8
36
  targetDID: string;
@@ -4,8 +4,11 @@ import { deleteTeleport } from "../lib/slash-commands/teleport";
4
4
  import { StreamNotifications } from "../lib/stream-notifications";
5
5
  import {
6
6
  LivestreamContext,
7
+ getStoreFromContext,
7
8
  makeLivestreamStore,
8
9
  useLivestreamStore,
10
+ usePinnedComment,
11
+ useUnpinChatMessage,
9
12
  } from "../livestream-store";
10
13
  import { useDID, usePDSAgent } from "../streamplace-store";
11
14
  import { useLivestreamWebsocket } from "./websocket";
@@ -134,6 +137,40 @@ export function TeleportWatcher({
134
137
  return <></>;
135
138
  }
136
139
 
140
+ export function PinnedCommentWatcher() {
141
+ const pinnedComment = usePinnedComment();
142
+ const streamerDID = useLivestreamStore((state) => state.profile?.did);
143
+ const unpinChatMessage = useUnpinChatMessage();
144
+ const store = getStoreFromContext();
145
+ const prevPinnedRef = useRef<string | null>(null);
146
+
147
+ // Show/hide notification when pinned comment changes
148
+ useEffect(() => {
149
+ const currentUri = pinnedComment?.uri ?? null;
150
+ if (currentUri === prevPinnedRef.current) return;
151
+ prevPinnedRef.current = currentUri;
152
+
153
+ if (pinnedComment) {
154
+ StreamNotifications.pinnedComment({
155
+ pinnedComment,
156
+ onDismiss: () => {
157
+ store.setState({ pinnedComment: null });
158
+ },
159
+ onUnpin: () => {
160
+ if (!streamerDID) return;
161
+ unpinChatMessage(pinnedComment.uri, streamerDID).catch((e) => {
162
+ console.error("Failed to unpin:", e);
163
+ });
164
+ },
165
+ });
166
+ } else {
167
+ StreamNotifications.pinnedCommentDismiss();
168
+ }
169
+ }, [pinnedComment, streamerDID, unpinChatMessage]);
170
+
171
+ return <></>;
172
+ }
173
+
137
174
  export function LivestreamPoller({
138
175
  children,
139
176
  src,
@@ -143,12 +180,11 @@ export function LivestreamPoller({
143
180
  src: string;
144
181
  onTeleport?: (targetHandle: string, targetDID: string) => void;
145
182
  }) {
146
- // Websocket watcher is a sibling instead of a parent to avoid
147
- // re-rendering when the websocket does stuff
148
183
  return (
149
184
  <>
150
185
  <WebsocketWatcher src={src} />
151
186
  <TeleportWatcher onTeleport={onTeleport} />
187
+ <PinnedCommentWatcher />
152
188
  {children}
153
189
  </>
154
190
  );