@streamplace/components 0.7.2 → 0.7.7
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.
- package/dist/components/chat/chat-box.js +212 -24
- package/dist/components/chat/chat-message.js +5 -5
- package/dist/components/chat/chat.js +83 -5
- package/dist/components/chat/emoji-suggestions.js +35 -0
- package/dist/components/chat/mod-view.js +59 -8
- package/dist/components/chat/system-message.js +19 -0
- package/dist/components/icons/bluesky-icon.js +9 -0
- package/dist/components/keep-awake.js +7 -0
- package/dist/components/keep-awake.native.js +16 -0
- package/dist/components/mobile-player/fullscreen.js +2 -1
- package/dist/components/mobile-player/fullscreen.native.js +3 -3
- package/dist/components/mobile-player/player.js +15 -30
- package/dist/components/mobile-player/ui/index.js +2 -0
- package/dist/components/mobile-player/ui/report-modal.js +90 -0
- package/dist/components/mobile-player/ui/streamer-loading-overlay.js +104 -0
- package/dist/components/mobile-player/ui/viewer-context-menu.js +20 -1
- package/dist/components/mobile-player/ui/viewer-loading-overlay.js +49 -0
- package/dist/components/mobile-player/use-webrtc.js +7 -1
- package/dist/components/mobile-player/video-retry.js +29 -0
- package/dist/components/mobile-player/video.js +84 -9
- package/dist/components/mobile-player/video.native.js +24 -10
- package/dist/components/share/sharesheet.js +91 -0
- package/dist/components/ui/dialog.js +1 -1
- package/dist/components/ui/dropdown.js +6 -6
- package/dist/components/ui/index.js +2 -0
- package/dist/components/ui/primitives/modal.js +0 -1
- package/dist/components/ui/resizeable.js +20 -11
- package/dist/components/ui/slider.js +5 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/usePointerDevice.js +71 -0
- package/dist/index.js +10 -3
- package/dist/lib/system-messages.js +101 -0
- package/dist/livestream-store/chat.js +111 -18
- package/dist/livestream-store/livestream-store.js +3 -0
- package/dist/livestream-store/problems.js +76 -0
- package/dist/livestream-store/websocket-consumer.js +39 -4
- package/dist/player-store/player-store.js +33 -4
- package/dist/streamplace-store/block.js +51 -12
- package/dist/streamplace-store/stream.js +44 -23
- package/dist/ui/index.js +79 -0
- package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
- package/node-compile-cache/v22.15.0-x64-efe9a9df-0/56540125 +0 -0
- package/node-compile-cache/{v22.15.0-x64-92db9086-0 → v22.15.0-x64-efe9a9df-0}/67b1eb60 +0 -0
- package/node-compile-cache/{v22.15.0-x64-92db9086-0 → v22.15.0-x64-efe9a9df-0}/7c275f90 +0 -0
- package/package.json +6 -2
- package/src/components/chat/chat-box.tsx +295 -25
- package/src/components/chat/chat-message.tsx +6 -7
- package/src/components/chat/chat.tsx +192 -41
- package/src/components/chat/emoji-suggestions.tsx +94 -0
- package/src/components/chat/mod-view.tsx +119 -40
- package/src/components/chat/system-message.tsx +38 -0
- package/src/components/icons/bluesky-icon.tsx +9 -0
- package/src/components/keep-awake.native.tsx +13 -0
- package/src/components/keep-awake.tsx +3 -0
- package/src/components/mobile-player/fullscreen.native.tsx +12 -3
- package/src/components/mobile-player/fullscreen.tsx +10 -3
- package/src/components/mobile-player/player.tsx +28 -36
- package/src/components/mobile-player/props.tsx +1 -0
- package/src/components/mobile-player/ui/index.ts +2 -0
- package/src/components/mobile-player/ui/report-modal.tsx +195 -0
- package/src/components/mobile-player/ui/streamer-loading-overlay.tsx +154 -0
- package/src/components/mobile-player/ui/viewer-context-menu.tsx +31 -3
- package/src/components/mobile-player/ui/viewer-loading-overlay.tsx +66 -0
- package/src/components/mobile-player/use-webrtc.tsx +10 -2
- package/src/components/mobile-player/video-retry.tsx +28 -0
- package/src/components/mobile-player/video.native.tsx +24 -10
- package/src/components/mobile-player/video.tsx +100 -21
- package/src/components/share/sharesheet.tsx +185 -0
- package/src/components/ui/dialog.tsx +1 -1
- package/src/components/ui/dropdown.tsx +13 -13
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/primitives/modal.tsx +0 -1
- package/src/components/ui/resizeable.tsx +26 -15
- package/src/components/ui/slider.tsx +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/usePointerDevice.ts +89 -0
- package/src/index.tsx +11 -2
- package/src/lib/system-messages.ts +135 -0
- package/src/livestream-store/chat.tsx +145 -17
- package/src/livestream-store/livestream-state.tsx +10 -0
- package/src/livestream-store/livestream-store.tsx +3 -0
- package/src/livestream-store/problems.tsx +96 -0
- package/src/livestream-store/websocket-consumer.tsx +44 -4
- package/src/player-store/player-state.tsx +25 -4
- package/src/player-store/player-store.tsx +43 -5
- package/src/streamplace-store/block.tsx +55 -13
- package/src/streamplace-store/stream.tsx +66 -35
- package/src/ui/index.ts +86 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/node-compile-cache/v22.15.0-x64-92db9086-0/37be0eec +0 -0
- package/node-compile-cache/v22.15.0-x64-92db9086-0/56540125 +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
|
|
4
|
+
export interface PointerDevice {
|
|
5
|
+
hasHover: boolean;
|
|
6
|
+
hasFinePointer: boolean;
|
|
7
|
+
isMouseDriven: boolean;
|
|
8
|
+
isTouchDriven: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hook to detect if the device is primarily mouse-driven vs touch-driven
|
|
13
|
+
* Uses CSS media queries to detect hover and pointer capabilities
|
|
14
|
+
*/
|
|
15
|
+
export function usePointerDevice(): PointerDevice {
|
|
16
|
+
const [pointerDevice, setPointerDevice] = useState<PointerDevice>(() => {
|
|
17
|
+
// Default values for non-web platforms
|
|
18
|
+
if (Platform.OS !== "web") {
|
|
19
|
+
return {
|
|
20
|
+
hasHover: false,
|
|
21
|
+
hasFinePointer: false,
|
|
22
|
+
isMouseDriven: false,
|
|
23
|
+
isTouchDriven: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Initial web detection
|
|
28
|
+
if (typeof window !== "undefined" && window.matchMedia) {
|
|
29
|
+
const hasHover = window.matchMedia("(hover: hover)").matches;
|
|
30
|
+
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
hasHover,
|
|
34
|
+
hasFinePointer,
|
|
35
|
+
isMouseDriven: hasHover && hasFinePointer,
|
|
36
|
+
isTouchDriven: !hasHover || !hasFinePointer,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Fallback for SSR or environments without matchMedia
|
|
41
|
+
return {
|
|
42
|
+
hasHover: false,
|
|
43
|
+
hasFinePointer: false,
|
|
44
|
+
isMouseDriven: false,
|
|
45
|
+
isTouchDriven: true,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
// Only run on web platforms
|
|
51
|
+
if (
|
|
52
|
+
Platform.OS !== "web" ||
|
|
53
|
+
typeof window === "undefined" ||
|
|
54
|
+
!window.matchMedia
|
|
55
|
+
) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const hoverQuery = window.matchMedia("(hover: hover)");
|
|
60
|
+
const pointerQuery = window.matchMedia("(pointer: fine)");
|
|
61
|
+
|
|
62
|
+
const updatePointerDevice = () => {
|
|
63
|
+
const hasHover = hoverQuery.matches;
|
|
64
|
+
const hasFinePointer = pointerQuery.matches;
|
|
65
|
+
|
|
66
|
+
setPointerDevice({
|
|
67
|
+
hasHover,
|
|
68
|
+
hasFinePointer,
|
|
69
|
+
isMouseDriven: hasHover && hasFinePointer,
|
|
70
|
+
isTouchDriven: !hasHover || !hasFinePointer,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Set up listeners for media query changes
|
|
75
|
+
hoverQuery.addEventListener("change", updatePointerDevice);
|
|
76
|
+
pointerQuery.addEventListener("change", updatePointerDevice);
|
|
77
|
+
|
|
78
|
+
// Initial update
|
|
79
|
+
updatePointerDevice();
|
|
80
|
+
|
|
81
|
+
// Cleanup
|
|
82
|
+
return () => {
|
|
83
|
+
hoverQuery.removeEventListener("change", updatePointerDevice);
|
|
84
|
+
pointerQuery.removeEventListener("change", updatePointerDevice);
|
|
85
|
+
};
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
return pointerDevice;
|
|
89
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -18,10 +18,19 @@ export * as ui from "./components/ui";
|
|
|
18
18
|
|
|
19
19
|
export * from "./components/ui";
|
|
20
20
|
|
|
21
|
-
export * as
|
|
22
|
-
export * as atoms from "./lib/theme/atoms";
|
|
21
|
+
export * as zero from "./ui";
|
|
23
22
|
|
|
24
23
|
export * from "./hooks";
|
|
25
24
|
|
|
25
|
+
// Theme system exports
|
|
26
|
+
export * from "./lib/theme";
|
|
27
|
+
|
|
26
28
|
export * from "./components/chat/chat";
|
|
27
29
|
export * from "./components/chat/chat-box";
|
|
30
|
+
export * from "./components/chat/system-message";
|
|
31
|
+
export { default as VideoRetry } from "./components/mobile-player/video-retry";
|
|
32
|
+
export * from "./lib/system-messages";
|
|
33
|
+
|
|
34
|
+
export * from "./components/share/sharesheet";
|
|
35
|
+
|
|
36
|
+
export * from "./components/keep-awake";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { ChatMessageViewHydrated } from "streamplace";
|
|
2
|
+
|
|
3
|
+
export enum SystemMessageType {
|
|
4
|
+
stream_start = "stream_start",
|
|
5
|
+
stream_end = "stream_end",
|
|
6
|
+
notification = "notification",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SystemMessageMetadata {
|
|
10
|
+
username?: string;
|
|
11
|
+
action?: string;
|
|
12
|
+
count?: number;
|
|
13
|
+
duration?: string;
|
|
14
|
+
reason?: string;
|
|
15
|
+
streamerName?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a system message with the proper structure
|
|
20
|
+
* @param type The type of system message
|
|
21
|
+
* @param text The message text
|
|
22
|
+
* @param metadata Optional metadata for the message
|
|
23
|
+
* @returns A properly formatted ChatMessageViewHydrated object
|
|
24
|
+
*/
|
|
25
|
+
export const createSystemMessage = (
|
|
26
|
+
type: SystemMessageType,
|
|
27
|
+
text: string,
|
|
28
|
+
metadata?: SystemMessageMetadata,
|
|
29
|
+
date: Date = new Date(),
|
|
30
|
+
): ChatMessageViewHydrated => {
|
|
31
|
+
const now = date;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
uri: `at://did:sys:system/place.stream.chat.message/${now.getTime()}`,
|
|
35
|
+
cid: `system-${now.getTime()}`,
|
|
36
|
+
author: {
|
|
37
|
+
did: "did:sys:system",
|
|
38
|
+
handle: type, // Use handle to specify the type of system message
|
|
39
|
+
},
|
|
40
|
+
record: {
|
|
41
|
+
text,
|
|
42
|
+
createdAt: now.toISOString(),
|
|
43
|
+
streamer: "system",
|
|
44
|
+
$type: "place.stream.chat.message",
|
|
45
|
+
},
|
|
46
|
+
indexedAt: now.toISOString(),
|
|
47
|
+
chatProfile: {
|
|
48
|
+
color: { red: 128, green: 128, blue: 128 }, // Gray color for system messages
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* System message factory functions for common scenarios
|
|
55
|
+
*/
|
|
56
|
+
export const SystemMessages = {
|
|
57
|
+
streamStart: (streamerName: string): ChatMessageViewHydrated =>
|
|
58
|
+
createSystemMessage(
|
|
59
|
+
SystemMessageType.stream_start,
|
|
60
|
+
`Now streaming - ${streamerName}`,
|
|
61
|
+
{
|
|
62
|
+
streamerName,
|
|
63
|
+
},
|
|
64
|
+
),
|
|
65
|
+
|
|
66
|
+
// technically, streams can't 'end' on Streamplace
|
|
67
|
+
// possibly we could use deleting or editing streams (`endedAt` param) for this?
|
|
68
|
+
streamEnd: (duration?: string): ChatMessageViewHydrated =>
|
|
69
|
+
createSystemMessage(
|
|
70
|
+
SystemMessageType.stream_end,
|
|
71
|
+
duration ? `Stream has ended. Duration: ${duration}` : "Stream has ended",
|
|
72
|
+
{ duration },
|
|
73
|
+
),
|
|
74
|
+
|
|
75
|
+
notification: (message: string): ChatMessageViewHydrated =>
|
|
76
|
+
createSystemMessage(SystemMessageType.notification, message),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Checks if a message is a system message
|
|
81
|
+
* @param message The message to check
|
|
82
|
+
* @returns True if the message is a system message
|
|
83
|
+
*/
|
|
84
|
+
export const isSystemMessage = (message: ChatMessageViewHydrated): boolean => {
|
|
85
|
+
return message.author.did === "did:sys:system";
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Gets the system message type from a message
|
|
90
|
+
* @param message The message to check
|
|
91
|
+
* @returns The system message type or null if not a system message
|
|
92
|
+
*/
|
|
93
|
+
export const getSystemMessageType = (
|
|
94
|
+
message: ChatMessageViewHydrated,
|
|
95
|
+
): SystemMessageType | null => {
|
|
96
|
+
if (!isSystemMessage(message)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return message.author.handle as SystemMessageType;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parses metadata from a system message based on its type
|
|
104
|
+
* @param message The system message to parse
|
|
105
|
+
* @returns The parsed metadata
|
|
106
|
+
*/
|
|
107
|
+
export const parseSystemMessageMetadata = (
|
|
108
|
+
message: ChatMessageViewHydrated,
|
|
109
|
+
): SystemMessageMetadata => {
|
|
110
|
+
const metadata: SystemMessageMetadata = {};
|
|
111
|
+
const type = getSystemMessageType(message);
|
|
112
|
+
const text = message.record.text;
|
|
113
|
+
|
|
114
|
+
if (!type) return metadata;
|
|
115
|
+
|
|
116
|
+
switch (type) {
|
|
117
|
+
case "stream_end": {
|
|
118
|
+
const durationMatch = text.match(/Duration:\s*(\d+:\d+(?::\d+)?)/);
|
|
119
|
+
if (durationMatch) {
|
|
120
|
+
metadata.duration = durationMatch[1];
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case "stream_start": {
|
|
126
|
+
const streamerMatch = text.match(/^(.+?)\s+is now live!/);
|
|
127
|
+
if (streamerMatch) {
|
|
128
|
+
metadata.streamerName = streamerMatch[1];
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return metadata;
|
|
135
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RichText } from "@atproto/api";
|
|
1
|
+
import { ComAtprotoModerationCreateReport, RichText } from "@atproto/api";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
3
|
import {
|
|
4
4
|
ChatMessageViewHydrated,
|
|
@@ -23,6 +23,27 @@ export const useSetReplyToMessage = () => {
|
|
|
23
23
|
);
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
export const usePendingHides = () =>
|
|
27
|
+
useLivestreamStore((state) => state.pendingHides);
|
|
28
|
+
|
|
29
|
+
export const useAddPendingHide = () => {
|
|
30
|
+
const store = getStoreFromContext();
|
|
31
|
+
return useCallback(
|
|
32
|
+
(messageUri: string) => {
|
|
33
|
+
const state = store.getState();
|
|
34
|
+
if (!state.pendingHides.includes(messageUri)) {
|
|
35
|
+
const newPendingHides = [...state.pendingHides, messageUri];
|
|
36
|
+
const newState = reduceChat(state, [], [], [messageUri]);
|
|
37
|
+
store.setState({
|
|
38
|
+
...newState,
|
|
39
|
+
pendingHides: newPendingHides,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
[store],
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
26
47
|
export type NewChatMessage = {
|
|
27
48
|
text: string;
|
|
28
49
|
reply?: {
|
|
@@ -87,7 +108,7 @@ export const useCreateChatMessage = () => {
|
|
|
87
108
|
chatProfile: chatProfile || undefined,
|
|
88
109
|
};
|
|
89
110
|
|
|
90
|
-
state = reduceChat(state, [localChat], []);
|
|
111
|
+
state = reduceChat(state, [localChat], [], []);
|
|
91
112
|
store.setState(state);
|
|
92
113
|
|
|
93
114
|
await pdsAgent.com.atproto.repo.createRecord({
|
|
@@ -109,7 +130,9 @@ const buildSortedChatList = (
|
|
|
109
130
|
const bTime = parseInt(b.split("-")[0], 10);
|
|
110
131
|
return bTime - aTime;
|
|
111
132
|
});
|
|
112
|
-
return sortedKeys
|
|
133
|
+
return sortedKeys
|
|
134
|
+
.map((key) => chatIndex[key])
|
|
135
|
+
.filter((msg) => !removedKeys.has(msg.uri));
|
|
113
136
|
};
|
|
114
137
|
|
|
115
138
|
const profileIsDifferent = (
|
|
@@ -138,8 +161,13 @@ export const reduceChatIncremental = (
|
|
|
138
161
|
state: LivestreamState,
|
|
139
162
|
newMessages: ChatMessageViewHydrated[],
|
|
140
163
|
blocks: PlaceStreamDefs.BlockView[],
|
|
164
|
+
hideUris: string[] = [],
|
|
141
165
|
): LivestreamState => {
|
|
142
|
-
if (
|
|
166
|
+
if (
|
|
167
|
+
newMessages.length === 0 &&
|
|
168
|
+
blocks.length === 0 &&
|
|
169
|
+
hideUris.length === 0
|
|
170
|
+
) {
|
|
143
171
|
return state;
|
|
144
172
|
}
|
|
145
173
|
|
|
@@ -148,6 +176,17 @@ export const reduceChatIncremental = (
|
|
|
148
176
|
let hasChanges = false;
|
|
149
177
|
const removedKeys = new Set<string>();
|
|
150
178
|
|
|
179
|
+
console.log("newMessages", newMessages);
|
|
180
|
+
|
|
181
|
+
for (const msg of newMessages) {
|
|
182
|
+
if (msg.deleted) {
|
|
183
|
+
hasChanges = true;
|
|
184
|
+
console.log("deleted", msg.uri);
|
|
185
|
+
removedKeys.add(msg.uri);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
newMessages = newMessages.filter((msg) => msg.deleted !== true);
|
|
189
|
+
|
|
151
190
|
// handle blocks
|
|
152
191
|
if (blocks.length > 0) {
|
|
153
192
|
const blockedDIDs = new Set(blocks.map((block) => block.record.subject));
|
|
@@ -160,17 +199,32 @@ export const reduceChatIncremental = (
|
|
|
160
199
|
}
|
|
161
200
|
}
|
|
162
201
|
|
|
202
|
+
if (hideUris.length > 0) {
|
|
203
|
+
for (const [key, message] of Object.entries(newChatIndex)) {
|
|
204
|
+
if (hideUris.includes(message.uri)) {
|
|
205
|
+
delete newChatIndex[key];
|
|
206
|
+
removedKeys.add(key);
|
|
207
|
+
hasChanges = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
163
212
|
const messagesToAdd: { key: string; message: ChatMessageViewHydrated }[] = [];
|
|
164
213
|
|
|
165
214
|
for (const message of newMessages) {
|
|
215
|
+
// don't worry about messages that will be hidden
|
|
216
|
+
if (state.pendingHides.includes(message.uri)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
166
220
|
const date = new Date(message.record.createdAt);
|
|
167
221
|
const key = `${date.getTime()}-${message.uri}`;
|
|
168
222
|
|
|
169
223
|
// only change the ref if the profile is different to avoid re-renders elsewhere
|
|
170
224
|
if (
|
|
171
|
-
profileIsDifferent(message.chatProfile, newAuthors[message.author.
|
|
225
|
+
profileIsDifferent(message.chatProfile, newAuthors[message.author.did])
|
|
172
226
|
) {
|
|
173
|
-
newAuthors[message.author.
|
|
227
|
+
newAuthors[message.author.did] = message.chatProfile;
|
|
174
228
|
}
|
|
175
229
|
|
|
176
230
|
// skip messages we already have
|
|
@@ -214,17 +268,20 @@ export const reduceChatIncremental = (
|
|
|
214
268
|
|
|
215
269
|
if (parentMsgKey) {
|
|
216
270
|
const parentMsg = newChatIndex[parentMsgKey];
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
271
|
+
// Don't allow replies to system messages
|
|
272
|
+
if (parentMsg.author.did !== "did:sys:system") {
|
|
273
|
+
processedMessage = {
|
|
274
|
+
...message,
|
|
275
|
+
replyTo: {
|
|
276
|
+
cid: parentMsg.cid,
|
|
277
|
+
uri: parentMsg.uri,
|
|
278
|
+
author: parentMsg.author,
|
|
279
|
+
record: parentMsg.record,
|
|
280
|
+
chatProfile: parentMsg.chatProfile,
|
|
281
|
+
indexedAt: parentMsg.indexedAt,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
228
285
|
}
|
|
229
286
|
}
|
|
230
287
|
}
|
|
@@ -251,11 +308,82 @@ export const reduceChatIncremental = (
|
|
|
251
308
|
removedKeys,
|
|
252
309
|
);
|
|
253
310
|
|
|
311
|
+
// Clean up pendingHides - remove URIs that we've now processed
|
|
312
|
+
let newPendingHides = state.pendingHides;
|
|
313
|
+
if (hideUris.length > 0) {
|
|
314
|
+
newPendingHides = state.pendingHides.filter(
|
|
315
|
+
(uri) => !hideUris.includes(uri),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
254
319
|
return {
|
|
255
320
|
...state,
|
|
321
|
+
authors: newAuthors,
|
|
256
322
|
chatIndex: newChatIndex,
|
|
257
323
|
chat: newChatList,
|
|
324
|
+
pendingHides: newPendingHides,
|
|
258
325
|
};
|
|
259
326
|
};
|
|
260
327
|
|
|
328
|
+
export const useSubmitReport = () => {
|
|
329
|
+
const pdsAgent = usePDSAgent();
|
|
330
|
+
const userDID = useDID();
|
|
331
|
+
|
|
332
|
+
return useCallback(
|
|
333
|
+
async (
|
|
334
|
+
subject: ComAtprotoModerationCreateReport.InputSchema["subject"],
|
|
335
|
+
reasonType: string,
|
|
336
|
+
reason?: string,
|
|
337
|
+
// no clue about this
|
|
338
|
+
moderationSvcDid: string = "did:web:stream.place",
|
|
339
|
+
) => {
|
|
340
|
+
if (!pdsAgent || !userDID) {
|
|
341
|
+
throw new Error("No PDS agent or user DID found");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const response = await pdsAgent.com.atproto.moderation.createReport(
|
|
346
|
+
{
|
|
347
|
+
reasonType,
|
|
348
|
+
reason,
|
|
349
|
+
subject: subject,
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
headers: {
|
|
353
|
+
// "atproto-proxy": `${userDID}#atproto_labeler`,
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
return response;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
console.error("Failed to submit report:", error);
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
[pdsAgent, userDID],
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
export const useReportChatMessage = () => {
|
|
369
|
+
const submitReport = useSubmitReport();
|
|
370
|
+
|
|
371
|
+
return useCallback(
|
|
372
|
+
async (
|
|
373
|
+
message: ChatMessageViewHydrated,
|
|
374
|
+
reasonType: string,
|
|
375
|
+
reason?: string,
|
|
376
|
+
) => {
|
|
377
|
+
const reportSubject = {
|
|
378
|
+
$type: "com.atproto.repo.strongRef",
|
|
379
|
+
uri: message.uri,
|
|
380
|
+
cid: message.cid,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
return await submitReport(reportSubject, reasonType, reason);
|
|
384
|
+
},
|
|
385
|
+
[submitReport],
|
|
386
|
+
);
|
|
387
|
+
};
|
|
388
|
+
|
|
261
389
|
export const reduceChat = reduceChatIncremental;
|
|
@@ -13,9 +13,19 @@ export interface LivestreamState {
|
|
|
13
13
|
authors: { [key: string]: ChatMessageViewHydrated["chatProfile"] };
|
|
14
14
|
livestream: LivestreamViewHydrated | null;
|
|
15
15
|
viewers: number | null;
|
|
16
|
+
pendingHides: string[];
|
|
16
17
|
segment: PlaceStreamSegment.Record | null;
|
|
18
|
+
recentSegments: PlaceStreamSegment.Record[];
|
|
19
|
+
problems: LivestreamProblem[];
|
|
17
20
|
renditions: PlaceStreamDefs.Rendition[];
|
|
18
21
|
replyToMessage: ChatMessageViewHydrated | null;
|
|
19
22
|
streamKey: string | null;
|
|
20
23
|
setStreamKey: (key: string | null) => void;
|
|
21
24
|
}
|
|
25
|
+
|
|
26
|
+
export interface LivestreamProblem {
|
|
27
|
+
code: string;
|
|
28
|
+
message: string;
|
|
29
|
+
severity: "error" | "warning" | "info";
|
|
30
|
+
link?: string;
|
|
31
|
+
}
|
|
@@ -13,12 +13,15 @@ export const makeLivestreamStore = (): StoreApi<LivestreamState> => {
|
|
|
13
13
|
chat: [],
|
|
14
14
|
livestream: null,
|
|
15
15
|
viewers: null,
|
|
16
|
+
pendingHides: [],
|
|
16
17
|
segment: null,
|
|
17
18
|
renditions: [],
|
|
18
19
|
replyToMessage: null,
|
|
19
20
|
streamKey: null,
|
|
20
21
|
setStreamKey: (sk) => set({ streamKey: sk }),
|
|
21
22
|
authors: {},
|
|
23
|
+
recentSegments: [],
|
|
24
|
+
problems: [],
|
|
22
25
|
}));
|
|
23
26
|
};
|
|
24
27
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { PlaceStreamSegment } from "streamplace";
|
|
2
|
+
import { LivestreamProblem } from "./livestream-state";
|
|
3
|
+
|
|
4
|
+
const VARIANCE_THRESHOLD = 0.5;
|
|
5
|
+
const DURATION_THRESHOLD = 5000000000; // 5s in ns
|
|
6
|
+
|
|
7
|
+
const detectVariableSegmentLength = (
|
|
8
|
+
segments: PlaceStreamSegment.Record[],
|
|
9
|
+
): { variable: boolean; duration: boolean } => {
|
|
10
|
+
if (segments.length < 3) {
|
|
11
|
+
// Need at least 3 segments to detect variability
|
|
12
|
+
return { variable: false, duration: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const durations = segments
|
|
16
|
+
.map((segment) => segment.duration)
|
|
17
|
+
.filter(
|
|
18
|
+
(duration): duration is number => duration !== undefined && duration > 0,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (durations.length < 3) {
|
|
22
|
+
return { variable: false, duration: false };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Calculate mean
|
|
26
|
+
const mean =
|
|
27
|
+
durations.reduce((sum: number, duration: number) => sum + duration, 0) /
|
|
28
|
+
durations.length;
|
|
29
|
+
|
|
30
|
+
// Calculate standard deviation
|
|
31
|
+
const variance =
|
|
32
|
+
durations.reduce((sum: number, duration: number) => {
|
|
33
|
+
const diff = duration - mean;
|
|
34
|
+
return sum + diff * diff;
|
|
35
|
+
}, 0) / durations.length;
|
|
36
|
+
const stdDev = Math.sqrt(variance);
|
|
37
|
+
|
|
38
|
+
// Calculate coefficient of variation (CV)
|
|
39
|
+
const cv = stdDev / mean;
|
|
40
|
+
|
|
41
|
+
// CV > 0.5 indicates high variability
|
|
42
|
+
// This threshold can be adjusted based on testing
|
|
43
|
+
return {
|
|
44
|
+
variable: cv > VARIANCE_THRESHOLD,
|
|
45
|
+
duration: mean > DURATION_THRESHOLD,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const findProblems = (
|
|
50
|
+
segments: PlaceStreamSegment.Record[],
|
|
51
|
+
): LivestreamProblem[] => {
|
|
52
|
+
const problems: LivestreamProblem[] = [];
|
|
53
|
+
let hasBFrames = false;
|
|
54
|
+
for (const segment of segments) {
|
|
55
|
+
const video = segment.video?.[0];
|
|
56
|
+
if (!video) {
|
|
57
|
+
// i mean yes this is a problem but it can't happen yet
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (video.bframes === true) {
|
|
61
|
+
hasBFrames = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (hasBFrames) {
|
|
66
|
+
problems.push({
|
|
67
|
+
code: "bframes",
|
|
68
|
+
message:
|
|
69
|
+
"Your stream contains B-Frames, which are not supported in Streamplace. Your stream will stutter.",
|
|
70
|
+
severity: "error",
|
|
71
|
+
link: "https://stream.place/docs/guides/start-streaming/obs/#obs-configuration",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { variable, duration } = detectVariableSegmentLength(segments);
|
|
76
|
+
if (variable) {
|
|
77
|
+
problems.push({
|
|
78
|
+
code: "variable_segment_length",
|
|
79
|
+
message:
|
|
80
|
+
"Your stream contains variable segment lengths, which may cause playback issues.",
|
|
81
|
+
severity: "warning",
|
|
82
|
+
link: "https://stream.place/docs/guides/start-streaming/obs/#obs-configuration",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (duration) {
|
|
86
|
+
problems.push({
|
|
87
|
+
code: "long_segments",
|
|
88
|
+
message:
|
|
89
|
+
"Your stream contains long segments (>5s). This will work fine, but increases the delay of the livestream.",
|
|
90
|
+
severity: "warning",
|
|
91
|
+
link: "https://stream.place/docs/guides/start-streaming/obs/#obs-configuration",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return problems;
|
|
96
|
+
};
|
|
@@ -3,13 +3,18 @@ import {
|
|
|
3
3
|
ChatMessageViewHydrated,
|
|
4
4
|
LivestreamViewHydrated,
|
|
5
5
|
PlaceStreamChatDefs,
|
|
6
|
+
PlaceStreamChatGate,
|
|
6
7
|
PlaceStreamChatMessage,
|
|
7
8
|
PlaceStreamDefs,
|
|
8
9
|
PlaceStreamLivestream,
|
|
9
10
|
PlaceStreamSegment,
|
|
10
11
|
} from "streamplace";
|
|
12
|
+
import { SystemMessages } from "../lib/system-messages";
|
|
11
13
|
import { reduceChat } from "./chat";
|
|
12
14
|
import { LivestreamState } from "./livestream-state";
|
|
15
|
+
import { findProblems } from "./problems";
|
|
16
|
+
|
|
17
|
+
const MAX_RECENT_SEGMENTS = 10;
|
|
13
18
|
|
|
14
19
|
export const handleWebSocketMessages = (
|
|
15
20
|
state: LivestreamState,
|
|
@@ -17,9 +22,23 @@ export const handleWebSocketMessages = (
|
|
|
17
22
|
): LivestreamState => {
|
|
18
23
|
for (const message of messages) {
|
|
19
24
|
if (PlaceStreamLivestream.isLivestreamView(message)) {
|
|
25
|
+
const newLivestream = message as LivestreamViewHydrated;
|
|
26
|
+
const oldLivestream = state.livestream;
|
|
27
|
+
|
|
28
|
+
// check if this is actually new
|
|
29
|
+
if (!oldLivestream || oldLivestream.uri !== newLivestream.uri) {
|
|
30
|
+
const streamTitle = newLivestream.record.title || "something cool!";
|
|
31
|
+
const systemMessage = SystemMessages.streamStart(streamTitle);
|
|
32
|
+
// set proper times
|
|
33
|
+
systemMessage.indexedAt = newLivestream.indexedAt;
|
|
34
|
+
systemMessage.record.createdAt = newLivestream.record.createdAt;
|
|
35
|
+
|
|
36
|
+
state = reduceChat(state, [systemMessage], []);
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
state = {
|
|
21
40
|
...state,
|
|
22
|
-
livestream:
|
|
41
|
+
livestream: newLivestream,
|
|
23
42
|
};
|
|
24
43
|
} else if (PlaceStreamLivestream.isViewerCount(message)) {
|
|
25
44
|
state = {
|
|
@@ -36,16 +55,24 @@ export const handleWebSocketMessages = (
|
|
|
36
55
|
indexedAt: message.indexedAt,
|
|
37
56
|
chatProfile: (message as any).chatProfile,
|
|
38
57
|
replyTo: (message as any).replyTo,
|
|
58
|
+
deleted: message.deleted,
|
|
39
59
|
};
|
|
40
|
-
state = reduceChat(state, [hydrated], []);
|
|
60
|
+
state = reduceChat(state, [hydrated], [], []);
|
|
41
61
|
} else if (PlaceStreamSegment.isRecord(message)) {
|
|
62
|
+
const newRecentSegments = [...state.recentSegments];
|
|
63
|
+
newRecentSegments.unshift(message);
|
|
64
|
+
if (newRecentSegments.length > MAX_RECENT_SEGMENTS) {
|
|
65
|
+
newRecentSegments.pop();
|
|
66
|
+
}
|
|
42
67
|
state = {
|
|
43
68
|
...state,
|
|
44
69
|
segment: message as PlaceStreamSegment.Record,
|
|
70
|
+
recentSegments: newRecentSegments,
|
|
71
|
+
problems: findProblems(newRecentSegments),
|
|
45
72
|
};
|
|
46
73
|
} else if (PlaceStreamDefs.isBlockView(message)) {
|
|
47
74
|
const block = message as PlaceStreamDefs.BlockView;
|
|
48
|
-
state = reduceChat(state, [], [block]);
|
|
75
|
+
state = reduceChat(state, [], [block], []);
|
|
49
76
|
} else if (PlaceStreamDefs.isRenditions(message)) {
|
|
50
77
|
state = {
|
|
51
78
|
...state,
|
|
@@ -56,7 +83,20 @@ export const handleWebSocketMessages = (
|
|
|
56
83
|
...state,
|
|
57
84
|
profile: message,
|
|
58
85
|
};
|
|
86
|
+
} else if (PlaceStreamChatGate.isRecord(message)) {
|
|
87
|
+
const hideRecord = message as PlaceStreamChatGate.Record;
|
|
88
|
+
const hiddenMessageUri = hideRecord.hiddenMessage;
|
|
89
|
+
const newPendingHides = [...state.pendingHides];
|
|
90
|
+
if (!newPendingHides.includes(hiddenMessageUri)) {
|
|
91
|
+
newPendingHides.push(hiddenMessageUri);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
state = {
|
|
95
|
+
...state,
|
|
96
|
+
pendingHides: newPendingHides,
|
|
97
|
+
};
|
|
98
|
+
state = reduceChat(state, [], [], [hiddenMessageUri]);
|
|
59
99
|
}
|
|
60
100
|
}
|
|
61
|
-
return reduceChat(state, [], []);
|
|
101
|
+
return reduceChat(state, [], [], []);
|
|
62
102
|
};
|