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 { 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 { ErrorBoundary } from "./components/error-boundary";
import { Page } from "./components/page";
@@ -35,12 +35,16 @@ import ToolsHomeView from "./views/tools";
import Nip19ToolsView from "./views/tools/nip19";
import UserAboutTab from "./views/user/about";
import UserLikesTab from "./views/user/likes";
import useSetColorMode from "./hooks/use-set-color-mode";
const LiveStreamsTab = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
const SearchView = React.lazy(() => import("./views/search"));
const RootPage = () => (
const RootPage = () => {
useSetColorMode();
return (
<Page>
<ScrollRestoration />
<Suspense fallback={<Spinner />}>
@@ -48,6 +52,7 @@ const RootPage = () => (
</Suspense>
</Page>
);
};
const router = createHashRouter([
{
@@ -118,19 +123,10 @@ const router = createHashRouter([
},
]);
export const App = () => {
const { setColorMode } = useColorMode();
const { colorMode } = useSubject(appSettings);
useEffect(() => {
setColorMode(colorMode);
}, [colorMode]);
return (
export const App = () => (
<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 { RelayMode } from "../classes/relay";
import relayScoreboardService from "../services/relay-scoreboard";
import { useAdditionalRelayContext } from "../providers/additional-relay-context";
type FormValues = {
amount: number;
@@ -50,6 +51,7 @@ export type ZapModalProps = Omit<ModalProps, "children"> & {
onInvoice: (invoice: string) => void;
allowComment?: boolean;
showEventPreview?: boolean;
additionalRelays?: string[];
};
export default function ZapModal({
@@ -62,9 +64,11 @@ export default function ZapModal({
onInvoice,
allowComment = true,
showEventPreview = true,
additionalRelays = [],
...props
}: ZapModalProps) {
const toast = useToast();
const contextRelays = useAdditionalRelayContext();
const { requestSignature } = useSigningContext();
const { customZapAmounts } = useSubject(appSettings);
const userReadRelays = useUserRelays(pubkey)
@@ -104,6 +108,7 @@ export default function ZapModal({
const writeRelays = clientRelaysService.getWriteUrls();
const writeRelaysRanked = relayScoreboardService.getRankedRelays(writeRelays).slice(0, 4);
const userReadRelaysRanked = relayScoreboardService.getRankedRelays(userReadRelays).slice(0, 4);
const contextRelaysRanked = relayScoreboardService.getRankedRelays(contextRelays).slice(0, 4);
const zapRequest: DraftNostrEvent = {
kind: Kind.ZapRequest,
@@ -111,10 +116,22 @@ export default function ZapModal({
content: values.comment,
tags: [
["p", pubkey],
["relays", ...unique([...writeRelaysRanked, ...userReadRelaysRanked, ...eventRelaysRanked])],
[
"relays",
...unique([
...contextRelaysRanked,
...writeRelaysRanked,
...userReadRelaysRanked,
...eventRelaysRanked,
...additionalRelays,
]),
],
["amount", String(amountInMilisat)],
],
};
console.log(zapRequest);
if (event) zapRequest.tags.push(["e", event.id]);
if (stream) zapRequest.tags.push(["a", getATag(stream)]);

View File

@@ -1,10 +1,11 @@
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
import { unique } from "../array";
export type ParsedStream = {
event: NostrEvent;
author: string;
host: string;
title: string;
summary?: string;
image?: string;
@@ -42,10 +43,12 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
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));
return {
author: stream.pubkey,
host,
event: stream,
updated: stream.created_at,
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 ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
return nip19.naddrEncode({
identifier,
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">
{image && <Image src={image} alt={title} borderRadius="lg" />}
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={stream.author} size="sm" />
<UserAvatar pubkey={stream.host} size="sm" noProxy />
<Heading size="sm">
<UserLink pubkey={stream.author} />
<UserLink pubkey={stream.host} />
</Heading>
</Flex>
<Heading size="md">

View File

@@ -1,28 +1,32 @@
import { useEffect, useRef, useState } from "react";
import { useScroll } from "react-use";
import { Box, Button, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react";
import { Link as RouterLink, useParams, Navigate } from "react-router-dom";
import { Box, Button, ButtonGroup, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react";
import { Link as RouterLink, useParams, Navigate, useSearchParams } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { Global, css } from "@emotion/react";
import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream";
import { NostrRequest } from "../../../classes/nostr-request";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { unique } from "../../../helpers/array";
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 { UserLink } from "../../../components/user-link";
import { useIsMobile } from "../../../hooks/use-is-mobile";
import { AdditionalRelayProvider } from "../../../providers/additional-relay-context";
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 scrollBox = useRef<HTMLDivElement | null>(null);
const scrollState = useScroll(scrollBox);
const action =
const renderActions = () => {
const toggleButton =
scrollState.y === 0 ? (
<Button
size="sm"
@@ -37,23 +41,59 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
</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 (
<Flex
h="full"
overflowX="hidden"
overflowY="auto"
direction={isMobile ? "column" : "row"}
p={isMobile ? 0 : "2"}
p={isMobile || !!displayMode ? 0 : "2"}
gap={isMobile ? 0 : "4"}
ref={scrollBox}
>
{displayMode && (
<Global
styles={css`
body {
background: transparent;
}
`}
/>
)}
{!displayMode && (
<Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}>
<LiveVideoPlayer stream={stream.streaming} autoPlay poster={stream.image} maxH="100vh" />
<Flex gap={isMobile ? "2" : "4"} alignItems="center" p={isMobile ? "2" : 0}>
<UserAvatarLink pubkey={stream.author} />
<UserAvatarLink pubkey={stream.host} noProxy />
<Box>
<Heading size="md">
<UserLink pubkey={stream.author} />
<UserLink pubkey={stream.host} />
</Heading>
<Text>{stream.title}</Text>
</Box>
@@ -64,14 +104,16 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
</Flex>
<StreamSummaryContent stream={stream} px={isMobile ? "2" : 0} />
</Flex>
)}
<StreamChat
stream={stream}
flexGrow={1}
maxW={isMobile ? undefined : "lg"}
maxW={isMobile || !!displayMode ? undefined : "lg"}
maxH="100vh"
minH={isMobile ? "100vh" : undefined}
flexShrink={0}
actions={isMobile && action}
actions={renderActions()}
displayMode={displayMode}
/>
</Flex>
);
@@ -79,6 +121,9 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
export default function StreamView() {
const { naddr } = useParams();
const [params] = useSearchParams();
useSetColorMode();
if (!naddr) return <Navigate replace to="/streams" />;
const readRelays = useReadRelayUrls();
@@ -104,8 +149,11 @@ export default function StreamView() {
if (!stream) return <Spinner />;
return (
<AdditionalRelayProvider relays={relays}>
<StreamPage stream={stream} />
// add snort and damus relays so zap.stream will always see zaps
<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>
);
}

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} />
<Text ref={ref}>
<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} />
{": "}
</Text>

View File

@@ -35,16 +35,28 @@ import { useTimelineCurserIntersectionCallback } from "../../../../hooks/use-tim
import useSubject from "../../../../hooks/use-subject";
import { useTimelineLoader } from "../../../../hooks/use-timeline-loader";
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({
stream,
actions,
displayMode,
...props
}: CardProps & { stream: ParsedStream; actions?: React.ReactNode }) {
}: CardProps & { stream: ParsedStream; actions?: React.ReactNode; displayMode?: ChatDisplayMode }) {
const toast = useToast();
const contextRelays = useAdditionalRelayContext();
const readRelays = useReadRelayUrls(contextRelays);
const userReadRelays = useUserRelays(stream.author)
const hostReadRelays = useUserRelays(stream.host)
.filter((r) => r.mode & RelayMode.READ)
.map((r) => r.url);
@@ -67,7 +79,7 @@ export default function StreamChat({
const draft = buildChatMessage(stream, values.content);
const signed = await requestSignature(draft);
if (!signed) throw new Error("Failed to sign");
nostrPostAction(unique([...contextRelays, ...userReadRelays]), signed);
nostrPostAction(unique([...contextRelays, ...hostReadRelays]), signed);
reset();
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
@@ -76,17 +88,22 @@ export default function StreamChat({
const zapModal = useDisclosure();
const { requestPay } = useInvoiceModalContext();
const zapMetadata = useUserLNURLMetadata(stream.author);
const zapMetadata = useUserLNURLMetadata(stream.host);
const isPopup = !!displayMode;
const isChatLog = displayMode === "log";
return (
<>
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<ImageGalleryProvider>
<Card {...props} overflow="hidden">
<Card {...props} overflow="hidden" background={isChatLog ? "transparent" : undefined}>
{!isPopup && (
<CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center">
<Heading size="md">Stream Chat</Heading>
{actions}
</CardHeader>
)}
<CardBody display="flex" flexDirection="column" gap="2" overflow="hidden" p={0}>
<Flex
overflowY="scroll"
@@ -97,6 +114,7 @@ export default function StreamChat({
px="4"
py="2"
gap="2"
css={isChatLog && hideScrollbar}
>
{events.map((event) =>
event.kind === 1311 ? (
@@ -106,6 +124,7 @@ export default function StreamChat({
)
)}
</Flex>
{!isChatLog && (
<Box
as="form"
borderRadius="md"
@@ -130,6 +149,7 @@ export default function StreamChat({
/>
)}
</Box>
)}
</CardBody>
</Card>
</ImageGalleryProvider>
@@ -138,7 +158,7 @@ export default function StreamChat({
<ZapModal
isOpen
stream={stream}
pubkey={stream.author}
pubkey={stream.host}
onInvoice={async (invoice) => {
reset();
zapModal.onClose();
@@ -146,6 +166,7 @@ export default function StreamChat({
}}
onClose={zapModal.onClose}
initialComment={getValues().content}
additionalRelays={contextRelays}
/>
)}
</>