Merge pull request 'home' (#60) from home into main

Reviewed-on: Kieran/stream#60
This commit is contained in:
Kieran 2023-08-01 09:17:46 +00:00
commit c503a1a557
15 changed files with 286 additions and 43 deletions

View File

@ -9,6 +9,9 @@ export const USER_CARDS = 17_777 as EventKind;
export const CARD = 37_777 as EventKind;
export const MUTED = 10_000 as EventKind;
export const DAY = 60 * 60 * 24;
export const WEEK = 7 * DAY;
export const defaultRelays = {
"wss://relay.snort.social": { read: true, write: true },
"wss://nos.lol": { read: true, write: true },

View File

@ -13,7 +13,7 @@ interface EventProps {
export function Event({ link }: EventProps) {
const event = useEvent(link);
if (event && event.kind === GOAL) {
if (event?.kind === GOAL) {
return (
<div className="event-container">
<Goal ev={event} />
@ -21,7 +21,7 @@ export function Event({ link }: EventProps) {
);
}
if (event && event.kind === EventKind.TextNote) {
if (event?.kind === EventKind.TextNote) {
return (
<div className="event-container">
<Note ev={event} />

View File

@ -1,8 +1,9 @@
import { type NostrLink } from "@snort/system";
import { type NostrLink, EventKind } from "@snort/system";
import { useEvent } from "hooks/event";
import { EMOJI_PACK } from "const";
import { EmojiPack } from "element/emoji-pack";
import { Badge } from "element/badge";
interface AddressProps {
link: NostrLink;
@ -15,5 +16,9 @@ export function Address({ link }: AddressProps) {
return <EmojiPack ev={event} />;
}
if (event?.kind === EventKind.Badge) {
return <Badge ev={event} />;
}
return null;
}

36
src/element/badge.css Normal file
View File

@ -0,0 +1,36 @@
.badge {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 0;
}
.badge .badge-details {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.badge .badge-name {
margin: 0;
text-align: center;
font-size: 22px;
font-weight: 700;
}
.badge .badge-description {
margin: 0;
color: var(--text-muted);
}
.badge .badge-thumbnail {
max-width: 120px;
aspect-ration: 4/3;
}
.badge .badge-description {
font-size: 14px;
line-height: 20px;
}

21
src/element/badge.tsx Normal file
View File

@ -0,0 +1,21 @@
import "./badge.css";
import type { NostrEvent } from "@snort/system";
import { findTag } from "utils";
export function Badge({ ev }: { ev: NostrEvent }) {
const name = findTag(ev, "name") || findTag(ev, "d");
const description = findTag(ev, "description");
const thumb = findTag(ev, "thumb");
const image = findTag(ev, "image");
return (
<div className="badge">
<img className="badge-thumbnail" src={thumb || image} alt={name} />
<div className="badge-details">
<h4 className="badge-name">{name}</h4>
{description?.length > 0 && (
<p className="badge-description">{description}</p>
)}
</div>
</div>
);
}

View File

@ -12,18 +12,14 @@ import { System } from "../index";
import { formatSats } from "../number";
import { EmojiPicker } from "./emoji-picker";
import { Icon } from "./icon";
import { Emoji } from "./emoji";
import { Emoji as EmojiComponent } from "./emoji";
import { Profile } from "./profile";
import { Text } from "element/text";
import { SendZapsDialog } from "./send-zap";
import { findTag } from "../utils";
import type { EmojiPack } from "../hooks/emoji";
import { useLogin } from "../hooks/login";
interface Emoji {
id: string;
native?: string;
}
import type { Badge, Emoji } from "types";
function emojifyReaction(reaction: string) {
if (reaction === "+") {
@ -40,11 +36,13 @@ export function ChatMessage({
ev,
reactions,
emojiPacks,
badges,
}: {
ev: NostrEvent;
streamer: string;
reactions: readonly NostrEvent[];
emojiPacks: EmojiPack[];
badges: Badge[];
}) {
const ref = useRef<HTMLDivElement | null>(null);
const inView = useIntersectionObserver(ref, {
@ -81,6 +79,9 @@ export function ChatMessage({
return messageZaps.reduce((acc, z) => acc + z.amount, 0);
}, [zaps, ev]);
const hasZaps = totalZaps > 0;
const awardedBadges = badges.filter(
(b) => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey),
);
useOnClickOutside(ref, () => {
setShowZapDialog(false);
@ -141,13 +142,20 @@ export function ChatMessage({
>
<Profile
icon={
ev.pubkey === streamer && <Icon name="signal" size={16} />
// todo: styling is ready if we want to add stream badges
// <img
// className="badge-icon"
// src="https://nostr.build/i/nostr.build_4b0d4f7293eb0f2bacb5b232a8d2ef3fe7648192d636e152a3c18b9fc06142d7.png"
// alt="TODO"
// />
ev.pubkey === streamer ? (
<Icon name="signal" size={16} />
) : (
awardedBadges.map((badge) => {
return (
<img
key={badge.name}
className="badge-icon"
src={badge.thumb || badge.image}
alt={badge.name}
/>
);
})
)
}
pubkey={ev.pubkey}
profile={profile}
@ -170,7 +178,7 @@ export function ChatMessage({
<div className="message-reaction-container">
{isCustomEmojiReaction && emoji ? (
<span className="message-reaction">
<Emoji name={emoji.at(1)!} url={emoji.at(2)!} />
<EmojiComponent name={emoji.at(1)!} url={emoji.at(2)!} />
</span>
) : (
<span className="message-reaction">{e}</span>

View File

@ -357,3 +357,20 @@
height: 18px;
border-radius: unset;
}
.badge-award {
border-radius: 12px;
border: 1px solid #303030;
background: #111;
padding: 8px 12px;
}
.badge-award .title {
margin: 0;
}
.badge-awardees {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@ -11,28 +11,49 @@ import {
import { useEffect, useMemo } from "react";
import uniqBy from "lodash.uniqby";
import { System } from "../index";
import useEmoji, { packId } from "../hooks/emoji";
import { useLiveChatFeed } from "../hooks/live-chat";
import { Profile } from "./profile";
import { Icon } from "./icon";
import Spinner from "./spinner";
import { Text } from "./text";
import { useLogin } from "../hooks/login";
import { formatSats } from "../number";
import useTopZappers from "../hooks/top-zappers";
import { LIVE_STREAM_CHAT } from "../const";
import { ChatMessage } from "./chat-message";
import { Goal } from "./goal";
import { NewGoalDialog } from "./new-goal";
import { WriteMessage } from "./write-message";
import { Icon } from "element/icon";
import Spinner from "element/spinner";
import { Text } from "element/text";
import { Profile } from "element/profile";
import { ChatMessage } from "element/chat-message";
import { Goal } from "element/goal";
import { Badge } from "element/badge";
import { NewGoalDialog } from "element/new-goal";
import { WriteMessage } from "element/write-message";
import useEmoji, { packId } from "hooks/emoji";
import { useLiveChatFeed } from "hooks/live-chat";
import { useBadges } from "hooks/badges";
import { useLogin } from "hooks/login";
import useTopZappers from "hooks/top-zappers";
import { useAddress } from "hooks/event";
import { formatSats } from "number";
import { LIVE_STREAM_CHAT } from "const";
import { findTag, getTagValues, getHost } from "utils";
import { System } from "index";
export interface LiveChatOptions {
canWrite?: boolean;
showHeader?: boolean;
}
function BadgeAward({ ev }: { ev: NostrEvent }) {
const badge = findTag(ev, "a");
const [k, pubkey, d] = badge.split(":");
const awardees = getTagValues(ev.tags, "p");
const event = useAddress(Number(k), pubkey, d);
return (
<div className="badge-award">
{event && <Badge ev={event} />}
<p>awarded to</p>
<div className="badge-awardees">
{awardees.map((pk) => (
<Profile key={pk} pubkey={pk} />
))}
</div>
</div>
);
}
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
const zappers = useTopZappers(zaps);
@ -69,6 +90,7 @@ export function LiveChat({
height?: number;
}) {
const host = getHost(ev);
const { badges, awards } = useBadges(host);
const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined);
const login = useLogin();
useEffect(() => {
@ -92,10 +114,10 @@ export function LiveChat({
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid);
const events = useMemo(() => {
return [...feed.messages, ...feed.zaps].sort(
return [...feed.messages, ...feed.zaps, ...awards].sort(
(a, b) => b.created_at - a.created_at,
);
}, [feed.messages, feed.zaps]);
}, [feed.messages, feed.zaps, awards]);
const streamer = getHost(ev);
const naddr = useMemo(() => {
if (ev) {
@ -143,9 +165,13 @@ export function LiveChat({
<div className="messages">
{filteredEvents.map((a) => {
switch (a.kind) {
case EventKind.BadgeAward: {
return <BadgeAward ev={a} />;
}
case LIVE_STREAM_CHAT: {
return (
<ChatMessage
badges={badges}
emojiPacks={allEmojiPacks}
streamer={streamer}
ev={a}

88
src/hooks/badges.ts Normal file
View File

@ -0,0 +1,88 @@
import { useMemo } from "react";
import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
import { findTag, toAddress, getTagValues } from "utils";
import { WEEK } from "const";
import { System } from "index";
export function useBadges(pubkey: string, leaveOpen = true) {
const since = useMemo(() => unixNow() - WEEK, [pubkey]);
const rb = useMemo(() => {
const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
rb.withOptions({ leaveOpen });
rb.withFilter().authors([pubkey]).kinds([EventKind.Badge]);
rb.withFilter()
.authors([pubkey])
.kinds([EventKind.BadgeAward])
.since(since);
return rb;
}, [pubkey, since]);
const { data: badgeEvents } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
rb,
);
const rawBadges = useMemo(() => {
if (badgeEvents) {
return badgeEvents
.filter((e) => e.kind === EventKind.Badge)
.sort((a, b) => b.created_at - a.created_at);
}
return [];
}, [badgeEvents]);
const badgeAwards = useMemo(() => {
if (badgeEvents) {
return badgeEvents.filter((e) => e.kind === EventKind.BadgeAward);
}
return [];
}, [badgeEvents]);
const acceptedSub = useMemo(() => {
if (rawBadges.length === 0) return null;
const rb = new RequestBuilder(`accepted-badges:${pubkey.slice(0, 12)}`);
rb.withFilter()
.kinds([EventKind.ProfileBadges])
.tag("d", ["profile_badges"])
.tag("a", rawBadges.map(toAddress));
return rb;
}, [rawBadges]);
const acceptedStream = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
acceptedSub,
);
const acceptedEvents = acceptedStream.data ?? [];
const badges = useMemo(() => {
return rawBadges.map((e) => {
const name = findTag(e, "d");
const address = toAddress(e);
const awardEvents = badgeAwards.filter(
(b) => findTag(b, "a") === address,
);
const awardees = new Set(
awardEvents.map((e) => getTagValues(e.tags, "p")).flat(),
);
const accepted = new Set(
acceptedEvents
.filter((pb) => awardees.has(pb.pubkey))
.filter((pb) =>
pb.tags.find((t) => t.at(0) === "a" && t.at(1) === address),
)
.map((pb) => pb.pubkey),
);
const thumb = findTag(e, "thumb");
const image = findTag(e, "image");
return { name, thumb, image, awardees, accepted };
});
return [];
}, [rawBadges]);
return { badges, awards: badgeAwards };
}

View File

@ -10,6 +10,22 @@ import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
export function useAddress(kind: number, pubkey: string, identifier: string) {
const sub = useMemo(() => {
const b = new RequestBuilder(`event:${kind}:${identifier}`);
b.withFilter().kinds([kind]).authors([pubkey]).tag("d", [identifier]);
return b;
}, [kind, pubkey, identifier]);
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
);
return data;
}
export function useEvent(link: NostrLink) {
const sub = useMemo(() => {
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);

View File

@ -8,13 +8,10 @@ import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
import { System } from "index";
import { useMemo } from "react";
import { LIVE_STREAM_CHAT } from "const";
import { LIVE_STREAM_CHAT, WEEK } from "const";
export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
const since = useMemo(
() => unixNow() - 60 * 60 * 24 * 7, // 7-days of zaps
[link.id],
);
const since = useMemo(() => unixNow() - WEEK, [link.id]);
const sub = useMemo(() => {
const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
rb.withOptions({

View File

@ -7,9 +7,10 @@ import { unixNow } from "@snort/shared";
import { LIVE_STREAM } from "const";
import { System, StreamState } from "index";
import { findTag } from "utils";
import { WEEK } from "const";
export function useStreamsFeed(tag?: string) {
const since = useMemo(() => unixNow() - 86400, [tag]);
const since = useMemo(() => unixNow() - WEEK, [tag]);
const rb = useMemo(() => {
const rb = new RequestBuilder(tag ? `streams:${tag}` : "streams");
rb.withOptions({

View File

@ -3,12 +3,12 @@ import { parseNostrLink, TaggedRawEvent } from "@snort/system";
import { useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet";
import useEventFeed from "hooks/event-feed";
import { LiveVideoPlayer } from "element/live-video-player";
import { findTag, getHost } from "utils";
import { Profile, getName } from "element/profile";
import { LiveChat } from "element/live-chat";
import AsyncButton from "element/async-button";
import useEventFeed from "hooks/event-feed";
import { useLogin } from "hooks/login";
import { useZapGoal } from "hooks/goals";
import { StreamState, System } from "index";
@ -22,7 +22,10 @@ import { StreamCards } from "element/stream-cards";
import { formatSats } from "number";
import { StreamTimer } from "element/stream-time";
import { ShareMenu } from "element/share-menu";
import { ContentWarningOverlay, isContentWarningAccepted } from "element/content-warning";
import {
ContentWarningOverlay,
isContentWarningAccepted,
} from "element/content-warning";
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
const login = useLogin();
@ -118,7 +121,7 @@ export function StreamPage() {
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? [];
if (contentWarning && !isContentWarningAccepted()) {
return <ContentWarningOverlay />
return <ContentWarningOverlay />;
}
const descriptionContent = [

View File

@ -20,3 +20,11 @@ export interface EmojiPack {
author: string;
emojis: EmojiTag[];
}
export interface Badge {
name: string;
thumb: string;
image: string;
awardees: Set<string>;
accepted: Set<string>;
}

View File

@ -2,6 +2,20 @@ import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
import * as utils from "@noble/curves/abstract/utils";
import { bech32 } from "@scure/base";
export function toAddress(e: NostrEvent): string {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
const dTag = findTag(e, "d");
return `${e.kind}:${e.pubkey}:${dTag}`;
}
if (e.kind === 0 || e.kind === 3) {
return e.pubkey;
}
return e.id;
}
export function toTag(e: NostrEvent): string[] {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
const dTag = findTag(e, "d");