@streamplace/components 0.9.0 → 0.9.1

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 (138) hide show
  1. package/dist/components/chat/chat-box.d.ts.map +1 -1
  2. package/dist/components/chat/chat-box.js +90 -34
  3. package/dist/components/chat/chat-box.js.map +1 -1
  4. package/dist/components/chat/chat-message.d.ts +4 -0
  5. package/dist/components/chat/chat-message.d.ts.map +1 -1
  6. package/dist/components/chat/chat-message.js +3 -2
  7. package/dist/components/chat/chat-message.js.map +1 -1
  8. package/dist/components/chat/chat.d.ts.map +1 -1
  9. package/dist/components/chat/chat.js +56 -3
  10. package/dist/components/chat/chat.js.map +1 -1
  11. package/dist/components/chat/emoji-suggestions.d.ts.map +1 -1
  12. package/dist/components/chat/emoji-suggestions.js +11 -11
  13. package/dist/components/chat/emoji-suggestions.js.map +1 -1
  14. package/dist/components/chat/mention-suggestions.d.ts.map +1 -1
  15. package/dist/components/chat/mention-suggestions.js +20 -19
  16. package/dist/components/chat/mention-suggestions.js.map +1 -1
  17. package/dist/components/chat/system-message.d.ts +5 -1
  18. package/dist/components/chat/system-message.d.ts.map +1 -1
  19. package/dist/components/chat/system-message.js +4 -4
  20. package/dist/components/chat/system-message.js.map +1 -1
  21. package/dist/components/mobile-player/shared.d.ts +1 -1
  22. package/dist/components/mobile-player/shared.d.ts.map +1 -1
  23. package/dist/components/mobile-player/shared.js +11 -10
  24. package/dist/components/mobile-player/shared.js.map +1 -1
  25. package/dist/components/mobile-player/ui/viewer-context-menu.d.ts +1 -1
  26. package/dist/components/mobile-player/ui/viewer-context-menu.d.ts.map +1 -1
  27. package/dist/components/mobile-player/ui/viewer-context-menu.js +60 -43
  28. package/dist/components/mobile-player/ui/viewer-context-menu.js.map +1 -1
  29. package/dist/components/stream-notification/index.d.ts +3 -0
  30. package/dist/components/stream-notification/index.d.ts.map +1 -0
  31. package/dist/components/stream-notification/index.js +9 -0
  32. package/dist/components/stream-notification/index.js.map +1 -0
  33. package/dist/components/stream-notification/stream-notification-manager.d.ts +36 -0
  34. package/dist/components/stream-notification/stream-notification-manager.d.ts.map +1 -0
  35. package/dist/components/stream-notification/stream-notification-manager.js +96 -0
  36. package/dist/components/stream-notification/stream-notification-manager.js.map +1 -0
  37. package/dist/components/stream-notification/stream-notification.d.ts +5 -0
  38. package/dist/components/stream-notification/stream-notification.d.ts.map +1 -0
  39. package/dist/components/stream-notification/stream-notification.js +146 -0
  40. package/dist/components/stream-notification/stream-notification.js.map +1 -0
  41. package/dist/components/stream-notification/teleport-notification.d.ts +8 -0
  42. package/dist/components/stream-notification/teleport-notification.d.ts.map +1 -0
  43. package/dist/components/stream-notification/teleport-notification.js +116 -0
  44. package/dist/components/stream-notification/teleport-notification.js.map +1 -0
  45. package/dist/components/ui/button.d.ts +1 -1
  46. package/dist/components/ui/button.d.ts.map +1 -1
  47. package/dist/components/ui/button.js +7 -0
  48. package/dist/components/ui/button.js.map +1 -1
  49. package/dist/components/ui/dialog.d.ts +2 -2
  50. package/dist/components/ui/dropdown.d.ts +4 -0
  51. package/dist/components/ui/dropdown.d.ts.map +1 -1
  52. package/dist/components/ui/dropdown.js +41 -15
  53. package/dist/components/ui/dropdown.js.map +1 -1
  54. package/dist/components/ui/index.d.ts +1 -0
  55. package/dist/components/ui/index.d.ts.map +1 -1
  56. package/dist/components/ui/index.js +1 -0
  57. package/dist/components/ui/index.js.map +1 -1
  58. package/dist/components/ui/portal.d.ts +2 -0
  59. package/dist/components/ui/portal.d.ts.map +1 -0
  60. package/dist/components/ui/portal.js +5 -0
  61. package/dist/components/ui/portal.js.map +1 -0
  62. package/dist/components/ui/portal.web.d.ts +11 -0
  63. package/dist/components/ui/portal.web.d.ts.map +1 -0
  64. package/dist/components/ui/portal.web.js +22 -0
  65. package/dist/components/ui/portal.web.js.map +1 -0
  66. package/dist/components/ui/resizeable.d.ts +2 -1
  67. package/dist/components/ui/resizeable.d.ts.map +1 -1
  68. package/dist/components/ui/resizeable.js +68 -26
  69. package/dist/components/ui/resizeable.js.map +1 -1
  70. package/dist/components/ui/text.d.ts +1 -1
  71. package/dist/components/ui/view.d.ts +3 -3
  72. package/dist/index.d.ts +2 -0
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +2 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/lib/slash-commands/teleport.d.ts +4 -0
  77. package/dist/lib/slash-commands/teleport.d.ts.map +1 -0
  78. package/dist/lib/slash-commands/teleport.js +110 -0
  79. package/dist/lib/slash-commands/teleport.js.map +1 -0
  80. package/dist/lib/slash-commands.d.ts +16 -0
  81. package/dist/lib/slash-commands.d.ts.map +1 -0
  82. package/dist/lib/slash-commands.js +46 -0
  83. package/dist/lib/slash-commands.js.map +1 -0
  84. package/dist/lib/stream-notifications.d.ts +13 -0
  85. package/dist/lib/stream-notifications.d.ts.map +1 -0
  86. package/dist/lib/stream-notifications.js +46 -0
  87. package/dist/lib/stream-notifications.js.map +1 -0
  88. package/dist/lib/system-messages.d.ts +4 -8
  89. package/dist/lib/system-messages.d.ts.map +1 -1
  90. package/dist/lib/system-messages.js +38 -2
  91. package/dist/lib/system-messages.js.map +1 -1
  92. package/dist/lib/theme/atoms.d.ts +193 -193
  93. package/dist/livestream-provider/index.d.ts +7 -2
  94. package/dist/livestream-provider/index.d.ts.map +1 -1
  95. package/dist/livestream-provider/index.js +72 -4
  96. package/dist/livestream-provider/index.js.map +1 -1
  97. package/dist/livestream-store/livestream-state.d.ts +4 -1
  98. package/dist/livestream-store/livestream-state.d.ts.map +1 -1
  99. package/dist/livestream-store/livestream-store.d.ts.map +1 -1
  100. package/dist/livestream-store/livestream-store.js +3 -0
  101. package/dist/livestream-store/livestream-store.js.map +1 -1
  102. package/dist/livestream-store/websocket-consumer.d.ts.map +1 -1
  103. package/dist/livestream-store/websocket-consumer.js +30 -43
  104. package/dist/livestream-store/websocket-consumer.js.map +1 -1
  105. package/dist/streamplace-store/index.d.ts +1 -0
  106. package/dist/streamplace-store/index.d.ts.map +1 -1
  107. package/dist/streamplace-store/index.js +1 -0
  108. package/dist/streamplace-store/index.js.map +1 -1
  109. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  110. package/package.json +4 -2
  111. package/src/components/chat/chat-box.tsx +126 -53
  112. package/src/components/chat/chat-message.tsx +1 -1
  113. package/src/components/chat/chat.tsx +79 -5
  114. package/src/components/chat/emoji-suggestions.tsx +27 -25
  115. package/src/components/chat/mention-suggestions.tsx +36 -33
  116. package/src/components/chat/system-message.tsx +14 -5
  117. package/src/components/mobile-player/shared.tsx +2 -1
  118. package/src/components/mobile-player/ui/viewer-context-menu.tsx +192 -166
  119. package/src/components/stream-notification/index.ts +5 -0
  120. package/src/components/stream-notification/stream-notification-manager.ts +140 -0
  121. package/src/components/stream-notification/stream-notification.tsx +227 -0
  122. package/src/components/stream-notification/teleport-notification.tsx +187 -0
  123. package/src/components/ui/button.tsx +7 -0
  124. package/src/components/ui/dropdown.tsx +96 -26
  125. package/src/components/ui/index.ts +1 -0
  126. package/src/components/ui/portal.tsx +1 -0
  127. package/src/components/ui/portal.web.tsx +37 -0
  128. package/src/components/ui/resizeable.tsx +89 -35
  129. package/src/index.tsx +3 -0
  130. package/src/lib/slash-commands/teleport.ts +136 -0
  131. package/src/lib/slash-commands.ts +65 -0
  132. package/src/lib/stream-notifications.ts +51 -0
  133. package/src/lib/system-messages.ts +52 -2
  134. package/src/livestream-provider/index.tsx +106 -3
  135. package/src/livestream-store/livestream-state.tsx +4 -0
  136. package/src/livestream-store/livestream-store.tsx +3 -0
  137. package/src/livestream-store/websocket-consumer.tsx +35 -54
  138. package/src/streamplace-store/index.tsx +1 -0
@@ -1,5 +1,5 @@
1
1
  import { ChevronUp } from "lucide-react-native";
2
- import { ComponentProps, useEffect } from "react";
2
+ import { ComponentProps, useEffect, useState } from "react";
3
3
  import { Dimensions } from "react-native";
4
4
  import {
5
5
  Gesture,
@@ -9,6 +9,7 @@ import {
9
9
  import Animated, {
10
10
  Extrapolation,
11
11
  interpolate,
12
+ runOnJS,
12
13
  useAnimatedStyle,
13
14
  useSharedValue,
14
15
  withSpring,
@@ -27,6 +28,7 @@ type ResizableChatSheetProps = {
27
28
  isPlayerRatioGreater: boolean;
28
29
  style?: ComponentProps<typeof AnimatedView>["style"];
29
30
  children?: React.ReactNode;
31
+ renderAbove?: (isCollapsed: boolean) => React.ReactNode;
30
32
  };
31
33
 
32
34
  const SPRING_CONFIG = { damping: 20, stiffness: 100 };
@@ -36,6 +38,7 @@ export function Resizable({
36
38
  isPlayerRatioGreater,
37
39
  style = {},
38
40
  children,
41
+ renderAbove,
39
42
  }: ResizableChatSheetProps) {
40
43
  const { slideKeyboard } = useKeyboardSlide();
41
44
  const { bottom: safeBottom } = useSafeAreaInsets();
@@ -45,13 +48,16 @@ export function Resizable({
45
48
 
46
49
  const sheetHeight = useSharedValue(MIN_HEIGHT);
47
50
  const startHeight = useSharedValue(MIN_HEIGHT);
51
+ const [isCollapsed, setIsCollapsed] = useState(true);
52
+ const wasCollapsed = useSharedValue(true);
48
53
 
49
54
  useEffect(() => {
50
55
  setTimeout(() => {
51
- sheetHeight.value = withSpring(
52
- startingPercentage ? startingPercentage * SCREEN_HEIGHT : MIN_HEIGHT,
53
- SPRING_CONFIG,
54
- );
56
+ const targetHeight = startingPercentage
57
+ ? startingPercentage * SCREEN_HEIGHT
58
+ : MIN_HEIGHT;
59
+ sheetHeight.value = withSpring(targetHeight, SPRING_CONFIG);
60
+ setIsCollapsed(targetHeight < COLLAPSE_HEIGHT);
55
61
  }, 1000);
56
62
  }, []);
57
63
 
@@ -65,8 +71,14 @@ export function Resizable({
65
71
  if (newHeight < MIN_HEIGHT) newHeight = MIN_HEIGHT;
66
72
  sheetHeight.value = newHeight;
67
73
 
68
- if (newHeight < COLLAPSE_HEIGHT) {
74
+ const nowCollapsed = newHeight < COLLAPSE_HEIGHT;
75
+ if (nowCollapsed && !wasCollapsed.value) {
69
76
  sheetHeight.value = withSpring(MIN_HEIGHT, SPRING_CONFIG);
77
+ wasCollapsed.value = true;
78
+ runOnJS(setIsCollapsed)(true);
79
+ } else if (!nowCollapsed && wasCollapsed.value) {
80
+ wasCollapsed.value = false;
81
+ runOnJS(setIsCollapsed)(false);
70
82
  }
71
83
  });
72
84
 
@@ -97,6 +109,19 @@ export function Resizable({
97
109
  ],
98
110
  }));
99
111
 
112
+ const aboveElementStyle = useAnimatedStyle(() => ({
113
+ // show inside area when not collapsed, and show outside area when collapsed
114
+ height: sheetHeight.value < COLLAPSE_HEIGHT ? 0 : sheetHeight.value,
115
+ transform: [
116
+ {
117
+ translateY:
118
+ sheetHeight.value < COLLAPSE_HEIGHT
119
+ ? withSpring(-120)
120
+ : withSpring(20),
121
+ },
122
+ ],
123
+ }));
124
+
100
125
  return (
101
126
  <>
102
127
  <Animated.View
@@ -111,10 +136,11 @@ export function Resizable({
111
136
  >
112
137
  <Pressable
113
138
  onPress={() => {
114
- sheetHeight.value =
115
- sheetHeight.value === MIN_HEIGHT
116
- ? withSpring(MAX_HEIGHT, SPRING_CONFIG)
117
- : withSpring(MIN_HEIGHT, SPRING_CONFIG);
139
+ const isCurrentlyCollapsed = sheetHeight.value === MIN_HEIGHT;
140
+ sheetHeight.value = isCurrentlyCollapsed
141
+ ? withSpring(MAX_HEIGHT, SPRING_CONFIG)
142
+ : withSpring(MIN_HEIGHT, SPRING_CONFIG);
143
+ setIsCollapsed(!isCurrentlyCollapsed);
118
144
  }}
119
145
  >
120
146
  <View
@@ -155,36 +181,64 @@ export function Resizable({
155
181
  ]}
156
182
  >
157
183
  <View style={[layout.flex.row, layout.flex.justifyCenter, h[2]]}>
158
- <GestureDetector gesture={panGesture}>
159
- <View
160
- // Make the touch area much larger, but keep the visible handle small
161
- style={{
162
- height: 30, // Large touch area
163
- width: 120, // Wide enough for thumbs
164
- alignItems: "center",
165
- justifyContent: "center",
166
- //backgroundColor: "rgba(0,255,255,0.1)",
167
- transform: [{ translateY: -30 }],
168
- }}
169
- >
184
+ <View style={{ alignItems: "center", width: "100%" }}>
185
+ <GestureDetector gesture={panGesture}>
170
186
  <View
171
- style={[
172
- w[32],
173
- {
174
- height: 6,
175
- backgroundColor: "#eeeeee66",
176
- borderRadius: 999,
177
-
178
- transform: [{ translateY: 5 }],
179
- },
180
- ]}
181
- />
182
- </View>
183
- </GestureDetector>
187
+ // Make the touch area much larger, but keep the visible handle small
188
+ style={{
189
+ height: 30, // Large touch area
190
+ width: 120, // Wide enough for thumbs
191
+ alignItems: "center",
192
+ justifyContent: "center",
193
+ //backgroundColor: "rgba(0,255,255,0.1)",
194
+ transform: [{ translateY: -30 }],
195
+ }}
196
+ >
197
+ <View
198
+ style={[
199
+ w[32],
200
+ {
201
+ height: 6,
202
+ backgroundColor: "#eeeeee66",
203
+ borderRadius: 999,
204
+
205
+ transform: [{ translateY: 5 }],
206
+ },
207
+ ]}
208
+ />
209
+ </View>
210
+ </GestureDetector>
211
+ </View>
184
212
  </View>
185
213
 
186
214
  {children}
187
215
  </AnimatedView>
216
+ <Animated.View
217
+ style={[
218
+ aboveElementStyle,
219
+ {
220
+ width: "100%",
221
+ pointerEvents: "none",
222
+ position: "absolute",
223
+ bottom: 0,
224
+ },
225
+ ]}
226
+ >
227
+ <View
228
+ style={{
229
+ pointerEvents: "auto",
230
+ width: "100%",
231
+ // hate doing it this way, but can't figure out
232
+ // how to make it size to content otherwise
233
+ minHeight: 50,
234
+ height: "100%",
235
+ maxHeight: 75,
236
+ flex: 0,
237
+ }}
238
+ >
239
+ {renderAbove?.(isCollapsed)}
240
+ </View>
241
+ </Animated.View>
188
242
  </>
189
243
  );
190
244
  }
package/src/index.tsx CHANGED
@@ -37,6 +37,9 @@ export * from "./components/chat/system-message";
37
37
  export { default as VideoRetry } from "./components/mobile-player/video-retry";
38
38
  export * from "./lib/system-messages";
39
39
 
40
+ export * from "./components/stream-notification";
41
+ export * from "./lib/stream-notifications";
42
+
40
43
  export * from "./utils/format-handle";
41
44
 
42
45
  export { DanmuOverlay } from "./components/danmu/danmu-overlay";
@@ -0,0 +1,136 @@
1
+ import { PlaceStreamLiveTeleport, StreamplaceAgent } from "streamplace";
2
+ import {
3
+ registerSlashCommand,
4
+ SlashCommandHandler,
5
+ SlashCommandResult,
6
+ } from "../slash-commands";
7
+
8
+ export async function deleteTeleport(
9
+ pdsAgent: StreamplaceAgent,
10
+ userDID: string,
11
+ uri: string,
12
+ ) {
13
+ const rkey = uri.split("/").pop();
14
+ if (!rkey) {
15
+ throw new Error("No rkey found in teleport URI");
16
+ }
17
+ return await pdsAgent.com.atproto.repo.deleteRecord({
18
+ repo: userDID,
19
+ collection: "place.stream.live.teleport",
20
+ rkey: rkey,
21
+ });
22
+ }
23
+
24
+ export function registerTeleportCommand(
25
+ pdsAgent: StreamplaceAgent,
26
+ userDID: string,
27
+ setActiveTeleportUri?: (uri: string | null) => void,
28
+ ) {
29
+ const teleportHandler: SlashCommandHandler = async (
30
+ args,
31
+ rawInput,
32
+ ): Promise<SlashCommandResult> => {
33
+ if (args.length === 0) {
34
+ return {
35
+ handled: true,
36
+ error: "Usage: /teleport @handle.bsky.social [duration_seconds]",
37
+ };
38
+ }
39
+
40
+ let targetHandle = args[0];
41
+
42
+ if (targetHandle.startsWith("@")) {
43
+ targetHandle = targetHandle.slice(1);
44
+ }
45
+
46
+ if (!targetHandle.includes(".")) {
47
+ return {
48
+ handled: true,
49
+ error: "Invalid handle format. Expected: handle.bsky.social",
50
+ };
51
+ }
52
+
53
+ let countdownSeconds = 10;
54
+ if (args.length > 1) {
55
+ const parsedDuration = parseInt(args[1], 10);
56
+ if (isNaN(parsedDuration)) {
57
+ return {
58
+ handled: true,
59
+ error: "Countdown must be a number (seconds)",
60
+ };
61
+ }
62
+ if (parsedDuration < 5 || parsedDuration > 300) {
63
+ return {
64
+ handled: true,
65
+ error: "Countdown must be between 5 seconds and 5 minutes",
66
+ };
67
+ }
68
+ countdownSeconds = parsedDuration;
69
+ }
70
+
71
+ let targetDID: string;
72
+ try {
73
+ const resolution = await pdsAgent.resolveHandle({
74
+ handle: targetHandle,
75
+ });
76
+ targetDID = resolution.data.did;
77
+ } catch (err) {
78
+ return {
79
+ handled: true,
80
+ error: `Could not resolve handle: ${targetHandle}`,
81
+ };
82
+ }
83
+
84
+ if (targetDID === userDID) {
85
+ return {
86
+ handled: true,
87
+ error: "You cannot teleport to yourself",
88
+ };
89
+ }
90
+
91
+ const startsAt = new Date(
92
+ Date.now() + countdownSeconds * 1000,
93
+ ).toISOString();
94
+
95
+ const record: PlaceStreamLiveTeleport.Record = {
96
+ $type: "place.stream.live.teleport",
97
+ streamer: targetDID,
98
+ startsAt,
99
+ countdownSeconds,
100
+ };
101
+
102
+ try {
103
+ const result = await pdsAgent.com.atproto.repo.createRecord({
104
+ repo: userDID,
105
+ collection: "place.stream.live.teleport",
106
+ record,
107
+ });
108
+
109
+ // store the URI in the livestream store
110
+ if (setActiveTeleportUri) {
111
+ setActiveTeleportUri(result.data.uri);
112
+ }
113
+
114
+ return { handled: true };
115
+ } catch (err) {
116
+ return {
117
+ handled: true,
118
+ error: err instanceof Error ? err.message : "Failed to create teleport",
119
+ };
120
+ }
121
+ };
122
+
123
+ registerSlashCommand({
124
+ name: "teleport",
125
+ description: "Start a teleport to another streamer",
126
+ usage: "/teleport @handle.bsky.social [duration_seconds]",
127
+ handler: teleportHandler,
128
+ });
129
+
130
+ registerSlashCommand({
131
+ name: "tp",
132
+ description: "Start a teleport to another streamer (alias for /teleport)",
133
+ usage: "/tp @handle.bsky.social [duration_seconds]",
134
+ handler: teleportHandler,
135
+ });
136
+ }
@@ -0,0 +1,65 @@
1
+ export interface SlashCommandResult {
2
+ handled: boolean;
3
+ error?: string;
4
+ }
5
+
6
+ export type SlashCommandHandler = (
7
+ args: string[],
8
+ rawInput: string,
9
+ ) => Promise<SlashCommandResult>;
10
+
11
+ export interface SlashCommand {
12
+ name: string;
13
+ description: string;
14
+ usage: string;
15
+ handler: SlashCommandHandler;
16
+ }
17
+
18
+ const commands = new Map<string, SlashCommand>();
19
+
20
+ export function registerSlashCommand(command: SlashCommand) {
21
+ commands.set(command.name, command);
22
+ }
23
+
24
+ export function unregisterSlashCommand(name: string) {
25
+ commands.delete(name);
26
+ }
27
+
28
+ export async function handleSlashCommand(
29
+ input: string,
30
+ ): Promise<SlashCommandResult> {
31
+ const trimmed = input.trim();
32
+ if (!trimmed.startsWith("/")) {
33
+ return { handled: false };
34
+ }
35
+
36
+ const parts = trimmed.slice(1).split(/\s+/);
37
+ const commandName = parts[0]?.toLowerCase();
38
+ const args = parts.slice(1);
39
+
40
+ if (!commandName) {
41
+ return { handled: false };
42
+ }
43
+
44
+ const command = commands.get(commandName);
45
+ if (!command) {
46
+ return {
47
+ // for now - return false
48
+ handled: false,
49
+ error: `Unknown command: /${commandName}`,
50
+ };
51
+ }
52
+
53
+ try {
54
+ return await command.handler(args, trimmed);
55
+ } catch (err) {
56
+ return {
57
+ handled: true,
58
+ error: err instanceof Error ? err.message : "Command failed",
59
+ };
60
+ }
61
+ }
62
+
63
+ export function getRegisteredCommands(): SlashCommand[] {
64
+ return Array.from(commands.values());
65
+ }
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { streamNotification } from "../components/stream-notification";
3
+ import { TeleportNotification } from "../components/stream-notification/teleport-notification";
4
+
5
+ export const StreamNotifications = {
6
+ teleport: (params: {
7
+ targetHandle: string;
8
+ targetDID: string;
9
+ countdown: number;
10
+ canCancel: boolean;
11
+ onDismiss?: (reason?: "user" | "auto") => void;
12
+ }) => {
13
+ streamNotification.show({
14
+ id: "teleport",
15
+ render: (isExiting, onDismiss, startTime) => {
16
+ return React.createElement(TeleportNotification, {
17
+ targetHandle: params.targetHandle,
18
+ countdown: params.countdown,
19
+ canCancel: params.canCancel,
20
+ startTime: startTime,
21
+ onDismiss: onDismiss,
22
+ });
23
+ },
24
+ duration: 0, // manually dismissed by countdown or user cancel
25
+ variant: "warning",
26
+ onDismiss: params.onDismiss,
27
+ });
28
+ },
29
+
30
+ teleportCancelled: () => {
31
+ streamNotification.hide("teleport");
32
+ },
33
+
34
+ teleportNow: (targetHandle: string) => {
35
+ streamNotification.show({
36
+ id: "teleport-now",
37
+ message: `Teleporting to @${targetHandle}...`,
38
+ duration: 2,
39
+ variant: "info",
40
+ });
41
+ },
42
+
43
+ activate: (message: string) => {
44
+ streamNotification.show({
45
+ id: "stream-activate",
46
+ message: message,
47
+ duration: 3,
48
+ variant: "info",
49
+ });
50
+ },
51
+ };
@@ -4,6 +4,7 @@ export enum SystemMessageType {
4
4
  stream_start = "stream_start",
5
5
  stream_end = "stream_end",
6
6
  notification = "notification",
7
+ command_error = "command_error",
7
8
  }
8
9
 
9
10
  export interface SystemMessageMetadata {
@@ -22,6 +23,8 @@ export interface SystemMessageMetadata {
22
23
  * @param metadata Optional metadata for the message
23
24
  * @returns A properly formatted ChatMessageViewHydrated object
24
25
  */
26
+ let systemMessageCounter = 0;
27
+
25
28
  export const createSystemMessage = (
26
29
  type: SystemMessageType,
27
30
  text: string,
@@ -29,10 +32,11 @@ export const createSystemMessage = (
29
32
  date: Date = new Date(),
30
33
  ): ChatMessageViewHydrated => {
31
34
  const now = date;
35
+ const uniqueId = `${now.getTime()}-${systemMessageCounter++}`;
32
36
 
33
37
  return {
34
- uri: `at://did:sys:system/place.stream.chat.message/${now.getTime()}`,
35
- cid: `system-${now.getTime()}`,
38
+ uri: `at://did:sys:system/place.stream.chat.message/${uniqueId}`,
39
+ cid: `system-${uniqueId}`,
36
40
  author: {
37
41
  did: "did:sys:system",
38
42
  handle: type, // Use handle to specify the type of system message
@@ -73,8 +77,54 @@ export const SystemMessages = {
73
77
  { duration },
74
78
  ),
75
79
 
80
+ teleportArrival: (
81
+ streamerName: string,
82
+ streamerDid: string,
83
+ count: number,
84
+ chatProfile?: any,
85
+ ): ChatMessageViewHydrated => {
86
+ const text =
87
+ count > 0
88
+ ? `${count} viewer${count !== 1 ? "s" : ""} teleported from ${streamerName}'s stream! Say hi!`
89
+ : `Someone teleported from ${streamerName}'s stream! Say hi!`;
90
+ const message = createSystemMessage(SystemMessageType.notification, text, {
91
+ streamerName,
92
+ count,
93
+ });
94
+
95
+ // create a mention facet for the streamer name so it gets colored using existing mention rendering
96
+ if (chatProfile && streamerDid) {
97
+ const nameStart = text.indexOf(streamerName);
98
+
99
+ // encode byte positions
100
+ const encoder = new TextEncoder();
101
+ const byteStart = encoder.encode(text.substring(0, nameStart)).length;
102
+ const byteEnd = byteStart + encoder.encode(streamerName).length;
103
+
104
+ message.record.facets = [
105
+ {
106
+ index: {
107
+ byteStart,
108
+ byteEnd,
109
+ },
110
+ features: [
111
+ {
112
+ $type: "app.bsky.richtext.facet#mention",
113
+ did: streamerDid,
114
+ },
115
+ ],
116
+ },
117
+ ];
118
+ }
119
+
120
+ return message;
121
+ },
122
+
76
123
  notification: (message: string): ChatMessageViewHydrated =>
77
124
  createSystemMessage(SystemMessageType.notification, message),
125
+
126
+ commandError: (message: string): ChatMessageViewHydrated =>
127
+ createSystemMessage(SystemMessageType.command_error, message),
78
128
  };
79
129
 
80
130
  /**
@@ -1,14 +1,24 @@
1
- import React, { useContext, useRef } from "react";
2
- import { LivestreamContext, makeLivestreamStore } from "../livestream-store";
1
+ import React, { useContext, useEffect, useRef } from "react";
2
+ import { useAvatars } from "../hooks";
3
+ import { deleteTeleport } from "../lib/slash-commands/teleport";
4
+ import { StreamNotifications } from "../lib/stream-notifications";
5
+ import {
6
+ LivestreamContext,
7
+ makeLivestreamStore,
8
+ useLivestreamStore,
9
+ } from "../livestream-store";
10
+ import { useDID, usePDSAgent } from "../streamplace-store";
3
11
  import { useLivestreamWebsocket } from "./websocket";
4
12
 
5
13
  export function LivestreamProvider({
6
14
  children,
7
15
  src,
16
+ onTeleport,
8
17
  ignoreOuterContext = false,
9
18
  }: {
10
19
  children: React.ReactNode;
11
20
  src: string;
21
+ onTeleport?: (targetHandle: string, targetDID: string) => void;
12
22
  ignoreOuterContext?: boolean;
13
23
  }) {
14
24
  const context = useContext(LivestreamContext);
@@ -24,7 +34,9 @@ export function LivestreamProvider({
24
34
  (window as any).livestreamStore = store;
25
35
  return (
26
36
  <LivestreamContext.Provider value={{ store: store }}>
27
- <LivestreamPoller src={src}>{children}</LivestreamPoller>
37
+ <LivestreamPoller src={src} onTeleport={onTeleport}>
38
+ {children}
39
+ </LivestreamPoller>
28
40
  </LivestreamContext.Provider>
29
41
  );
30
42
  }
@@ -34,18 +46,109 @@ export function WebsocketWatcher({ src }: { src: string }) {
34
46
  return <></>;
35
47
  }
36
48
 
49
+ export function TeleportWatcher({
50
+ onTeleport,
51
+ }: {
52
+ onTeleport?: (targetHandle: string, targetDID: string) => void;
53
+ }) {
54
+ const activeTeleport = useLivestreamStore((state) => state.activeTeleport);
55
+ const activeTeleportUri = useLivestreamStore(
56
+ (state) => state.activeTeleportUri,
57
+ );
58
+ const profile = useAvatars(activeTeleport ? [activeTeleport.streamer] : []);
59
+ const livestreamProfile = useLivestreamStore((state) => state.profile);
60
+ const pdsAgent = usePDSAgent();
61
+ const userDID = useDID();
62
+ const prevActiveTeleportRef = useRef(activeTeleport);
63
+
64
+ useEffect(() => {
65
+ if (!activeTeleport || !profile[activeTeleport.streamer]) return;
66
+
67
+ const startsAt = new Date(activeTeleport.startsAt);
68
+ const now = new Date();
69
+ const countdown = Math.max(
70
+ 0,
71
+ Math.floor((startsAt.getTime() - now.getTime()) / 1000),
72
+ );
73
+
74
+ // resolve the DID to a handle for display
75
+ const targetHandle =
76
+ profile[activeTeleport.streamer]?.handle || activeTeleport.streamer;
77
+
78
+ // check if the current user is the streamer of the current livestream
79
+ const canCancel = livestreamProfile?.did === userDID;
80
+
81
+ StreamNotifications.teleport({
82
+ targetHandle: targetHandle,
83
+ targetDID: activeTeleport.streamer,
84
+ countdown: countdown,
85
+ canCancel: canCancel,
86
+ onDismiss: async (reason) => {
87
+ console.log(
88
+ "🔍 StreamNotifications.onDismiss called with reason:",
89
+ reason,
90
+ );
91
+ if (reason === "user" && activeTeleportUri && pdsAgent && userDID) {
92
+ try {
93
+ await deleteTeleport(pdsAgent, userDID, activeTeleportUri);
94
+ } catch (err) {
95
+ console.error("Failed to delete teleport:", err);
96
+ }
97
+ }
98
+ if (reason === "auto" && onTeleport) {
99
+ console.log(
100
+ "🔍 Calling onTeleport with:",
101
+ targetHandle,
102
+ activeTeleport.streamer,
103
+ );
104
+ onTeleport(targetHandle, activeTeleport.streamer);
105
+ } else if (reason === "auto" && !onTeleport) {
106
+ console.log("🔍 onTeleport is not defined!");
107
+ } else if (reason === "auto") {
108
+ console.log(
109
+ "🔍 Reason is auto but teleport function not called for unknown reason",
110
+ );
111
+ }
112
+ },
113
+ });
114
+ }, [
115
+ activeTeleport,
116
+ activeTeleportUri,
117
+ profile,
118
+ onTeleport,
119
+ pdsAgent,
120
+ userDID,
121
+ ]);
122
+
123
+ useEffect(() => {
124
+ if (
125
+ prevActiveTeleportRef.current &&
126
+ !activeTeleport &&
127
+ !activeTeleportUri
128
+ ) {
129
+ StreamNotifications.teleportCancelled();
130
+ }
131
+ prevActiveTeleportRef.current = activeTeleport;
132
+ }, [activeTeleport, activeTeleportUri]);
133
+
134
+ return <></>;
135
+ }
136
+
37
137
  export function LivestreamPoller({
38
138
  children,
39
139
  src,
140
+ onTeleport,
40
141
  }: {
41
142
  children: React.ReactNode;
42
143
  src: string;
144
+ onTeleport?: (targetHandle: string, targetDID: string) => void;
43
145
  }) {
44
146
  // Websocket watcher is a sibling instead of a parent to avoid
45
147
  // re-rendering when the websocket does stuff
46
148
  return (
47
149
  <>
48
150
  <WebsocketWatcher src={src} />
151
+ <TeleportWatcher onTeleport={onTeleport} />
49
152
  {children}
50
153
  </>
51
154
  );
@@ -3,6 +3,7 @@ import {
3
3
  ChatMessageViewHydrated,
4
4
  LivestreamViewHydrated,
5
5
  PlaceStreamDefs,
6
+ PlaceStreamLiveTeleport,
6
7
  PlaceStreamModerationPermission,
7
8
  PlaceStreamSegment,
8
9
  } from "streamplace";
@@ -22,6 +23,9 @@ export interface LivestreamState {
22
23
  replyToMessage: ChatMessageViewHydrated | null;
23
24
  streamKey: string | null;
24
25
  setStreamKey: (key: string | null) => void;
26
+ activeTeleport: PlaceStreamLiveTeleport.Record | null;
27
+ activeTeleportUri: string | null;
28
+ setActiveTeleportUri: (uri: string | null) => void;
25
29
  websocketConnected: boolean;
26
30
  hasReceivedSegment: boolean;
27
31
  moderationPermissions: PlaceStreamModerationPermission.Record[];