stream improvements

This commit is contained in:
hzrd149 2023-08-01 16:28:24 -05:00
parent 15023d8577
commit 69bea820a4
45 changed files with 314 additions and 204 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for playing back stream recordings

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Correctly handle replaceable events in timeline loader

View File

@ -35,6 +35,8 @@ import UserLikesTab from "./views/user/likes";
import useSetColorMode from "./hooks/use-set-color-mode"; import useSetColorMode from "./hooks/use-set-color-mode";
import UserStreamsTab from "./views/user/streams"; import UserStreamsTab from "./views/user/streams";
import { PageProviders } from "./providers"; import { PageProviders } from "./providers";
import { NostrRequest } from "./classes/nostr-request";
import { NostrEvent } from "./types/nostr-event";
const StreamsView = React.lazy(() => import("./views/streams")); const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream")); const StreamView = React.lazy(() => import("./views/streams/stream"));

View File

@ -0,0 +1,60 @@
import { getEventUID } from "../helpers/nostr/event";
import { NostrEvent } from "../types/nostr-event";
import Subject from "./subject";
type EventFilter = (event: NostrEvent) => boolean;
export default class EventStore {
name?: string;
events = new Map<string, NostrEvent>();
constructor(name?: string) {
this.name = name;
}
getSortedEvents() {
return Array.from(this.events.values()).sort((a, b) => b.created_at - a.created_at);
}
onEvent = new Subject<NostrEvent>();
onClear = new Subject();
addEvent(event: NostrEvent) {
const id = getEventUID(event);
const existing = this.events.get(id);
if (!existing || event.created_at > existing.created_at) {
this.events.set(id, event);
this.onEvent.next(event);
return true;
}
return false;
}
clear() {
this.events.clear();
this.onClear.next(null);
}
connect(other: EventStore) {
other.onEvent.subscribe(this.addEvent, this);
}
disconnect(other: EventStore) {
other.onEvent.unsubscribe(this.addEvent, this);
}
getFirstEvent(nth = 0, filter?: EventFilter) {
const events = this.getSortedEvents();
const filteredEvents = filter ? events.filter(filter) : events;
for (let i = 0; i <= nth; i++) {
const event = filteredEvents[i];
if (event) return event;
}
}
getLastEvent(nth = 0, filter?: EventFilter) {
const events = this.getSortedEvents();
const filteredEvents = filter ? events.filter(filter) : events;
for (let i = nth; i >= 0; i--) {
const event = filteredEvents[filteredEvents.length - 1 - i];
if (event) return event;
}
}
}

View File

@ -1,4 +1,4 @@
import { getReferences } from "../helpers/nostr-event"; import { getReferences } from "../helpers/nostr/event";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { NostrRequest } from "./nostr-request"; import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription"; import { NostrMultiSubscription } from "./nostr-multi-subscription";

View File

@ -1,12 +1,13 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { utils } from "nostr-tools"; import { utils } from "nostr-tools";
import debug, { Debug, Debugger } from "debug"; import { Debugger } from "debug";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { NostrQuery, NostrRequestFilter } from "../types/nostr-query"; import { NostrQuery, NostrRequestFilter } from "../types/nostr-query";
import { NostrRequest } from "./nostr-request"; import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription"; import { NostrMultiSubscription } from "./nostr-multi-subscription";
import Subject, { PersistentSubject } from "./subject"; import Subject, { PersistentSubject } from "./subject";
import { logger } from "../helpers/debug"; import { logger } from "../helpers/debug";
import EventStore from "./event-store";
function addToQuery(filter: NostrRequestFilter, query: NostrQuery) { function addToQuery(filter: NostrRequestFilter, query: NostrQuery) {
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
@ -28,11 +29,10 @@ class RelayTimelineLoader {
private log: Debugger; private log: Debugger;
loading = false; loading = false;
events: NostrEvent[] = []; events: EventStore;
/** set to true when the next block produces 0 events */ /** set to true when the next block produces 0 events */
complete = false; complete = false;
onEvent = new Subject<NostrEvent>();
onBlockFinish = new Subject<void>(); onBlockFinish = new Subject<void>();
constructor(relay: string, query: NostrRequestFilter, name: string, log?: Debugger) { constructor(relay: string, query: NostrRequestFilter, name: string, log?: Debugger) {
@ -41,19 +41,24 @@ class RelayTimelineLoader {
this.name = name; this.name = name;
this.log = log || logger.extend(name); this.log = log || logger.extend(name);
this.events = new EventStore(relay);
} }
loadNextBlock() { loadNextBlock() {
this.loading = true; this.loading = true;
let query: NostrRequestFilter = addToQuery(this.query, { limit: this.blockSize }); let query: NostrRequestFilter = addToQuery(this.query, { limit: this.blockSize });
if (this.events[this.events.length - 1]) { let oldestEvent = this.getLastEvent();
query = addToQuery(query, { until: this.events[this.events.length - 1].created_at - 1 }); if (oldestEvent) {
query = addToQuery(query, { until: oldestEvent.created_at - 1 });
} }
const request = new NostrRequest([this.relay], undefined, this.name + "-" + this.requestId++); const request = new NostrRequest([this.relay], undefined, this.name + "-" + this.requestId++);
let gotEvents = 0; let gotEvents = 0;
request.onEvent.subscribe((e) => { request.onEvent.subscribe((e) => {
// if(oldestEvent && e.created_at<oldestEvent.created_at){
// this.log('Got event older than oldest')
// }
if (this.handleEvent(e)) { if (this.handleEvent(e)) {
gotEvents++; gotEvents++;
} }
@ -68,23 +73,12 @@ class RelayTimelineLoader {
request.start(query); request.start(query);
} }
private seenEvents = new Set<string>();
private handleEvent(event: NostrEvent) { private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) { return this.events.addEvent(event);
this.seenEvents.add(event.id);
this.events = utils.insertEventIntoDescendingList(Array.from(this.events), event);
this.onEvent.next(event);
return true;
}
return false;
} }
getLastEvent(nth = 0, filter?: EventFilter) { getLastEvent(nth = 0, filter?: EventFilter) {
const events = filter ? this.events.filter(filter) : this.events; return this.events.getLastEvent(nth, filter);
for (let i = nth; i >= 0; i--) {
const event = events[events.length - 1 - i];
if (event) return event;
}
} }
} }
@ -93,7 +87,7 @@ export class TimelineLoader {
query?: NostrRequestFilter; query?: NostrRequestFilter;
relays: string[] = []; relays: string[] = [];
events = new PersistentSubject<NostrEvent[]>([]); events: EventStore;
timeline = new PersistentSubject<NostrEvent[]>([]); timeline = new PersistentSubject<NostrEvent[]>([]);
loading = new PersistentSubject(false); loading = new PersistentSubject(false);
complete = new PersistentSubject(false); complete = new PersistentSubject(false);
@ -110,20 +104,23 @@ export class TimelineLoader {
constructor(name: string) { constructor(name: string) {
this.name = name; this.name = name;
this.log = logger.extend("TimelineLoader:" + name); this.log = logger.extend("TimelineLoader:" + name);
this.events = new EventStore(name);
this.subscription = new NostrMultiSubscription([], undefined, name); this.subscription = new NostrMultiSubscription([], undefined, name);
this.subscription.onEvent.subscribe(this.handleEvent, this); this.subscription.onEvent.subscribe(this.handleEvent, this);
// update the timeline when there are new events
this.events.onEvent.subscribe(this.updateTimeline, this);
this.events.onClear.subscribe(this.updateTimeline, this);
} }
private seenEvents = new Set<string>(); private updateTimeline() {
if (this.eventFilter) {
this.timeline.next(this.events.getSortedEvents().filter(this.eventFilter));
} else this.timeline.next(this.events.getSortedEvents());
}
private handleEvent(event: NostrEvent) { private handleEvent(event: NostrEvent) {
if (!this.seenEvents.has(event.id)) { this.events.addEvent(event);
this.seenEvents.add(event.id);
this.events.next(utils.insertEventIntoDescendingList(Array.from(this.events.value), event));
if (!this.eventFilter || this.eventFilter(event)) {
this.timeline.next(utils.insertEventIntoDescendingList(Array.from(this.timeline.value), event));
}
}
} }
private createLoaders() { private createLoaders() {
@ -133,7 +130,7 @@ export class TimelineLoader {
if (!this.relayTimelineLoaders.has(relay)) { if (!this.relayTimelineLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query, this.name, this.log.extend(relay)); const loader = new RelayTimelineLoader(relay, this.query, this.name, this.log.extend(relay));
this.relayTimelineLoaders.set(relay, loader); this.relayTimelineLoaders.set(relay, loader);
loader.onEvent.subscribe(this.handleEvent, this); this.events.connect(loader.events);
loader.onBlockFinish.subscribe(this.updateLoading, this); loader.onBlockFinish.subscribe(this.updateLoading, this);
loader.onBlockFinish.subscribe(this.updateComplete, this); loader.onBlockFinish.subscribe(this.updateComplete, this);
} }
@ -142,9 +139,9 @@ export class TimelineLoader {
private removeLoaders(filter?: (loader: RelayTimelineLoader) => boolean) { private removeLoaders(filter?: (loader: RelayTimelineLoader) => boolean) {
for (const [relay, loader] of this.relayTimelineLoaders) { for (const [relay, loader] of this.relayTimelineLoaders) {
if (!filter || filter(loader)) { if (!filter || filter(loader)) {
loader?.onEvent.unsubscribe(this.handleEvent, this); this.events.disconnect(loader.events);
loader?.onBlockFinish.unsubscribe(this.updateLoading, this); loader.onBlockFinish.unsubscribe(this.updateLoading, this);
loader?.onBlockFinish.unsubscribe(this.updateComplete, this); loader.onBlockFinish.unsubscribe(this.updateComplete, this);
this.relayTimelineLoaders.delete(relay); this.relayTimelineLoaders.delete(relay);
} }
} }
@ -168,9 +165,8 @@ export class TimelineLoader {
this.removeLoaders(); this.removeLoaders();
this.query = query; this.query = query;
this.events.next([]); this.events.clear();
this.timeline.next([]); this.timeline.next([]);
this.seenEvents.clear();
this.createLoaders(); this.createLoaders();
this.updateComplete(); this.updateComplete();
@ -181,9 +177,7 @@ export class TimelineLoader {
} }
setFilter(filter?: (event: NostrEvent) => boolean) { setFilter(filter?: (event: NostrEvent) => boolean) {
this.eventFilter = filter; this.eventFilter = filter;
if (this.eventFilter) { this.updateTimeline();
this.timeline.next(this.events.value.filter(this.eventFilter));
}
} }
setCursor(cursor: number) { setCursor(cursor: number) {
@ -250,9 +244,8 @@ export class TimelineLoader {
// TODO: this is only needed because the current logic dose not remove events when the relay they where fetched from is removed // TODO: this is only needed because the current logic dose not remove events when the relay they where fetched from is removed
/** @deprecated */ /** @deprecated */
forgetEvents() { forgetEvents() {
this.events.next([]); this.events.clear();
this.timeline.next([]); this.timeline.next([]);
this.seenEvents.clear();
this.subscription.forgetEvents(); this.subscription.forgetEvents();
} }
} }

View File

@ -1,7 +1,7 @@
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton, Flex } from "@chakra-ui/react"; import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton, Flex } from "@chakra-ui/react";
import { ModalProps } from "@chakra-ui/react"; import { ModalProps } from "@chakra-ui/react";
import { Bech32Prefix, hexToBech32 } from "../../helpers/nip19"; import { Bech32Prefix, hexToBech32 } from "../../helpers/nip19";
import { getReferences } from "../../helpers/nostr-event"; import { getReferences } from "../../helpers/nostr/event";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import RawJson from "./raw-json"; import RawJson from "./raw-json";
import RawValue from "./raw-value"; import RawValue from "./raw-value";

View File

@ -3,7 +3,7 @@ import { Link as RouterLink, useLocation } from "react-router-dom";
import { UserAvatar } from "../user-avatar"; import { UserAvatar } from "../user-avatar";
import { useUserMetadata } from "../../hooks/use-user-metadata"; import { useUserMetadata } from "../../hooks/use-user-metadata";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19"; import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { truncatedId } from "../../helpers/nostr-event"; import { truncatedId } from "../../helpers/nostr/event";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
function ProfileButton() { function ProfileButton() {

View File

@ -44,15 +44,6 @@ export function LiveVideoPlayer({
return ( return (
<Flex justifyContent="center" alignItems="center" {...props} position="relative"> <Flex justifyContent="center" alignItems="center" {...props} position="relative">
<Badge
position="absolute"
top="4"
left="4"
fontSize="1.2rem"
colorScheme={status === VideoStatus.Offline ? "red" : undefined}
>
{status}
</Badge>
<video <video
ref={video} ref={video}
playsInline={true} playsInline={true}

View File

@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Link, LinkProps } from "@chakra-ui/react"; import { Link, LinkProps } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { truncatedId } from "../helpers/nostr-event"; import { truncatedId } from "../helpers/nostr/event";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { getSharableNoteId } from "../helpers/nip19"; import { getSharableNoteId } from "../helpers/nip19";

View File

@ -3,7 +3,7 @@ import { IconButton } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event"; import { NostrEvent } from "../../../types/nostr-event";
import { QuoteRepostIcon } from "../../icons"; import { QuoteRepostIcon } from "../../icons";
import { PostModalContext } from "../../../providers/post-modal-provider"; import { PostModalContext } from "../../../providers/post-modal-provider";
import { buildQuoteRepost } from "../../../helpers/nostr-event"; import { buildQuoteRepost } from "../../../helpers/nostr/event";
import { useCurrentAccount } from "../../../hooks/use-current-account"; import { useCurrentAccount } from "../../../hooks/use-current-account";
export function QuoteRepostButton({ event }: { event: NostrEvent }) { export function QuoteRepostButton({ event }: { event: NostrEvent }) {

View File

@ -3,7 +3,7 @@ import { IconButton } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event"; import { NostrEvent } from "../../../types/nostr-event";
import { ReplyIcon } from "../../icons"; import { ReplyIcon } from "../../icons";
import { PostModalContext } from "../../../providers/post-modal-provider"; import { PostModalContext } from "../../../providers/post-modal-provider";
import { buildReply } from "../../../helpers/nostr-event"; import { buildReply } from "../../../helpers/nostr/event";
import { useCurrentAccount } from "../../../hooks/use-current-account"; import { useCurrentAccount } from "../../../hooks/use-current-account";
export function ReplyButton({ event }: { event: NostrEvent }) { export function ReplyButton({ event }: { event: NostrEvent }) {

View File

@ -15,7 +15,7 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event"; import { NostrEvent } from "../../../types/nostr-event";
import { RepostIcon } from "../../icons"; import { RepostIcon } from "../../icons";
import { buildRepost } from "../../../helpers/nostr-event"; import { buildRepost } from "../../../helpers/nostr/event";
import { useCurrentAccount } from "../../../hooks/use-current-account"; import { useCurrentAccount } from "../../../hooks/use-current-account";
import { nostrPostAction } from "../../../classes/nostr-post-action"; import { nostrPostAction } from "../../../classes/nostr-post-action";
import clientRelaysService from "../../../services/client-relays"; import clientRelaysService from "../../../services/client-relays";

View File

@ -24,7 +24,7 @@ import NoteDebugModal from "../debug-modals/note-debug-modal";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import QuoteNote from "./quote-note"; import QuoteNote from "./quote-note";
import { buildDeleteEvent } from "../../helpers/nostr-event"; import { buildDeleteEvent } from "../../helpers/nostr/event";
import signingService from "../../services/signing"; import signingService from "../../services/signing";
import { nostrPostAction } from "../../classes/nostr-post-action"; import { nostrPostAction } from "../../classes/nostr-post-action";
import clientRelaysService from "../../services/client-relays"; import clientRelaysService from "../../services/client-relays";

View File

@ -1,10 +1,10 @@
import { memo } from "react"; import { memo } from "react";
import { IconButtonProps } from "@chakra-ui/react";
import { getEventRelays } from "../../services/event-relays"; import { getEventRelays } from "../../services/event-relays";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { RelayIconStack } from "../relay-icon-stack"; import { RelayIconStack } from "../relay-icon-stack";
import { useIsMobile } from "../../hooks/use-is-mobile"; import { useIsMobile } from "../../hooks/use-is-mobile";
import { getEventUID } from "../../helpers/nostr/event";
export type NoteRelaysProps = { export type NoteRelaysProps = {
event: NostrEvent; event: NostrEvent;
@ -12,7 +12,7 @@ export type NoteRelaysProps = {
export const NoteRelays = memo(({ event }: NoteRelaysProps) => { export const NoteRelays = memo(({ event }: NoteRelaysProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const eventRelays = useSubject(getEventRelays(event.id)); const eventRelays = useSubject(getEventRelays(getEventUID(event)));
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={isMobile ? 4 : undefined} />; return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={isMobile ? 4 : undefined} />;
}); });

View File

@ -17,7 +17,7 @@ import React, { useRef, useState } from "react";
import { useList } from "react-use"; import { useList } from "react-use";
import { nostrPostAction, PostResult } from "../../classes/nostr-post-action"; import { nostrPostAction, PostResult } from "../../classes/nostr-post-action";
import { normalizeToHex } from "../../helpers/nip19"; import { normalizeToHex } from "../../helpers/nip19";
import { getReferences } from "../../helpers/nostr-event"; import { getReferences } from "../../helpers/nostr/event";
import { matchHashtag, mentionNpubOrNote } from "../../helpers/regexp"; import { matchHashtag, mentionNpubOrNote } from "../../helpers/regexp";
import { useWriteRelayUrls } from "../../hooks/use-client-relays"; import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useIsMobile } from "../../hooks/use-is-mobile"; import { useIsMobile } from "../../hooks/use-is-mobile";

View File

@ -1,12 +1,13 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getEventRelays } from "../services/event-relays"; import { getEventRelays } from "../../services/event-relays";
import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag, Tag } from "../types/nostr-event"; import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event";
import { RelayConfig, RelayMode } from "../classes/relay"; import { RelayConfig, RelayMode } from "../../classes/relay";
import accountService from "../services/account"; import accountService from "../../services/account";
import { Kind, nip19 } from "nostr-tools"; import { Kind, nip19 } from "nostr-tools";
import { matchNostrLink } from "./regexp"; import { matchNostrLink } from "../regexp";
import { getSharableNoteId } from "./nip19"; import { getSharableNoteId } from "../nip19";
import relayScoreboardService from "../services/relay-scoreboard"; import relayScoreboardService from "../../services/relay-scoreboard";
import { getAddr } from "../../services/replaceable-event-requester";
export function isReply(event: NostrEvent | DraftNostrEvent) { export function isReply(event: NostrEvent | DraftNostrEvent) {
return event.kind === 1 && !!getReferences(event).replyId; return event.kind === 1 && !!getReferences(event).replyId;
@ -22,6 +23,14 @@ export function truncatedId(str: string, keep = 6) {
return str.substring(0, keep) + "..." + str.substring(str.length - keep); return str.substring(0, keep) + "..." + str.substring(str.length - keep);
} }
// used to get a unique Id for each event, should take into account replaceable events
export function getEventUID(event: NostrEvent) {
if (event.kind >= 30000 && event.kind < 40000) {
return getAddr(event.kind, event.pubkey, event.tags.find((t) => t[0] === "d" && t[1])?.[1]);
}
return event.id;
}
/** /**
* returns an array of tag indexes that are referenced in the content * returns an array of tag indexes that are referenced in the content
* either with the legacy #[0] syntax or nostr:xxxxx links * either with the legacy #[0] syntax or nostr:xxxxx links

View File

@ -1,6 +1,7 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event"; import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
import { unique } from "../array"; import { unique } from "../array";
import { getAddr } from "../../services/replaceable-event-requester";
export const STREAM_KIND = 30311; export const STREAM_KIND = 30311;
export const STREAM_CHAT_MESSAGE_KIND = 1311; export const STREAM_CHAT_MESSAGE_KIND = 1311;
@ -18,7 +19,9 @@ export type ParsedStream = {
ends?: number; ends?: number;
identifier: string; identifier: string;
tags: string[]; tags: string[];
streaming: string; streaming?: string;
recording?: string;
relays?: string[];
}; };
export function parseStreamEvent(stream: NostrEvent): ParsedStream { export function parseStreamEvent(stream: NostrEvent): ParsedStream {
@ -28,16 +31,23 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
const starts = stream.tags.find((t) => t[0] === "starts")?.[1]; const starts = stream.tags.find((t) => t[0] === "starts")?.[1];
const endsTag = stream.tags.find((t) => t[0] === "ends")?.[1]; const endsTag = stream.tags.find((t) => t[0] === "ends")?.[1];
const streaming = stream.tags.find((t) => t[0] === "streaming")?.[1]; const streaming = stream.tags.find((t) => t[0] === "streaming")?.[1];
const recording = stream.tags.find((t) => t[0] === "recording")?.[1];
const identifier = stream.tags.find((t) => t[0] === "d")?.[1]; const identifier = stream.tags.find((t) => t[0] === "d")?.[1];
let relays = stream.tags.find((t) => t[0] === "relays");
// remove the first "relays" element
if (relays) {
relays = Array.from(relays);
relays.shift();
}
const startTime = starts ? parseInt(starts) : stream.created_at; const startTime = starts ? parseInt(starts) : stream.created_at;
const endTime = endsTag ? parseInt(endsTag) : dayjs(startTime).add(4, "hour").unix(); const endTime = endsTag ? parseInt(endsTag) : undefined;
if (!identifier) throw new Error("missing identifier"); if (!identifier) throw new Error("missing identifier");
if (!streaming) throw new Error("missing streaming");
let status = stream.tags.find((t) => t[0] === "status")?.[1] || "ended"; let status = stream.tags.find((t) => t[0] === "status")?.[1] || "ended";
if (endTime > dayjs().unix()) { if (endTime && endTime > dayjs().unix()) {
status = "ended"; status = "ended";
} }
@ -55,6 +65,7 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
event: stream, event: stream,
updated: stream.created_at, updated: stream.created_at,
streaming, streaming,
recording,
tags, tags,
title, title,
summary, summary,
@ -63,11 +74,12 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
starts: startTime, starts: startTime,
ends: endTime, ends: endTime,
identifier, identifier,
relays,
}; };
} }
export function getATag(stream: ParsedStream) { export function getATag(stream: ParsedStream) {
return `${stream.event.kind}:${stream.author}:${stream.identifier}`; return getAddr(stream.event.kind, stream.author, stream.identifier);
} }
export function buildChatMessage(stream: ParsedStream, content: string) { export function buildChatMessage(stream: ParsedStream, content: string) {

View File

@ -1,5 +1,5 @@
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { EventReferences, getReferences } from "./nostr-event"; import { EventReferences, getReferences } from "./nostr/event";
export function countReplies(thread: ThreadItem): number { export function countReplies(thread: ThreadItem): number {
return thread.replies.reduce((c, item) => c + countReplies(item), 0) + thread.replies.length; return thread.replies.reduce((c, item) => c + countReplies(item), 0) + thread.replies.length;

View File

@ -1,6 +1,6 @@
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { Bech32Prefix, normalizeToBech32 } from "./nip19"; import { Bech32Prefix, normalizeToBech32 } from "./nip19";
import { truncatedId } from "./nostr-event"; import { truncatedId } from "./nostr/event";
export type Kind0ParsedContent = { export type Kind0ParsedContent = {
name?: string; name?: string;

View File

@ -4,10 +4,10 @@ import { nip19 } from "nostr-tools";
import { getEventRelays } from "../services/event-relays"; import { getEventRelays } from "../services/event-relays";
import relayScoreboardService from "../services/relay-scoreboard"; import relayScoreboardService from "../services/relay-scoreboard";
export default function useEventNaddr(event: NostrEvent) { export default function useEventNaddr(event: NostrEvent, overrideRelays?: string[]) {
return useMemo(() => { return useMemo(() => {
const identifier = event.tags.find((t) => t[0] === "d" && t[1])?.[1]; const identifier = event.tags.find((t) => t[0] === "d" && t[1])?.[1];
const relays = getEventRelays(event.id).value; const relays = overrideRelays || getEventRelays(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);

View File

@ -1,5 +1,5 @@
import { PropsWithChildren, createContext, useContext, useEffect, useMemo } from "react"; import { PropsWithChildren, createContext, useContext, useEffect, useMemo } from "react";
import { truncatedId } from "../helpers/nostr-event"; import { truncatedId } from "../helpers/nostr/event";
import { useReadRelayUrls } from "../hooks/use-client-relays"; import { useReadRelayUrls } from "../hooks/use-client-relays";
import { useCurrentAccount } from "../hooks/use-current-account"; import { useCurrentAccount } from "../hooks/use-current-account";
import { TimelineLoader } from "../classes/timeline-loader"; import { TimelineLoader } from "../classes/timeline-loader";

View File

@ -2,7 +2,7 @@ import { Kind } from "nostr-tools";
import { NostrRequest } from "../classes/nostr-request"; import { NostrRequest } from "../classes/nostr-request";
import Subject from "../classes/subject"; import Subject from "../classes/subject";
import { SuperMap } from "../classes/super-map"; import { SuperMap } from "../classes/super-map";
import { getReferences } from "../helpers/nostr-event"; import { getReferences } from "../helpers/nostr/event";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
type eventId = string; type eventId = string;

View File

@ -1,5 +1,6 @@
import { Relay } from "../classes/relay"; import { Relay } from "../classes/relay";
import { PersistentSubject } from "../classes/subject"; import { PersistentSubject } from "../classes/subject";
import { getEventUID } from "../helpers/nostr/event";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import relayPoolService from "./relay-pool"; import relayPoolService from "./relay-pool";
@ -14,14 +15,21 @@ export function getEventRelays(id: string) {
return relays; return relays;
} }
export function handleEventFromRelay(relay: Relay, event: NostrEvent) { function addRelay(id: string, relay: string) {
const relays = getEventRelays(event.id); const relays = getEventRelays(id);
if (!relays.value.includes(relay.url)) { if (!relays.value.includes(relay)) {
relays.next(relays.value.concat(relay.url)); relays.next(relays.value.concat(relay));
} }
} }
export function handleEventFromRelay(relay: Relay, event: NostrEvent) {
const uid = getEventUID(event);
addRelay(uid, relay.url);
if (event.id !== uid) addRelay(event.id, relay.url);
}
relayPoolService.onRelayCreated.subscribe((relay) => { relayPoolService.onRelayCreated.subscribe((relay) => {
relay.onEvent.subscribe(({ body: event }) => { relay.onEvent.subscribe(({ body: event }) => {
handleEventFromRelay(relay, event); handleEventFromRelay(relay, event);

View File

@ -2,7 +2,7 @@ import { Kind } from "nostr-tools";
import { NostrRequest } from "../classes/nostr-request"; import { NostrRequest } from "../classes/nostr-request";
import Subject from "../classes/subject"; import Subject from "../classes/subject";
import { SuperMap } from "../classes/super-map"; import { SuperMap } from "../classes/super-map";
import { getReferences } from "../helpers/nostr-event"; import { getReferences } from "../helpers/nostr/event";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
type eventId = string; type eventId = string;

View File

@ -1,6 +1,6 @@
import { isRTag, NostrEvent } from "../types/nostr-event"; import { isRTag, NostrEvent } from "../types/nostr-event";
import { RelayConfig } from "../classes/relay"; import { RelayConfig } from "../classes/relay";
import { parseRTag } from "../helpers/nostr-event"; import { parseRTag } from "../helpers/nostr/event";
import { SuperMap } from "../classes/super-map"; import { SuperMap } from "../classes/super-map";
import Subject from "../classes/subject"; import Subject from "../classes/subject";
import { normalizeRelayConfigs } from "../helpers/relay"; import { normalizeRelayConfigs } from "../helpers/relay";

View File

@ -18,7 +18,7 @@ import { CloseIcon } from "@chakra-ui/icons";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useAppTitle } from "../../hooks/use-app-title"; import { useAppTitle } from "../../hooks/use-app-title";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isReply } from "../../helpers/nostr-event"; import { isReply } from "../../helpers/nostr/event";
import { CheckIcon, EditIcon } from "../../components/icons"; import { CheckIcon, EditIcon } from "../../components/icons";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button"; import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";

View File

@ -1,6 +1,6 @@
import { Flex, FormControl, FormLabel, Switch } from "@chakra-ui/react"; import { Flex, FormControl, FormLabel, Switch } from "@chakra-ui/react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { isReply, truncatedId } from "../../helpers/nostr-event"; import { isReply, truncatedId } from "../../helpers/nostr/event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts"; import { useUserContacts } from "../../hooks/use-user-contacts";
import { useCallback } from "react"; import { useCallback } from "react";

View File

@ -1,6 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react"; import { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react";
import { isReply } from "../../helpers/nostr-event"; import { isReply } from "../../helpers/nostr/event";
import { useAppTitle } from "../../hooks/use-app-title"; import { useAppTitle } from "../../hooks/use-app-title";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";

View File

@ -16,7 +16,7 @@ import { DraftNostrEvent } from "../../types/nostr-event";
import RequireCurrentAccount from "../../providers/require-current-account"; import RequireCurrentAccount from "../../providers/require-current-account";
import { Message } from "./message"; import { Message } from "./message";
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 { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
import IntersectionObserverProvider from "../../providers/intersection-observer"; import IntersectionObserverProvider from "../../providers/intersection-observer";

View File

@ -15,7 +15,7 @@ import { useNotificationTimeline } from "../../providers/notification-timeline";
import { Kind, getEventHash } from "nostr-tools"; import { Kind, getEventHash } from "nostr-tools";
import { parseZapEvent } from "../../helpers/zaps"; import { parseZapEvent } from "../../helpers/zaps";
import { readablizeSats } from "../../helpers/bolt11"; import { readablizeSats } from "../../helpers/bolt11";
import { getReferences } from "../../helpers/nostr-event"; import { getReferences } from "../../helpers/nostr/event";
const Kind1Notification = ({ event }: { event: NostrEvent }) => ( const Kind1Notification = ({ event }: { event: NostrEvent }) => (
<Card size="sm" variant="outline"> <Card size="sm" variant="outline">

View File

@ -19,7 +19,7 @@ import { ClipboardIcon, LightningIcon, QrCodeIcon } from "../../components/icons
import { UserAvatarLink } from "../../components/user-avatar-link"; import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon"; import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import ZapModal from "../../components/zap-modal"; import ZapModal from "../../components/zap-modal";
import { truncatedId } from "../../helpers/nostr-event"; import { truncatedId } from "../../helpers/nostr/event";
import QrScannerModal from "../../components/qr-scanner-modal"; import QrScannerModal from "../../components/qr-scanner-modal";
import { safeDecode } from "../../helpers/nip19"; import { safeDecode } from "../../helpers/nip19";
import { useInvoiceModalContext } from "../../providers/invoice-modal"; import { useInvoiceModalContext } from "../../providers/invoice-modal";

View File

@ -1,4 +1,4 @@
import { useMemo, useRef } from "react"; import { useRef } from "react";
import { ParsedStream } from "../../../helpers/nostr/stream"; import { ParsedStream } from "../../../helpers/nostr/stream";
import { import {
Badge, Badge,
@ -9,44 +9,32 @@ import {
Divider, Divider,
Flex, Flex,
Heading, Heading,
IconButton,
Image, Image,
LinkBox, LinkBox,
LinkOverlay, LinkOverlay,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spacer, Spacer,
Text, Text,
useDisclosure,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { UserAvatar } from "../../../components/user-avatar"; import { UserAvatar } from "../../../components/user-avatar";
import { UserLink } from "../../../components/user-link"; import { UserLink } from "../../../components/user-link";
import dayjs from "dayjs"; import dayjs from "dayjs";
import StreamStatusBadge from "./status-badge"; import StreamStatusBadge from "./status-badge";
import { CodeIcon } from "../../../components/icons";
import RawValue from "../../../components/debug-modals/raw-value";
import RawJson from "../../../components/debug-modals/raw-json";
import { NoteRelays } from "../../../components/note/note-relays"; import { NoteRelays } from "../../../components/note/note-relays";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import useEventNaddr from "../../../hooks/use-event-naddr"; import useEventNaddr from "../../../hooks/use-event-naddr";
import StreamDebugButton from "./stream-debug-button";
export default function StreamCard({ stream, ...props }: CardProps & { stream: ParsedStream }) { export default function StreamCard({ stream, ...props }: CardProps & { stream: ParsedStream }) {
const { title, identifier, image } = stream; const { title, image } = stream;
const devModal = useDisclosure();
// if there is a parent intersection observer, register this card // if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, stream.event.id); useRegisterIntersectionEntity(ref, stream.event.id);
const naddr = useEventNaddr(stream.event); const naddr = useEventNaddr(stream.event, stream.relays);
return ( return (
<>
<Card {...props} ref={ref}> <Card {...props} ref={ref}>
<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" />}
@ -75,31 +63,8 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P
<StreamStatusBadge stream={stream} /> <StreamStatusBadge stream={stream} />
<Spacer /> <Spacer />
<NoteRelays event={stream.event} /> <NoteRelays event={stream.event} />
<IconButton <StreamDebugButton stream={stream} variant="ghost" size="sm" />
icon={<CodeIcon />}
aria-label="show raw event"
onClick={devModal.onOpen}
variant="ghost"
size="sm"
/>
</CardFooter> </CardFooter>
</Card> </Card>
<Modal isOpen={devModal.isOpen} onClose={devModal.onClose} size="6xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Raw event</ModalHeader>
<ModalCloseButton />
<ModalBody p="4">
<Flex gap="2" direction="column">
<RawValue heading="Event Id" value={stream.event.id} />
<RawValue heading="naddr" value={naddr} />
<RawJson heading="Parsed" json={{ ...stream, event: "Omitted, see JSON below" }} />
<RawJson heading="JSON" json={stream.event} />
</Flex>
</ModalBody>
</ModalContent>
</Modal>
</>
); );
} }

View File

@ -0,0 +1,47 @@
import {
Flex,
IconButton,
IconButtonProps,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
useDisclosure,
} from "@chakra-ui/react";
import { CodeIcon } from "../../../components/icons";
import RawValue from "../../../components/debug-modals/raw-value";
import RawJson from "../../../components/debug-modals/raw-json";
import { ParsedStream } from "../../../helpers/nostr/stream";
import useEventNaddr from "../../../hooks/use-event-naddr";
export default function StreamDebugButton({
stream,
...props
}: { stream: ParsedStream } & Omit<IconButtonProps, "icon" | "aria-label">) {
const debugModal = useDisclosure();
const naddr = useEventNaddr(stream.event);
return (
<>
<IconButton icon={<CodeIcon />} aria-label="Show raw event" onClick={debugModal.onOpen} {...props} />
<Modal isOpen={debugModal.isOpen} onClose={debugModal.onClose} size="6xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Raw event</ModalHeader>
<ModalCloseButton />
<ModalBody p="4">
<Flex gap="2" direction="column">
<RawValue heading="Event Id" value={stream.event.id} />
<RawValue heading="naddr" value={naddr} />
<RawJson heading="Parsed" json={{ ...stream, event: "Omitted, see JSON below" }} />
<RawJson heading="JSON" json={stream.event} />
</Flex>
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@ -5,7 +5,7 @@ import IntersectionObserverProvider from "../../providers/intersection-observer"
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import StreamCard from "./components/stream-card"; import StreamCard from "./components/stream-card";
import { ParsedStream, STREAM_KIND, getATag, parseStreamEvent } from "../../helpers/nostr/stream"; import { ParsedStream, STREAM_KIND, parseStreamEvent } from "../../helpers/nostr/stream";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button"; import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider"; import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
@ -15,8 +15,7 @@ import PeopleListProvider, { usePeopleListContext } from "../../components/peopl
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
function StreamsPage() { function StreamsPage() {
// hard code damus and snort relays for finding streams const relays = useRelaySelectionRelays();
const readRelays = useRelaySelectionRelays(); //useReadRelayUrls(["wss://relay.damus.io", "wss://relay.snort.social"]);
const [filterStatus, setFilterStatus] = useState<string>("live"); const [filterStatus, setFilterStatus] = useState<string>("live");
const eventFilter = useCallback( const eventFilter = useCallback(
@ -38,25 +37,22 @@ function StreamsPage() {
{ "#p": people, kinds: [STREAM_KIND] }, { "#p": people, kinds: [STREAM_KIND] },
] ]
: { kinds: [STREAM_KIND] }; : { kinds: [STREAM_KIND] };
const timeline = useTimelineLoader(`streams`, readRelays, query, { eventFilter }); const timeline = useTimelineLoader(`streams`, relays, query, { eventFilter });
useRelaysChanged(readRelays, () => timeline.reset()); useRelaysChanged(relays, () => timeline.reset());
const callback = useTimelineCurserIntersectionCallback(timeline); const callback = useTimelineCurserIntersectionCallback(timeline);
const events = useSubject(timeline.timeline); const events = useSubject(timeline.timeline);
const streams = useMemo(() => { const streams = useMemo(() => {
const parsedStreams: Record<string, ParsedStream> = {}; const parsedStreams: ParsedStream[] = [];
for (const event of events) { for (const event of events) {
try { try {
const parsed = parseStreamEvent(event); const parsed = parseStreamEvent(event);
const aTag = getATag(parsed); parsedStreams.push(parsed);
if (!parsedStreams[aTag] || parsed.event.created_at > parsedStreams[aTag].event.created_at) {
parsedStreams[aTag] = parsed;
}
} catch (e) {} } catch (e) {}
} }
return Array.from(Object.values(parsedStreams)).sort((a, b) => (b.starts ?? 0) - (a.starts ?? 0)); return parsedStreams.sort((a, b) => (b.starts ?? 0) - (a.starts ?? 0));
}, [events]); }, [events]);
return ( return (
@ -83,7 +79,7 @@ function StreamsPage() {
export default function StreamsView() { export default function StreamsView() {
return ( return (
<RelaySelectionProvider <RelaySelectionProvider
additionalDefaults={["wss://nos.lol", "wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"]} overrideDefault={["wss://nos.lol", "wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"]}
> >
<PeopleListProvider> <PeopleListProvider>
<StreamsPage /> <StreamsPage />

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useScroll } from "react-use"; import { useScroll } from "react-use";
import { Box, Button, ButtonGroup, 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, useSearchParams } from "react-router-dom"; import { Link as RouterLink, useParams, Navigate, useSearchParams } from "react-router-dom";
@ -6,7 +6,6 @@ import { nip19 } from "nostr-tools";
import { Global, css } from "@emotion/react"; import { Global, css } from "@emotion/react";
import { ParsedStream, STREAM_KIND, parseStreamEvent } from "../../../helpers/nostr/stream"; import { ParsedStream, STREAM_KIND, parseStreamEvent } from "../../../helpers/nostr/stream";
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";
@ -14,12 +13,15 @@ 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 StreamSummaryContent from "../components/stream-summary-content"; import StreamSummaryContent from "../components/stream-summary-content";
import { ArrowDownSIcon, ArrowUpSIcon, ExternalLinkIcon } from "../../../components/icons"; import { ArrowDownSIcon, ArrowUpSIcon, ExternalLinkIcon } from "../../../components/icons";
import useSetColorMode from "../../../hooks/use-set-color-mode"; import useSetColorMode from "../../../hooks/use-set-color-mode";
import { CopyIconButton } from "../../../components/copy-icon-button"; import { CopyIconButton } from "../../../components/copy-icon-button";
import { NoteRelays } from "../../../components/note/note-relays"; import StreamDebugButton from "../components/stream-debug-button";
import replaceableEventLoaderService from "../../../services/replaceable-event-requester";
import useSubject from "../../../hooks/use-subject";
import RelaySelectionButton from "../../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider from "../../../providers/relay-selection-provider";
function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode?: ChatDisplayMode }) { function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode?: ChatDisplayMode }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -91,7 +93,12 @@ function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode
)} )}
{!displayMode && ( {!displayMode && (
<Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}> <Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}>
<LiveVideoPlayer stream={stream.streaming} autoPlay poster={stream.image} maxH="100vh" /> <LiveVideoPlayer
stream={stream.streaming || stream.recording}
autoPlay={!!stream.streaming}
poster={stream.image}
maxH="100vh"
/>
<Flex gap={isMobile ? "2" : "4"} alignItems="center" p={isMobile ? "2" : 0}> <Flex gap={isMobile ? "2" : "4"} alignItems="center" p={isMobile ? "2" : 0}>
<UserAvatarLink pubkey={stream.host} noProxy /> <UserAvatarLink pubkey={stream.host} noProxy />
<Box> <Box>
@ -101,7 +108,8 @@ function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode
<Text>{stream.title}</Text> <Text>{stream.title}</Text>
</Box> </Box>
<Spacer /> <Spacer />
<NoteRelays event={stream.event} /> <StreamDebugButton stream={stream} variant="ghost" />
<RelaySelectionButton />
<Button as={RouterLink} to="/streams"> <Button as={RouterLink} to="/streams">
Back Back
</Button> </Button>
@ -131,33 +139,40 @@ export default function StreamView() {
if (!naddr) return <Navigate replace to="/streams" />; if (!naddr) return <Navigate replace to="/streams" />;
const readRelays = useReadRelayUrls(); const readRelays = useReadRelayUrls();
const [stream, setStream] = useState<ParsedStream>(); const [streamRelays, setStreamRelays] = useState<string[]>([]);
const [relays, setRelays] = useState<string[]>([]);
useEffect(() => { const subject = useMemo(() => {
try { try {
const parsed = nip19.decode(naddr); const parsed = nip19.decode(naddr);
if (parsed.type !== "naddr") throw new Error("Invalid stream address"); if (parsed.type !== "naddr") throw new Error("Invalid stream address");
if (parsed.data.kind !== STREAM_KIND) throw new Error("Invalid stream kind"); if (parsed.data.kind !== STREAM_KIND) throw new Error("Invalid stream kind");
const request = new NostrRequest(unique([...readRelays, ...(parsed.data.relays ?? [])])); const addrRelays = parsed.data.relays ?? [];
request.onEvent.subscribe((event) => { return replaceableEventLoaderService.requestEvent(
setStream(parseStreamEvent(event)); unique([...readRelays, ...streamRelays, ...addrRelays]),
if (parsed.data.relays) setRelays(parsed.data.relays); parsed.data.kind,
}); parsed.data.pubkey,
request.start({ kinds: [parsed.data.kind], "#d": [parsed.data.identifier], authors: [parsed.data.pubkey] }); parsed.data.identifier,
true
);
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
}, [naddr]); }, [naddr, streamRelays.join("|")]);
const streamEvent = useSubject(subject);
const stream = useMemo(() => streamEvent && parseStreamEvent(streamEvent), [streamEvent]);
// refetch the stream from the correct relays when its loaded to ensure we have the latest
useEffect(() => {
if (stream?.relays) setStreamRelays(stream.relays);
}, [stream?.relays]);
if (!stream) return <Spinner />; if (!stream) return <Spinner />;
return ( return (
// add snort and damus relays so zap.stream will always see zaps // add snort and damus relays so zap.stream will always see zaps
<AdditionalRelayProvider <RelaySelectionProvider additionalDefaults={streamRelays}>
relays={unique([...relays, "wss://relay.snort.social", "wss://relay.damus.io", "wss://nos.lol"])}
>
<StreamPage stream={stream} displayMode={(params.get("displayMode") as ChatDisplayMode) ?? undefined} /> <StreamPage stream={stream} displayMode={(params.get("displayMode") as ChatDisplayMode) ?? undefined} />
</AdditionalRelayProvider> </RelaySelectionProvider>
); );
} }

View File

@ -34,11 +34,12 @@ import { useSigningContext } from "../../../../providers/signing-provider";
import { useTimelineCurserIntersectionCallback } from "../../../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../../../hooks/use-timeline-cursor-intersection-callback";
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"; import { css } from "@emotion/react";
import TopZappers from "./top-zappers"; import TopZappers from "./top-zappers";
import { parseZapEvent } from "../../../../helpers/zaps"; import { parseZapEvent } from "../../../../helpers/zaps";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import { useRelaySelectionRelays } from "../../../../providers/relay-selection-provider";
const hideScrollbar = css` const hideScrollbar = css`
scrollbar-width: 0; scrollbar-width: 0;
@ -57,13 +58,14 @@ export default function StreamChat({
...props ...props
}: CardProps & { stream: ParsedStream; actions?: React.ReactNode; displayMode?: ChatDisplayMode }) { }: CardProps & { stream: ParsedStream; actions?: React.ReactNode; displayMode?: ChatDisplayMode }) {
const toast = useToast(); const toast = useToast();
const contextRelays = useAdditionalRelayContext(); const streamRelays = useRelaySelectionRelays();
const readRelays = useReadRelayUrls(contextRelays);
const hostReadRelays = useUserRelays(stream.host) 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);
const timeline = useTimelineLoader(`${truncatedId(stream.event.id)}-chat`, readRelays, { const relays = useMemo(() => unique([...streamRelays, ...hostReadRelays]), [hostReadRelays, streamRelays]);
const timeline = useTimelineLoader(`${truncatedId(stream.identifier)}-chat`, streamRelays, {
"#a": [getATag(stream)], "#a": [getATag(stream)],
kinds: [STREAM_CHAT_MESSAGE_KIND, Kind.Zap], kinds: [STREAM_CHAT_MESSAGE_KIND, Kind.Zap],
}); });
@ -92,7 +94,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, ...hostReadRelays]), signed); nostrPostAction(relays, 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" });
@ -181,7 +183,7 @@ export default function StreamChat({
}} }}
onClose={zapModal.onClose} onClose={zapModal.onClose}
initialComment={getValues().content} initialComment={getValues().content}
additionalRelays={contextRelays} additionalRelays={relays}
/> />
)} )}
</> </>

View File

@ -30,7 +30,7 @@ import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { ArrowDownSIcon, ArrowUpSIcon, AtIcon, ExternalLinkIcon, KeyIcon, LightningIcon } from "../../components/icons"; import { ArrowDownSIcon, ArrowUpSIcon, AtIcon, ExternalLinkIcon, KeyIcon, LightningIcon } from "../../components/icons";
import { normalizeToBech32 } from "../../helpers/nip19"; import { normalizeToBech32 } from "../../helpers/nip19";
import { Bech32Prefix } from "../../helpers/nip19"; import { Bech32Prefix } from "../../helpers/nip19";
import { truncatedId } from "../../helpers/nostr-event"; import { truncatedId } from "../../helpers/nostr/event";
import { CopyIconButton } from "../../components/copy-icon-button"; import { CopyIconButton } from "../../components/copy-icon-button";
import { QrIconButton } from "./components/share-qr-button"; import { QrIconButton } from "./components/share-qr-button";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon"; import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";

View File

@ -10,7 +10,7 @@ import { RelayMode } from "../../../classes/relay";
import UserDebugModal from "../../../components/debug-modals/user-debug-modal"; import UserDebugModal from "../../../components/debug-modals/user-debug-modal";
import { useCopyToClipboard } from "react-use"; import { useCopyToClipboard } from "react-use";
import { useSharableProfileId } from "../../../hooks/use-shareable-profile-id"; import { useSharableProfileId } from "../../../hooks/use-shareable-profile-id";
import { truncatedId } from "../../../helpers/nostr-event"; import { truncatedId } from "../../../helpers/nostr/event";
export const UserProfileMenu = ({ export const UserProfileMenu = ({
pubkey, pubkey,

View File

@ -6,7 +6,7 @@ import { UserCard, UserCardProps } from "./components/user-card";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
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 useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";

View File

@ -2,7 +2,7 @@ import { useRef } from "react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { Box, Flex, SkeletonText, Spacer, Text } from "@chakra-ui/react"; import { Box, Flex, SkeletonText, Spacer, Text } from "@chakra-ui/react";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import { getReferences, truncatedId } from "../../helpers/nostr-event"; import { getReferences, truncatedId } from "../../helpers/nostr/event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";

View File

@ -2,7 +2,7 @@ import { useCallback } from "react";
import { Flex, FormControl, FormLabel, Spacer, Switch, useDisclosure } from "@chakra-ui/react"; import { Flex, FormControl, FormLabel, Spacer, Switch, useDisclosure } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import { isReply, isRepost, truncatedId } from "../../helpers/nostr-event"; import { isReply, isRepost, truncatedId } from "../../helpers/nostr/event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { RelayIconStack } from "../../components/relay-icon-stack"; import { RelayIconStack } from "../../components/relay-icon-stack";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";

View File

@ -2,7 +2,7 @@ import { Flex, Text } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { NoteLink } from "../../components/note-link"; import { NoteLink } from "../../components/note-link";
import { UserLink } from "../../components/user-link"; import { UserLink } from "../../components/user-link";
import { filterTagsByContentRefs, truncatedId } from "../../helpers/nostr-event"; import { filterTagsByContentRefs, truncatedId } from "../../helpers/nostr/event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isETag, isPTag, NostrEvent } from "../../types/nostr-event"; import { isETag, isPTag, NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";

View File

@ -1,6 +1,6 @@
import { Flex } from "@chakra-ui/react"; import { Flex } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { truncatedId } from "../../helpers/nostr-event"; import { truncatedId } from "../../helpers/nostr/event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer"; import IntersectionObserverProvider from "../../providers/intersection-observer";

View File

@ -8,7 +8,7 @@ import { NoteLink } from "../../components/note-link";
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 { readablizeSats } from "../../helpers/bolt11"; import { readablizeSats } from "../../helpers/bolt11";
import { truncatedId } from "../../helpers/nostr-event"; import { truncatedId } from "../../helpers/nostr/event";
import { isProfileZap, isNoteZap, parseZapEvent, totalZaps } from "../../helpers/zaps"; import { isProfileZap, isNoteZap, parseZapEvent, totalZaps } from "../../helpers/zaps";
import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";