diff --git a/js/components/src/components/chat/chat-box.tsx b/js/components/src/components/chat/chat-box.tsx index 71ff8cfd4..49f190076 100644 --- a/js/components/src/components/chat/chat-box.tsx +++ b/js/components/src/components/chat/chat-box.tsx @@ -1,3 +1,8 @@ +import { + createTeleport, + handleSlashCommand, + registerTeleportCommand, +} from "@streamplace/core"; import Graphemer from "graphemer"; import { AtSignIcon, ExternalLink, X } from "lucide-react-native"; import { env } from "process"; @@ -5,11 +10,6 @@ import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, TextInput } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; import { Button, Loader, Text, toast, useTheme, View } from "../../"; -import { handleSlashCommand } from "../../lib/slash-commands"; -import { - createTeleport, - registerTeleportCommand, -} from "../../lib/slash-commands/teleport"; import { StreamNotifications } from "../../lib/stream-notifications"; import { SystemMessages } from "../../lib/system-messages"; import { @@ -119,15 +119,21 @@ export function ChatBox({ ); useEffect(() => { - if (pdsAgent && userDID) { - registerTeleportCommand( - pdsAgent, - userDID, - () => (linfo ? { uri: linfo.uri, cid: linfo.cid } : null), - setActiveTeleportUri, - () => setShowTeleportModal(true), - ); - } + if (!pdsAgent || !userDID) return; + return registerTeleportCommand( + pdsAgent, + userDID, + () => + linfo && !linfo.record.endedAt + ? { + uri: linfo.uri, + cid: linfo.cid, + streamerDid: linfo.author.did, + } + : null, + setActiveTeleportUri, + () => setShowTeleportModal(true), + ); }, [pdsAgent, userDID, linfo, setActiveTeleportUri]); const authors = useMemo(() => { @@ -141,18 +147,6 @@ export function ChatBox({ }, new Map()); }, [chat]); - useEffect(() => { - if (pdsAgent && linfo?.author?.did && pdsAgent.did === linfo.author.did) { - registerTeleportCommand( - pdsAgent, - pdsAgent.did, - () => (linfo ? { uri: linfo.uri, cid: linfo.cid } : null), - setActiveTeleportUri, - () => setShowTeleportModal(true), - ); - } - }, [pdsAgent, linfo, setActiveTeleportUri]); - const handleMentionSelect = (handle: string) => { const beforeAt = message.slice(0, message.lastIndexOf("@")); setMessage(`${beforeAt}@${handle} `); @@ -177,6 +171,14 @@ export function ChatBox({ countdownSeconds: number, ) => { if (!pdsAgent || !userDID) return; + if (!linfo || linfo.record.endedAt || linfo.author.did !== userDID) { + addSystemMessage( + SystemMessages.commandError( + "Only the streamer of the current livestream can start a teleport", + ), + ); + return; + } const result = await createTeleport( pdsAgent, diff --git a/js/components/src/livestream-provider/index.tsx b/js/components/src/livestream-provider/index.tsx index 83d5097b6..f10962e5d 100644 --- a/js/components/src/livestream-provider/index.tsx +++ b/js/components/src/livestream-provider/index.tsx @@ -1,7 +1,6 @@ -import { makeLivestreamStore } from "@streamplace/core"; +import { deleteTeleport, makeLivestreamStore } from "@streamplace/core"; import React, { useContext, useEffect, useRef } from "react"; import { useAvatars } from "../hooks"; -import { deleteTeleport } from "../lib/slash-commands/teleport"; import { StreamNotifications } from "../lib/stream-notifications"; import { LivestreamContext } from "../livestream-store/context"; import { useUnpinChatMessage } from "../livestream-store/hooks"; diff --git a/js/core/src/index.ts b/js/core/src/index.ts index acbafc570..4bb46052f 100644 --- a/js/core/src/index.ts +++ b/js/core/src/index.ts @@ -2,6 +2,8 @@ export * from "./vod-store"; export * from "./livestream-store"; +export * from "./slash-commands"; +export * from "./slash-commands/teleport"; export * from "./hooks/use-actor-typeahead"; export * from "./lib/badge-label"; diff --git a/js/components/src/lib/slash-commands.ts b/js/core/src/slash-commands.ts similarity index 87% rename from js/components/src/lib/slash-commands.ts rename to js/core/src/slash-commands.ts index d64e24c3e..179e74c50 100644 --- a/js/components/src/lib/slash-commands.ts +++ b/js/core/src/slash-commands.ts @@ -17,8 +17,13 @@ export interface SlashCommand { const commands = new Map(); -export function registerSlashCommand(command: SlashCommand) { +export function registerSlashCommand(command: SlashCommand): () => void { commands.set(command.name, command); + return () => { + if (commands.get(command.name) === command) { + commands.delete(command.name); + } + }; } export function unregisterSlashCommand(name: string) { diff --git a/js/components/src/lib/slash-commands/teleport.ts b/js/core/src/slash-commands/teleport.ts similarity index 91% rename from js/components/src/lib/slash-commands/teleport.ts rename to js/core/src/slash-commands/teleport.ts index 1bccbc899..ebd6f2a1f 100644 --- a/js/components/src/lib/slash-commands/teleport.ts +++ b/js/core/src/slash-commands/teleport.ts @@ -92,14 +92,27 @@ export async function createTeleport( export function registerTeleportCommand( pdsAgent: StreamplaceAgent, userDID: string, - getLivestream?: () => { uri: string; cid: string } | null, + getLivestream?: () => { + uri: string; + cid: string; + streamerDid: string; + } | null, setActiveTeleportUri?: (uri: string | null) => void, onOpenModal?: () => void, -) { +): () => void { const teleportHandler: SlashCommandHandler = async ( args, rawInput, ): Promise => { + const livestream = getLivestream?.() ?? null; + if (!livestream || livestream.streamerDid !== userDID) { + return { + handled: true, + error: + "Only the streamer of the current livestream can start a teleport", + }; + } + if (args.length === 0) { if (onOpenModal) { onOpenModal(); @@ -146,8 +159,6 @@ export function registerTeleportCommand( // stream so the server can end exactly that record on arrival. When absent // the teleport still sends viewers over; the server just won't end a // source stream. - const livestream = getLivestream?.() ?? null; - let targetDID: string; try { const resolution = await pdsAgent.resolveHandle({ @@ -201,17 +212,22 @@ export function registerTeleportCommand( } }; - registerSlashCommand({ + const unregisterTeleport = registerSlashCommand({ name: "teleport", description: "Start a teleport to another streamer", usage: "/teleport @handle.bsky.social [duration_seconds]", handler: teleportHandler, }); - registerSlashCommand({ + const unregisterTp = registerSlashCommand({ name: "tp", description: "Start a teleport to another streamer (alias for /teleport)", usage: "/tp @handle.bsky.social [duration_seconds]", handler: teleportHandler, }); + + return () => { + unregisterTeleport(); + unregisterTp(); + }; } diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 096428105..918fa366a 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -281,6 +281,7 @@ theatre-exit = Exit theatre mode ## Notifications teleporting-in = Teleporting in +teleporting-to = Teleporting to @{ $handle } ## Video Card views-count = { $count -> diff --git a/js/i18n/public/locales/en-US/common.json b/js/i18n/public/locales/en-US/common.json index 05bd4036a..2deeccfed 100644 --- a/js/i18n/public/locales/en-US/common.json +++ b/js/i18n/public/locales/en-US/common.json @@ -232,6 +232,7 @@ "theatre-enter": "Theatre mode", "theatre-exit": "Exit theatre mode", "teleporting-in": "Teleporting in", + "teleporting-to": "Teleporting to @{$handle}", "views-count": "{$count ->\n [one] {$count} view\n *[other] {$count} views\n }", "likes-count": "{$count ->\n [one] {$count} like\n *[other] {$count} likes\n }", "could-not-load-streams": "Could not load streams. You might be offline.", diff --git a/js/web/public/locales/en-US/common.json b/js/web/public/locales/en-US/common.json index 05bd4036a..2deeccfed 100644 --- a/js/web/public/locales/en-US/common.json +++ b/js/web/public/locales/en-US/common.json @@ -232,6 +232,7 @@ "theatre-enter": "Theatre mode", "theatre-exit": "Exit theatre mode", "teleporting-in": "Teleporting in", + "teleporting-to": "Teleporting to @{$handle}", "views-count": "{$count ->\n [one] {$count} view\n *[other] {$count} views\n }", "likes-count": "{$count ->\n [one] {$count} like\n *[other] {$count} likes\n }", "could-not-load-streams": "Could not load streams. You might be offline.", diff --git a/js/web/src/components/stream/chat-input.tsx b/js/web/src/components/stream/chat-input.tsx index 7ecc9132a..865df37bb 100644 --- a/js/web/src/components/stream/chat-input.tsx +++ b/js/web/src/components/stream/chat-input.tsx @@ -1,4 +1,8 @@ -import type { LivestreamStore } from "@streamplace/core"; +import { + handleSlashCommand, + registerTeleportCommand, + type LivestreamStore, +} from "@streamplace/core"; import { Extension } from "@tiptap/core"; import MentionBase from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; @@ -8,7 +12,7 @@ import StarterKit from "@tiptap/starter-kit"; import { exitSuggestion, Suggestion } from "@tiptap/suggestion"; import type { SkinTone } from "frimousse"; import { Reply, Smile, X } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useTranslation } from "react-i18next"; import type { ChatMessageViewHydrated } from "streamplace"; @@ -29,6 +33,7 @@ import { Button } from "../ui/button"; import { EmojiList } from "./emoji-list"; import { EmojiPicker } from "./emoji-picker"; import { MentionList, type MentionItem } from "./mention-list"; +import { TeleportDialog } from "./teleport-dialog"; const MAX_LENGTH = 300; @@ -296,7 +301,7 @@ function createEmojiSuggestion({ getSkinTone }: EmojiSuggestionProps) { export function ChatInput({ store }: { store: LivestreamStore }) { const { t } = useTranslation("common"); - const { state } = useSession(); + const { state, pdsAgent, did } = useSession(); const isAuthed = state.status === "authenticated"; const send = useChatSend(store); const replyToMessage = useStore(store, (s) => s.replyToMessage); @@ -306,6 +311,26 @@ export function ChatInput({ store }: { store: LivestreamStore }) { const [sending, setSending] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false); const [skinTone, setSkinTone] = useSkinTone(); + const [teleportOpen, setTeleportOpen] = useState(false); + const livestream = useStore(store, (s) => s.livestream); + + useEffect(() => { + if (!pdsAgent || !did) return; + return registerTeleportCommand( + pdsAgent, + did, + () => + livestream && !livestream.record.endedAt + ? { + uri: livestream.uri, + cid: livestream.cid, + streamerDid: livestream.author.did, + } + : null, + (uri) => store.setState({ activeTeleportUri: uri }), + () => setTeleportOpen(true), + ); + }, [did, livestream, pdsAgent, store]); // Keep module-level refs fresh so the suggestion items functions always // have up-to-date data without recreating the editor. @@ -455,6 +480,15 @@ export function ChatInput({ store }: { store: LivestreamStore }) { setError(null); setSending(true); try { + if (text.startsWith("/")) { + const result = await handleSlashCommand(text); + if (result.handled) { + editor.commands.clearContent(); + store.setState((s) => ({ ...s, replyToMessage: null })); + if (result.error) setError(result.error); + return; + } + } await send(text); editor.commands.clearContent(); } catch (error) { @@ -463,7 +497,19 @@ export function ChatInput({ store }: { store: LivestreamStore }) { } finally { setSending(false); } - }, [editor, sending, send]); + }, [editor, sending, send, store]); + + const submitTeleport = useCallback( + async (handle: string, countdownSeconds: number) => { + const result = await handleSlashCommand( + `/teleport @${handle} ${countdownSeconds}`, + ); + if (!result.handled || result.error) { + throw new Error(result.error || "Could not start teleport."); + } + }, + [], + ); // Keep the ref fresh so editorProps.handleKeyDown can call the latest onSubmit. onSubmitRef.current = () => onSubmit(); @@ -506,10 +552,15 @@ export function ChatInput({ store }: { store: LivestreamStore }) { {replyToMessage && (
- - {t("chat-replying-to", { - handle: replyToMessage.author.handle || replyToMessage.author.did, - })} + + + {t("chat-replying-to", { + handle: + replyToMessage.author.handle || replyToMessage.author.did, + })} + + {" · "} + {replyToMessage.record.text} + )} +
+ +
0 + ? Math.round((Math.max(0, startsAt - now) / initialTimeLeft) * 100) + : 0 + } + > +
0 + ? Math.max( + 0, + Math.min(100, ((startsAt - now) / initialTimeLeft) * 100), + ) + : 0 + }%`, + }} + /> +
+ ); } diff --git a/js/web/src/components/stream/teleport-dialog.tsx b/js/web/src/components/stream/teleport-dialog.tsx new file mode 100644 index 000000000..16bebc32d --- /dev/null +++ b/js/web/src/components/stream/teleport-dialog.tsx @@ -0,0 +1,182 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import useAvatars from "@/hooks/use-avatars"; +import { useLiveUsers } from "@/hooks/use-live-users"; +import { useMemo, useState } from "react"; + +export function TeleportDialog({ + open, + onOpenChange, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (handle: string, countdownSeconds: number) => Promise; +}) { + const [query, setQuery] = useState(""); + const [countdown, setCountdown] = useState("10"); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const { data: liveUsers = [], isLoading } = useLiveUsers(); + const authorDids = useMemo( + () => + liveUsers.map((stream) => stream.author?.did).filter(Boolean) as string[], + [liveUsers], + ); + const profiles = useAvatars(authorDids); + const filteredUsers = useMemo(() => { + const filter = query.trim().toLowerCase(); + if (!filter) return liveUsers; + return liveUsers.filter( + (stream) => + stream.author?.handle?.toLowerCase().includes(filter) || + String(stream.record.title || "") + .toLowerCase() + .includes(filter), + ); + }, [liveUsers, query]); + + const close = () => { + setQuery(""); + setCountdown("10"); + setSelected(null); + setError(null); + onOpenChange(false); + }; + + const submit = async () => { + const stream = liveUsers.find((candidate) => candidate.uri === selected); + const seconds = Number.parseInt(countdown, 10); + if (!stream?.author?.handle) return; + if (!Number.isInteger(seconds) || seconds < 5 || seconds > 300) { + setError("Countdown must be between 5 and 300 seconds."); + return; + } + setSubmitting(true); + setError(null); + try { + await onSubmit(stream.author.handle, seconds); + close(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Teleport failed."); + } finally { + setSubmitting(false); + } + }; + + return ( + !nextOpen && close()}> + + + Teleport to another live streamer + + Select a streamer to teleport your viewers to their stream. + + + +
+ + + {isLoading && liveUsers.length === 0 ? ( +

+ Loading live streamers… +

+ ) : filteredUsers.length === 0 ? ( +

+ {liveUsers.length === 0 + ? "No live streamers found." + : "No matching live streamers found."} +

+ ) : ( +
+ {filteredUsers.map((stream) => { + const did = stream.author?.did; + const handle = stream.author?.handle || did || "Unknown"; + const profile = did ? profiles[did] : undefined; + const isSelected = stream.uri === selected; + return ( + + ); + })} +
+ )} + + + {error && ( +

+ {error} +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/js/web/src/routes/$user/index.tsx b/js/web/src/routes/$user/index.tsx index 638f41adc..e2a5eeec8 100644 --- a/js/web/src/routes/$user/index.tsx +++ b/js/web/src/routes/$user/index.tsx @@ -4,6 +4,7 @@ import { CopyButton, StreamInfo, } from "@/components/stream/stream-info"; +import { StreamTeleportNotification } from "@/components/stream/stream-notifications"; import { VideoSection } from "@/components/stream/video-section"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -15,12 +16,13 @@ import { } from "@/components/ui/sheet"; import { useFullscreen } from "@/contexts/fullscreen-context"; import { useActorLookup } from "@/hooks/actor-lookup"; +import useAvatars from "@/hooks/use-avatars"; import { useLivenessState } from "@/hooks/use-liveness-state"; import { useLivestreamStore } from "@/hooks/use-livestream-store"; import { useStreamAvatar } from "@/hooks/use-stream-avatar"; import { useStreamplaceUrl } from "@/lib/store/hooks"; import type { LivestreamStore } from "@streamplace/core"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { ExternalLink, House, @@ -29,7 +31,7 @@ import { Search, X, } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; @@ -140,9 +142,32 @@ function CreatorState({ } function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { + const navigate = useNavigate(); const liveness = useLivenessState(store); const avatar = useStreamAvatar(store); + const activeTeleport = useStore(store, (s) => s.activeTeleport); + const livestreamDid = useStore(store, (s) => s.livestream?.author.did); + const teleportDids = useMemo( + () => (activeTeleport ? [activeTeleport.streamer] : []), + [activeTeleport?.streamer], + ); + const teleportProfiles = useAvatars(teleportDids); const { theatre } = useFullscreen(); + + useEffect(() => { + if (!activeTeleport || activeTeleport.streamer === livestreamDid) return; + const targetDid = activeTeleport.streamer; + const startsAt = new Date(activeTeleport.startsAt).getTime(); + if (!Number.isFinite(startsAt)) return; + const timer = setTimeout( + () => { + const target = teleportProfiles[targetDid]?.handle || targetDid; + void navigate({ to: "/$user", params: { user: target } }); + }, + Math.max(0, startsAt - Date.now()), + ); + return () => clearTimeout(timer); + }, [activeTeleport, livestreamDid, navigate, teleportProfiles]); // Offline streams default to a closed chat; the chat has nothing // to show, and the shorter offline player should take the page // width. Live streams honor the user's saved preference. @@ -207,6 +232,7 @@ function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { return (
+