add stream chat popup

correctly show host user on streams
This commit is contained in:
hzrd149
2023-07-04 12:19:00 -05:00
parent 6e956855bb
commit 85d6a2a098
8 changed files with 204 additions and 103 deletions

View File

@@ -1,5 +1,5 @@
import React, { Suspense, useEffect } from "react"; import React, { Suspense, useEffect } from "react";
import { createHashRouter, Outlet, RouterProvider, ScrollRestoration, useLocation } from "react-router-dom"; import { createHashRouter, Outlet, RouterProvider, ScrollRestoration, useSearchParams } from "react-router-dom";
import { Spinner, useColorMode } from "@chakra-ui/react"; import { Spinner, useColorMode } from "@chakra-ui/react";
import { ErrorBoundary } from "./components/error-boundary"; import { ErrorBoundary } from "./components/error-boundary";
import { Page } from "./components/page"; import { Page } from "./components/page";
@@ -35,19 +35,24 @@ import ToolsHomeView from "./views/tools";
import Nip19ToolsView from "./views/tools/nip19"; import Nip19ToolsView from "./views/tools/nip19";
import UserAboutTab from "./views/user/about"; import UserAboutTab from "./views/user/about";
import UserLikesTab from "./views/user/likes"; import UserLikesTab from "./views/user/likes";
import useSetColorMode from "./hooks/use-set-color-mode";
const LiveStreamsTab = React.lazy(() => import("./views/streams")); const LiveStreamsTab = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream")); const StreamView = React.lazy(() => import("./views/streams/stream"));
const SearchView = React.lazy(() => import("./views/search")); const SearchView = React.lazy(() => import("./views/search"));
const RootPage = () => ( const RootPage = () => {
<Page> useSetColorMode();
<ScrollRestoration />
<Suspense fallback={<Spinner />}> return (
<Outlet /> <Page>
</Suspense> <ScrollRestoration />
</Page> <Suspense fallback={<Spinner />}>
); <Outlet />
</Suspense>
</Page>
);
};
const router = createHashRouter([ const router = createHashRouter([
{ {
@@ -118,19 +123,10 @@ const router = createHashRouter([
}, },
]); ]);
export const App = () => { export const App = () => (
const { setColorMode } = useColorMode(); <ErrorBoundary>
const { colorMode } = useSubject(appSettings); <Suspense fallback={<Spinner />}>
<RouterProvider router={router} />
useEffect(() => { </Suspense>
setColorMode(colorMode); </ErrorBoundary>
}, [colorMode]); );
return (
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<RouterProvider router={router} />
</Suspense>
</ErrorBoundary>
);
};

View File

@@ -35,6 +35,7 @@ import { unique } from "../helpers/array";
import { useUserRelays } from "../hooks/use-user-relays"; import { useUserRelays } from "../hooks/use-user-relays";
import { RelayMode } from "../classes/relay"; import { RelayMode } from "../classes/relay";
import relayScoreboardService from "../services/relay-scoreboard"; import relayScoreboardService from "../services/relay-scoreboard";
import { useAdditionalRelayContext } from "../providers/additional-relay-context";
type FormValues = { type FormValues = {
amount: number; amount: number;
@@ -50,6 +51,7 @@ export type ZapModalProps = Omit<ModalProps, "children"> & {
onInvoice: (invoice: string) => void; onInvoice: (invoice: string) => void;
allowComment?: boolean; allowComment?: boolean;
showEventPreview?: boolean; showEventPreview?: boolean;
additionalRelays?: string[];
}; };
export default function ZapModal({ export default function ZapModal({
@@ -62,9 +64,11 @@ export default function ZapModal({
onInvoice, onInvoice,
allowComment = true, allowComment = true,
showEventPreview = true, showEventPreview = true,
additionalRelays = [],
...props ...props
}: ZapModalProps) { }: ZapModalProps) {
const toast = useToast(); const toast = useToast();
const contextRelays = useAdditionalRelayContext();
const { requestSignature } = useSigningContext(); const { requestSignature } = useSigningContext();
const { customZapAmounts } = useSubject(appSettings); const { customZapAmounts } = useSubject(appSettings);
const userReadRelays = useUserRelays(pubkey) const userReadRelays = useUserRelays(pubkey)
@@ -104,6 +108,7 @@ export default function ZapModal({
const writeRelays = clientRelaysService.getWriteUrls(); const writeRelays = clientRelaysService.getWriteUrls();
const writeRelaysRanked = relayScoreboardService.getRankedRelays(writeRelays).slice(0, 4); const writeRelaysRanked = relayScoreboardService.getRankedRelays(writeRelays).slice(0, 4);
const userReadRelaysRanked = relayScoreboardService.getRankedRelays(userReadRelays).slice(0, 4); const userReadRelaysRanked = relayScoreboardService.getRankedRelays(userReadRelays).slice(0, 4);
const contextRelaysRanked = relayScoreboardService.getRankedRelays(contextRelays).slice(0, 4);
const zapRequest: DraftNostrEvent = { const zapRequest: DraftNostrEvent = {
kind: Kind.ZapRequest, kind: Kind.ZapRequest,
@@ -111,10 +116,22 @@ export default function ZapModal({
content: values.comment, content: values.comment,
tags: [ tags: [
["p", pubkey], ["p", pubkey],
["relays", ...unique([...writeRelaysRanked, ...userReadRelaysRanked, ...eventRelaysRanked])], [
"relays",
...unique([
...contextRelaysRanked,
...writeRelaysRanked,
...userReadRelaysRanked,
...eventRelaysRanked,
...additionalRelays,
]),
],
["amount", String(amountInMilisat)], ["amount", String(amountInMilisat)],
], ],
}; };
console.log(zapRequest);
if (event) zapRequest.tags.push(["e", event.id]); if (event) zapRequest.tags.push(["e", event.id]);
if (stream) zapRequest.tags.push(["a", getATag(stream)]); if (stream) zapRequest.tags.push(["a", getATag(stream)]);

View File

@@ -1,10 +1,11 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event"; import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
import { unique } from "../array"; import { unique } from "../array";
export type ParsedStream = { export type ParsedStream = {
event: NostrEvent; event: NostrEvent;
author: string; author: string;
host: string;
title: string; title: string;
summary?: string; summary?: string;
image?: string; image?: string;
@@ -42,10 +43,12 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
status = "ended"; status = "ended";
} }
const host = stream.tags.filter(isPTag)[0]?.[1] ?? stream.pubkey;
const tags = unique(stream.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1] as string)); const tags = unique(stream.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1] as string));
return { return {
author: stream.pubkey, author: stream.pubkey,
host,
event: stream, event: stream,
updated: stream.created_at, updated: stream.created_at,
streaming, streaming,

View File

@@ -0,0 +1,15 @@
import { useColorMode } from "@chakra-ui/react";
import useSubject from "./use-subject";
import appSettings from "../services/app-settings";
import { useSearchParams } from "react-router-dom";
import { useEffect } from "react";
export default function useSetColorMode() {
const { setColorMode } = useColorMode();
const { colorMode } = useSubject(appSettings);
const [params] = useSearchParams();
useEffect(() => {
setColorMode(params.get("colorMode") || colorMode);
}, [colorMode, params.get("colorMode")]);
}

View File

@@ -43,6 +43,7 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P
const relays = getEventRelays(stream.event.id).value; const relays = getEventRelays(stream.event.id).value;
const ranked = relayScoreboardService.getRankedRelays(relays); const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2); const onlyTwo = ranked.slice(0, 2);
return nip19.naddrEncode({ return nip19.naddrEncode({
identifier, identifier,
relays: onlyTwo, relays: onlyTwo,
@@ -57,9 +58,9 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P
<LinkBox as={CardBody} p="2" display="flex" flexDirection="column" gap="2"> <LinkBox as={CardBody} p="2" display="flex" flexDirection="column" gap="2">
{image && <Image src={image} alt={title} borderRadius="lg" />} {image && <Image src={image} alt={title} borderRadius="lg" />}
<Flex gap="2" alignItems="center"> <Flex gap="2" alignItems="center">
<UserAvatar pubkey={stream.author} size="sm" /> <UserAvatar pubkey={stream.host} size="sm" noProxy />
<Heading size="sm"> <Heading size="sm">
<UserLink pubkey={stream.author} /> <UserLink pubkey={stream.host} />
</Heading> </Heading>
</Flex> </Flex>
<Heading size="md"> <Heading size="md">

View File

@@ -1,41 +1,71 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useScroll } from "react-use"; import { useScroll } from "react-use";
import { Box, Button, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react"; import { Box, Button, ButtonGroup, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react";
import { Link as RouterLink, useParams, Navigate } from "react-router-dom"; import { Link as RouterLink, useParams, Navigate, useSearchParams } from "react-router-dom";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { Global, css } from "@emotion/react";
import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream"; import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream";
import { NostrRequest } from "../../../classes/nostr-request"; import { NostrRequest } from "../../../classes/nostr-request";
import { useReadRelayUrls } from "../../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { unique } from "../../../helpers/array"; import { unique } from "../../../helpers/array";
import { LiveVideoPlayer } from "../../../components/live-video-player"; import { LiveVideoPlayer } from "../../../components/live-video-player";
import StreamChat from "./stream-chat"; import StreamChat, { ChatDisplayMode } from "./stream-chat";
import { UserAvatarLink } from "../../../components/user-avatar-link"; import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link"; import { UserLink } from "../../../components/user-link";
import { useIsMobile } from "../../../hooks/use-is-mobile"; import { useIsMobile } from "../../../hooks/use-is-mobile";
import { AdditionalRelayProvider } from "../../../providers/additional-relay-context"; import { AdditionalRelayProvider } from "../../../providers/additional-relay-context";
import StreamSummaryContent from "../components/stream-summary-content"; import StreamSummaryContent from "../components/stream-summary-content";
import { ArrowDownSIcon, ArrowUpSIcon } from "../../../components/icons"; import { ArrowDownSIcon, ArrowUpSIcon, ExternalLinkIcon } from "../../../components/icons";
import useSetColorMode from "../../../hooks/use-set-color-mode";
import { CopyIconButton } from "../../../components/copy-icon-button";
function StreamPage({ stream }: { stream: ParsedStream }) { function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode?: ChatDisplayMode }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const scrollBox = useRef<HTMLDivElement | null>(null); const scrollBox = useRef<HTMLDivElement | null>(null);
const scrollState = useScroll(scrollBox); const scrollState = useScroll(scrollBox);
const action = const renderActions = () => {
scrollState.y === 0 ? ( const toggleButton =
<Button scrollState.y === 0 ? (
size="sm" <Button
onClick={() => scrollBox.current?.scroll(0, scrollBox.current.scrollHeight)} size="sm"
leftIcon={<ArrowDownSIcon />} onClick={() => scrollBox.current?.scroll(0, scrollBox.current.scrollHeight)}
> leftIcon={<ArrowDownSIcon />}
View Chat >
</Button> View Chat
) : ( </Button>
<Button size="sm" onClick={() => scrollBox.current?.scroll(0, 0)} leftIcon={<ArrowUpSIcon />}> ) : (
View Stream <Button size="sm" onClick={() => scrollBox.current?.scroll(0, 0)} leftIcon={<ArrowUpSIcon />}>
</Button> View Stream
</Button>
);
return (
<ButtonGroup>
{isMobile && toggleButton}
<CopyIconButton
text={location.href + "?displayMode=log&colorMode=dark"}
aria-label="Copy chat log URL"
title="Copy chat log URL"
size="sm"
/>
<Button
rightIcon={<ExternalLinkIcon />}
size="sm"
onClick={() => {
const w = 512;
const h = 910;
const y = window.screenTop + window.innerHeight - h;
const x = window.screenLeft + window.innerWidth - w;
window.open(location.href + "?displayMode=popup", "_blank", `width=${w},height=${h},left=${x},top=${y}`);
}}
>
Open
</Button>
</ButtonGroup>
); );
};
return ( return (
<Flex <Flex
@@ -43,35 +73,47 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
overflowX="hidden" overflowX="hidden"
overflowY="auto" overflowY="auto"
direction={isMobile ? "column" : "row"} direction={isMobile ? "column" : "row"}
p={isMobile ? 0 : "2"} p={isMobile || !!displayMode ? 0 : "2"}
gap={isMobile ? 0 : "4"} gap={isMobile ? 0 : "4"}
ref={scrollBox} ref={scrollBox}
> >
<Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}> {displayMode && (
<LiveVideoPlayer stream={stream.streaming} autoPlay poster={stream.image} maxH="100vh" /> <Global
<Flex gap={isMobile ? "2" : "4"} alignItems="center" p={isMobile ? "2" : 0}> styles={css`
<UserAvatarLink pubkey={stream.author} /> body {
<Box> background: transparent;
<Heading size="md"> }
<UserLink pubkey={stream.author} /> `}
</Heading> />
<Text>{stream.title}</Text> )}
</Box> {!displayMode && (
<Spacer /> <Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}>
<Button as={RouterLink} to="/streams"> <LiveVideoPlayer stream={stream.streaming} autoPlay poster={stream.image} maxH="100vh" />
Back <Flex gap={isMobile ? "2" : "4"} alignItems="center" p={isMobile ? "2" : 0}>
</Button> <UserAvatarLink pubkey={stream.host} noProxy />
<Box>
<Heading size="md">
<UserLink pubkey={stream.host} />
</Heading>
<Text>{stream.title}</Text>
</Box>
<Spacer />
<Button as={RouterLink} to="/streams">
Back
</Button>
</Flex>
<StreamSummaryContent stream={stream} px={isMobile ? "2" : 0} />
</Flex> </Flex>
<StreamSummaryContent stream={stream} px={isMobile ? "2" : 0} /> )}
</Flex>
<StreamChat <StreamChat
stream={stream} stream={stream}
flexGrow={1} flexGrow={1}
maxW={isMobile ? undefined : "lg"} maxW={isMobile || !!displayMode ? undefined : "lg"}
maxH="100vh" maxH="100vh"
minH={isMobile ? "100vh" : undefined} minH={isMobile ? "100vh" : undefined}
flexShrink={0} flexShrink={0}
actions={isMobile && action} actions={renderActions()}
displayMode={displayMode}
/> />
</Flex> </Flex>
); );
@@ -79,6 +121,9 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
export default function StreamView() { export default function StreamView() {
const { naddr } = useParams(); const { naddr } = useParams();
const [params] = useSearchParams();
useSetColorMode();
if (!naddr) return <Navigate replace to="/streams" />; if (!naddr) return <Navigate replace to="/streams" />;
const readRelays = useReadRelayUrls(); const readRelays = useReadRelayUrls();
@@ -104,8 +149,11 @@ export default function StreamView() {
if (!stream) return <Spinner />; if (!stream) return <Spinner />;
return ( return (
<AdditionalRelayProvider relays={relays}> // add snort and damus relays so zap.stream will always see zaps
<StreamPage stream={stream} /> <AdditionalRelayProvider
relays={unique([...relays, "wss://relay.snort.social", "wss://relay.damus.io", "wss://nos.lol"])}
>
<StreamPage stream={stream} displayMode={(params.get("displayMode") as ChatDisplayMode) ?? undefined} />
</AdditionalRelayProvider> </AdditionalRelayProvider>
); );
} }

View File

@@ -19,7 +19,7 @@ export default function ChatMessage({ event, stream }: { event: NostrEvent; stre
<NoteZapButton note={event} size="xs" variant="ghost" float="right" ml="2" allowComment={false} /> <NoteZapButton note={event} size="xs" variant="ghost" float="right" ml="2" allowComment={false} />
<Text ref={ref}> <Text ref={ref}>
<UserAvatar pubkey={event.pubkey} size="xs" display="inline-block" mr="2" /> <UserAvatar pubkey={event.pubkey} size="xs" display="inline-block" mr="2" />
<Text as="span" fontWeight="bold" color={event.pubkey === stream.author ? "rgb(248, 56, 217)" : "cyan"}> <Text as="span" fontWeight="bold" color={event.pubkey === stream.host ? "rgb(248, 56, 217)" : "cyan"}>
<UserLink pubkey={event.pubkey} /> <UserLink pubkey={event.pubkey} />
{": "} {": "}
</Text> </Text>

View File

@@ -35,16 +35,28 @@ import { useTimelineCurserIntersectionCallback } from "../../../../hooks/use-tim
import useSubject from "../../../../hooks/use-subject"; import useSubject from "../../../../hooks/use-subject";
import { useTimelineLoader } from "../../../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../../../hooks/use-timeline-loader";
import { truncatedId } from "../../../../helpers/nostr-event"; import { truncatedId } from "../../../../helpers/nostr-event";
import { css } from "@emotion/react";
const hideScrollbar = css`
scrollbar-width: 0;
::-webkit-scrollbar {
width: 0;
}
`;
export type ChatDisplayMode = "log" | "popup";
export default function StreamChat({ export default function StreamChat({
stream, stream,
actions, actions,
displayMode,
...props ...props
}: CardProps & { stream: ParsedStream; actions?: React.ReactNode }) { }: CardProps & { stream: ParsedStream; actions?: React.ReactNode; displayMode?: ChatDisplayMode }) {
const toast = useToast(); const toast = useToast();
const contextRelays = useAdditionalRelayContext(); const contextRelays = useAdditionalRelayContext();
const readRelays = useReadRelayUrls(contextRelays); const readRelays = useReadRelayUrls(contextRelays);
const userReadRelays = useUserRelays(stream.author) const hostReadRelays = useUserRelays(stream.host)
.filter((r) => r.mode & RelayMode.READ) .filter((r) => r.mode & RelayMode.READ)
.map((r) => r.url); .map((r) => r.url);
@@ -67,7 +79,7 @@ export default function StreamChat({
const draft = buildChatMessage(stream, values.content); const draft = buildChatMessage(stream, values.content);
const signed = await requestSignature(draft); const signed = await requestSignature(draft);
if (!signed) throw new Error("Failed to sign"); if (!signed) throw new Error("Failed to sign");
nostrPostAction(unique([...contextRelays, ...userReadRelays]), signed); nostrPostAction(unique([...contextRelays, ...hostReadRelays]), signed);
reset(); reset();
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
@@ -76,17 +88,22 @@ export default function StreamChat({
const zapModal = useDisclosure(); const zapModal = useDisclosure();
const { requestPay } = useInvoiceModalContext(); const { requestPay } = useInvoiceModalContext();
const zapMetadata = useUserLNURLMetadata(stream.author); const zapMetadata = useUserLNURLMetadata(stream.host);
const isPopup = !!displayMode;
const isChatLog = displayMode === "log";
return ( return (
<> <>
<IntersectionObserverProvider callback={callback} root={scrollBox}> <IntersectionObserverProvider callback={callback} root={scrollBox}>
<ImageGalleryProvider> <ImageGalleryProvider>
<Card {...props} overflow="hidden"> <Card {...props} overflow="hidden" background={isChatLog ? "transparent" : undefined}>
<CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center"> {!isPopup && (
<Heading size="md">Stream Chat</Heading> <CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center">
{actions} <Heading size="md">Stream Chat</Heading>
</CardHeader> {actions}
</CardHeader>
)}
<CardBody display="flex" flexDirection="column" gap="2" overflow="hidden" p={0}> <CardBody display="flex" flexDirection="column" gap="2" overflow="hidden" p={0}>
<Flex <Flex
overflowY="scroll" overflowY="scroll"
@@ -97,6 +114,7 @@ export default function StreamChat({
px="4" px="4"
py="2" py="2"
gap="2" gap="2"
css={isChatLog && hideScrollbar}
> >
{events.map((event) => {events.map((event) =>
event.kind === 1311 ? ( event.kind === 1311 ? (
@@ -106,30 +124,32 @@ export default function StreamChat({
) )
)} )}
</Flex> </Flex>
<Box {!isChatLog && (
as="form" <Box
borderRadius="md" as="form"
flexShrink={0} borderRadius="md"
display="flex" flexShrink={0}
gap="2" display="flex"
px="2" gap="2"
pb="2" px="2"
onSubmit={sendMessage} pb="2"
> onSubmit={sendMessage}
<Input placeholder="Message" {...register("content", { required: true })} autoComplete="off" /> >
<Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}> <Input placeholder="Message" {...register("content", { required: true })} autoComplete="off" />
Send <Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}>
</Button> Send
{zapMetadata.metadata?.allowsNostr && ( </Button>
<IconButton {zapMetadata.metadata?.allowsNostr && (
icon={<LightningIcon color="yellow.400" />} <IconButton
aria-label="Zap stream" icon={<LightningIcon color="yellow.400" />}
borderColor="yellow.400" aria-label="Zap stream"
variant="outline" borderColor="yellow.400"
onClick={zapModal.onOpen} variant="outline"
/> onClick={zapModal.onOpen}
)} />
</Box> )}
</Box>
)}
</CardBody> </CardBody>
</Card> </Card>
</ImageGalleryProvider> </ImageGalleryProvider>
@@ -138,7 +158,7 @@ export default function StreamChat({
<ZapModal <ZapModal
isOpen isOpen
stream={stream} stream={stream}
pubkey={stream.author} pubkey={stream.host}
onInvoice={async (invoice) => { onInvoice={async (invoice) => {
reset(); reset();
zapModal.onClose(); zapModal.onClose();
@@ -146,6 +166,7 @@ export default function StreamChat({
}} }}
onClose={zapModal.onClose} onClose={zapModal.onClose}
initialComment={getValues().content} initialComment={getValues().content}
additionalRelays={contextRelays}
/> />
)} )}
</> </>