Merge pull request 'media URLs and nostr refs' (#71) from refs into main

Reviewed-on: Kieran/stream#71
Reviewed-by: Kieran <kieran@noreply.localhost>
This commit is contained in:
2023-08-04 13:03:14 +00:00
26 changed files with 425 additions and 132 deletions

View File

@ -1,33 +1,80 @@
import "./event.css";
import { type NostrLink, EventKind } from "@snort/system";
import { useEvent } from "hooks/event";
import { GOAL } from "const";
import {
type NostrLink,
type NostrEvent as NostrEventType,
EventKind,
} from "@snort/system";
import { Icon } from "element/icon";
import { Goal } from "element/goal";
import { Note } from "element/note";
import { EmojiPack } from "element/emoji-pack";
import { Badge } from "element/badge";
import { useEvent } from "hooks/event";
import { GOAL, EMOJI_PACK } from "const";
interface EventProps {
link: NostrLink;
}
export function Event({ link }: EventProps) {
const event = useEvent(link);
export function EventIcon({ kind }: { kind: EventKind }) {
if (kind === GOAL) {
return <Icon name="piggybank" />;
}
if (event?.kind === GOAL) {
if (kind === EMOJI_PACK) {
return <Icon name="face-content" />;
}
if (kind === EventKind.Badge) {
return <Icon name="badge" />;
}
if (kind === EventKind.TextNote) {
return <Icon name="note" />;
}
return null;
}
export function NostrEvent({ ev }: { ev: NostrEventType }) {
if (ev?.kind === GOAL) {
return (
<div className="event-container">
<Goal ev={event} />
<Goal ev={ev} />
</div>
);
}
if (event?.kind === EventKind.TextNote) {
if (ev?.kind === EMOJI_PACK) {
return (
<div className="event-container">
<Note ev={event} />
<EmojiPack ev={ev} />
</div>
);
}
if (ev?.kind === EventKind.Badge) {
return (
<div className="event-container">
<Badge ev={ev} />
</div>
);
}
if (ev?.kind === EventKind.TextNote) {
return (
<div className="event-container">
<Note ev={ev} />
</div>
);
}
return null;
}
export function Event({ link }: EventProps) {
const event = useEvent(link);
return event ? <NostrEvent ev={event} /> : null;
}

View File

@ -1,24 +0,0 @@
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;
}
export function Address({ link }: AddressProps) {
const event = useEvent(link);
if (event?.kind === EMOJI_PACK) {
return <EmojiPack ev={event} />;
}
if (event?.kind === EventKind.Badge) {
return <Badge ev={event} />;
}
return null;
}

View File

@ -3,7 +3,8 @@
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 0;
background: transparent;
margin: 8px 0;
}
.badge .badge-details {
@ -23,6 +24,7 @@
.badge .badge-description {
margin: 0;
color: var(--text-muted);
text-align: center;
}
.badge .badge-thumbnail {

View File

@ -14,6 +14,7 @@ import { Emoji as EmojiComponent } from "element/emoji";
import { Profile } from "./profile";
import { Text } from "element/text";
import { SendZapsDialog } from "element/send-zap";
import { CollapsibleEvent } from "element/collapsible";
import { useLogin } from "hooks/login";
import { formatSats } from "number";
import { findTag } from "utils";
@ -30,6 +31,10 @@ function emojifyReaction(reaction: string) {
return reaction;
}
const customComponents = {
Event: CollapsibleEvent,
};
export function ChatMessage({
streamer,
ev,
@ -137,7 +142,6 @@ export function ChatMessage({
<div
className={`message${streamer === ev.pubkey ? " streamer" : ""}`}
ref={ref}
onClick={() => setShowZapDialog(true)}
>
<Profile
icon={
@ -159,7 +163,11 @@ export function ChatMessage({
pubkey={ev.pubkey}
profile={profile}
/>
<Text tags={ev.tags} content={ev.content} />
<Text
tags={ev.tags}
content={ev.content}
customComponents={customComponents}
/>
{(hasReactions || hasZaps) && (
<div className="message-reactions">
{hasZaps && (

View File

@ -0,0 +1,41 @@
.collapsible-media {
display: flex;
flex-direction: column;
gap: 12px;
}
.collapsible-media a {
color: var(--text-link);
word-wrap: break-word;
}
.collapsible-media img,
.collapsible-media video {
width: 100%;
}
.url-preview {
color: var(--text-link);
cursor: zoom-in;
}
.collapsible {
width: 100%;
}
.collapsed-event {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0;
}
.collapsed-event-header {
display: flex;
align-items: center;
gap: 8px;
}
.collapsed-event-header svg {
color: var(--text-muted);
}

View File

@ -0,0 +1,74 @@
import "./collapsible.css";
import type { ReactNode } from "react";
import { useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import * as Collapsible from "@radix-ui/react-collapsible";
import type { NostrLink } from "@snort/system";
import { Mention } from "element/mention";
import { NostrEvent, EventIcon } from "element/Event";
import { ExternalLink } from "element/external-link";
import { useEvent } from "hooks/event";
interface MediaURLProps {
url: URL;
children: ReactNode;
}
export function MediaURL({ url, children }: MediaURLProps) {
const preview = <span className="url-preview">{url.toString()}</span>;
return (
<Dialog.Root>
<Dialog.Trigger asChild>{preview}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<div className="collapsible-media">
<ExternalLink href={url.toString()}>{url.toString()}</ExternalLink>
{children}
</div>
<Dialog.Close asChild>
<button className="btn delete-button" aria-label="Close">
Close
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export function CollapsibleEvent({ link }: { link: NostrLink }) {
const event = useEvent(link);
const [open, setOpen] = useState(false);
const author = event?.pubkey || link.author;
return (
<Collapsible.Root
className="collapsible"
open={open}
onOpenChange={setOpen}
>
<div className="collapsed-event">
<div className="collapsed-event-header">
{event && <EventIcon kind={event.kind} />}
{author && <Mention pubkey={author} />}
</div>
<Collapsible.Trigger asChild>
<button
className={`${
open ? "btn btn-small delete-button" : "btn btn-small"
}`}
>
{open ? "Hide" : "Show"}
</button>
</Collapsible.Trigger>
</div>
<Collapsible.Content>
{open && event && <NostrEvent ev={event} />}
</Collapsible.Content>
</Collapsible.Root>
);
}

View File

@ -1,18 +1,22 @@
.emoji-pack-title {
.emoji-pack {
margin: 8px 0;
}
.emoji-pack .emoji-pack-title {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
}
.emoji-pack-title .name {
.emoji-pack .emoji-pack-title .name {
margin: 0;
}
.emoji-pack-title a {
.emoji-pack .emoji-pack-title a {
font-size: 14px;
}
.emoji-pack-emojis {
.emoji-pack .emoji-pack-emojis {
margin-top: 12px;
display: flex;
flex-direction: row;
@ -20,14 +24,14 @@
gap: 4px;
}
.emoji-definition {
.emoji-pack .emoji-definition {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.emoji-name {
.emoji-pack .emoji-name {
font-size: 10px;
}

View File

@ -4,7 +4,6 @@ import { type NostrEvent } from "@snort/system";
import { useLogin } from "hooks/login";
import { toEmojiPack } from "hooks/emoji";
import AsyncButton from "element/async-button";
import { Mention } from "element/mention";
import { findTag } from "utils";
import { USER_EMOJIS } from "const";
import { Login, System } from "index";
@ -44,15 +43,14 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
}
return (
<div className="emoji-pack">
<div className="outline emoji-pack">
<div className="emoji-pack-title">
<div>
<h4>{name}</h4>
<Mention pubkey={ev.pubkey} />
</div>
<h4>{name}</h4>
{login?.pubkey && (
<AsyncButton
className={`btn btn-primary ${isUsed ? "delete-button" : ""}`}
className={`btn btn-small btn-primary ${
isUsed ? "delete-button" : ""
}`}
onClick={toggleEmojiPack}
>
{isUsed ? "Remove" : "Add"}

View File

@ -15,6 +15,7 @@ export function ExternalLink({ children, href }: ExternalLinkProps) {
}
interface ExternalIconLinkProps extends Omit<ExternalLinkProps, "children"> {
className?: string;
size?: number;
}

View File

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { NostrLink } from "element/nostr-link";
import { MediaURL } from "element/collapsible";
const FileExtensionRegex = /\.([\w]+)$/i;
@ -23,17 +24,23 @@ export function HyperText({ link, children }: HyperTextProps) {
case "bmp":
case "webp": {
return (
<img
src={url.toString()}
alt={url.toString()}
style={{ objectFit: "contain" }}
/>
<MediaURL url={url}>
<img
src={url.toString()}
alt={url.toString()}
style={{ objectFit: "contain" }}
/>
</MediaURL>
);
}
case "wav":
case "mp3":
case "ogg": {
return <audio key={url.toString()} src={url.toString()} controls />;
return (
<MediaURL url={url}>
<audio key={url.toString()} src={url.toString()} controls />;
</MediaURL>
);
}
case "mp4":
case "mov":
@ -41,7 +48,11 @@ export function HyperText({ link, children }: HyperTextProps) {
case "avi":
case "m4v":
case "webm": {
return <video key={url.toString()} src={url.toString()} controls />;
return (
<MediaURL url={url}>
<video key={url.toString()} src={url.toString()} controls />
</MediaURL>
);
}
default:
return <a href={url.toString()}>{children || url.toString()}</a>;

View File

@ -144,6 +144,7 @@ export function LiveChat({
<h2 className="title">Stream Chat</h2>
<Icon
name="link"
className="secondary"
size={32}
onClick={() =>
window.open(

View File

@ -18,8 +18,3 @@
font-weight: 400;
line-height: 29px; /* 161.111% */
}
.markdown > img {
max-height: 230px;
width: 100%;
}

View File

@ -30,6 +30,9 @@ export function Markdown({ content, tags = [] }: MarkdownProps) {
td: ({ children }: ComponentProps) => {
return children && <td>{transformText(children, tags)}</td>;
},
th: ({ children }: ComponentProps) => {
return children && <th>{transformText(children, tags)}</th>;
},
p: ({ children }: ComponentProps) => {
return children && <p>{transformText(children, tags)}</p>;
},

View File

@ -1,7 +1,9 @@
.note {
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
margin: 8px 0;
color: #fff;
font-size: 15px;
font-weight: 400;
line-height: 22px;
}
.note .note-header {
@ -10,23 +12,23 @@
}
.note .note-header .profile {
font-size: 14px;
font-size: 15px;
font-weight: 600;
}
.note .note-avatar {
width: 18px;
height: 18px;
.note .note-header .note-avatar {
width: 24px;
height: 24px;
}
.note .note-content {
margin-left: 30px;
.note .note-header .note-link-icon {
color: #909090;
}
.note .note-content .markdown > * {
font-size: 14px;
}
.note .note-content .markdown > ul,
.note .note-content .markdown ol {
margin-left: 30px;
.note .note-content .markdown > *:last-child {
margin-bottom: 0;
}

View File

@ -1,16 +1,24 @@
import "./note.css";
import { type NostrEvent } from "@snort/system";
import { type NostrEvent, NostrPrefix } from "@snort/system";
import { Markdown } from "element/markdown";
import { ExternalIconLink } from "element/external-link";
import { Profile } from "element/profile";
import { hexToBech32 } from "utils";
export function Note({ ev }: { ev: NostrEvent }) {
return (
<div className="note">
<div className="surface note">
<div className="note-header">
<Profile avatarClassname="note-avatar" pubkey={ev.pubkey} />
<ExternalIconLink size={25} href={`https://snort.social/e/${ev.id}`} />
<ExternalIconLink
size={24}
className="note-link-icon"
href={`https://snort.social/e/${hexToBech32(
NostrPrefix.Event,
ev.id
)}`}
/>
</div>
<div className="note-content">
<Markdown tags={ev.tags} content={ev.content} />

View File

@ -4,7 +4,9 @@ import { Link } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import { UserMetadata } from "@snort/system";
import { hexToBech32 } from "@snort/shared";
import { Icon } from "element/icon";
import usePlaceholder from "hooks/placeholders";
import { System } from "index";
import { useInView } from "react-intersection-observer";
@ -47,6 +49,7 @@ export function Profile({
useUserProfile(System, inView && !profile ? pubkey : undefined) || profile;
const showAvatar = options?.showAvatar ?? true;
const showName = options?.showName ?? true;
const placeholder = usePlaceholder(pubkey);
const content = (
<>
@ -57,7 +60,7 @@ export function Profile({
<img
alt={pLoaded?.name || pubkey}
className={avatarClassname ? avatarClassname : ""}
src={pLoaded?.picture ?? ""}
src={pLoaded?.picture ?? placeholder}
/>
))}
{icon}

View File

@ -1,8 +1,12 @@
.stream-cards {
.stream-cards,
.edit-container {
display: none;
}
@media (min-width: 1020px) {
.edit-container {
display: block;
}
.stream-cards {
display: grid;
align-items: flex-start;
@ -86,6 +90,7 @@
.new-card h3 {
margin: 0;
margin-bottom: 12px;
font-weight: 500;
}
.new-card input[type="text"] {

View File

@ -1,5 +0,0 @@
.custom-emoji {
width: 21px;
height: 21px;
display: inline-block;
}

View File

@ -1,13 +1,17 @@
import { useMemo, type ReactNode } from "react";
import { useMemo, type ReactNode, type FunctionComponent } from "react";
import { parseNostrLink, validateNostrLink } from "@snort/system";
import {
type NostrLink,
parseNostrLink,
validateNostrLink,
} from "@snort/system";
import { Address } from "element/address";
import { Event } from "element/Event";
import { Mention } from "element/mention";
import { Emoji } from "element/emoji";
import { HyperText } from "element/hypertext";
import { splitByUrl } from "utils";
import type { Tags } from "types";
export type Fragment = string | ReactNode;
@ -31,25 +35,11 @@ function extractLinks(fragments: Fragment[]) {
return (
normalizedStr.startsWith("http:") ||
normalizedStr.startsWith("https:") ||
normalizedStr.startsWith("magnet:")
normalizedStr.startsWith("https:")
);
};
if (validateLink()) {
if (!a.startsWith("nostr:")) {
return (
<a
href={a}
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext"
>
{a}
</a>
);
}
return <HyperText link={a}>{a}</HyperText>;
}
return a;
@ -122,7 +112,7 @@ function extractNpubs(fragments: Fragment[]) {
.flat();
}
function extractNevents(fragments: Fragment[]) {
function extractNevents(fragments: Fragment[], Event: NostrComponent) {
return fragments
.map((f) => {
if (typeof f === "string") {
@ -144,7 +134,7 @@ function extractNevents(fragments: Fragment[]) {
.flat();
}
function extractNaddrs(fragments: Fragment[]) {
function extractNaddrs(fragments: Fragment[], Address: NostrComponent) {
return fragments
.map((f) => {
if (typeof f === "string") {
@ -167,7 +157,7 @@ function extractNaddrs(fragments: Fragment[]) {
.flat();
}
function extractNoteIds(fragments: Fragment[]) {
function extractNoteIds(fragments: Fragment[], Event: NostrComponent) {
return fragments
.map((f) => {
if (typeof f === "string") {
@ -189,22 +179,46 @@ function extractNoteIds(fragments: Fragment[]) {
.flat();
}
export function transformText(ps: Fragment[], tags: Array<string[]>) {
export type NostrComponent = FunctionComponent<{ link: NostrLink }>;
export interface NostrComponents {
Event: NostrComponent;
}
const components: NostrComponents = {
Event,
};
export function transformText(
ps: Fragment[],
tags: Array<string[]>,
customComponents = components
) {
let fragments = extractEmoji(ps, tags);
fragments = extractNprofiles(fragments);
fragments = extractNevents(fragments);
fragments = extractNaddrs(fragments);
fragments = extractNoteIds(fragments);
fragments = extractNevents(fragments, customComponents.Event);
fragments = extractNaddrs(fragments, customComponents.Event);
fragments = extractNoteIds(fragments, customComponents.Event);
fragments = extractNpubs(fragments);
fragments = extractLinks(fragments);
return fragments;
}
export function Text({ content, tags }: { content: string; tags: string[][] }) {
interface TextProps {
content: string;
tags: Tags;
customComponents?: NostrComponents;
}
export function Text({ content, tags, customComponents }: TextProps) {
// todo: RTL langugage support
const element = useMemo(() => {
return <span>{transformText([content], tags)}</span>;
return (
<span className="text">
{transformText([content], tags, customComponents)}
</span>
);
}, [content, tags]);
return <>{element}</>;

View File

@ -0,0 +1,9 @@
import { useMemo } from "react";
export default function usePlaceholder(pubkey: string) {
const url = useMemo(
() => `https://robohash.v0l.io/${pubkey}.png?set=2`,
[pubkey]
);
return url;
}

View File

@ -15,7 +15,15 @@ body {
--text-muted: #797979;
--text-link: #f838d9;
--text-danger: #ff563f;
--border: #333;
--surface: #222;
--border: #171717;
--gradient-purple: linear-gradient(135deg, #882bff 0%, #f83838 100%);
--gradient-yellow: linear-gradient(270deg, #adff27 0%, #ffd027 100%);
--gradient-orange: linear-gradient(
270deg,
#ff5b27 0%,
rgba(255, 182, 39, 0.99) 100%
);
}
@media (max-width: 1020px) {
@ -102,6 +110,12 @@ a {
gap: 8px;
}
.btn-small {
font-size: 14px;
line-height: 18px;
padding: 4px 8px;
}
.btn-border {
border: 1px solid transparent;
color: inherit;
@ -277,3 +291,19 @@ div.paper {
height: 15px;
margin-bottom: -2px;
}
.surface {
padding: 8px 12px 12px 12px;
background: var(--surface);
border-radius: 10px;
}
.outline {
padding: 8px 12px 12px 12px;
border-radius: 10px;
border: 1px solid var(--border);
}
.secondary {
color: #909090;
}

View File

@ -46,14 +46,29 @@
margin-left: 16px;
}
@media (min-width: 480px) {
.profile-page .status-indicator {
position: absolute;
top: 16px;
left: 120px;
}
}
.profile-page .profile-actions {
position: absolute;
display: flex;
gap: 12px;
align-items: flex-start;
gap: 4px;
top: 12px;
right: 12px;
}
@media (min-width: 480px) {
.profile-page .profile-actions {
gap: 12px;
}
}
.profile-page .profile-information {
margin: 12px;
margin-left: 16px;
@ -79,22 +94,11 @@
line-height: 24px;
}
.profile-page .icon-button {
.profile-page .zap-button {
display: flex;
align-items: center;
gap: 8px;
}
.profile-page .icon-button span {
display: none;
}
@media (min-width: 420px) {
.profile-page .icon-button span {
display: block;
}
}
.profile-page .zap-button-icon {
color: #171717;
}
@ -187,9 +191,7 @@
align-items: center;
justify-content: space-between;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
.profile-page .zapper .zapper-amount {
@ -197,7 +199,6 @@
align-items: center;
gap: 4px;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: 22px;
}
@ -229,3 +230,19 @@
font-weight: 500;
line-height: 24px;
}
.profile-page .live-button span {
display: none;
}
.profile-page .zap-button span {
display: none;
}
@media (min-width: 480px) {
.profile-page .zap-button span {
display: block;
}
.profile-page .live-button span {
display: block;
}
}

View File

@ -18,6 +18,7 @@ import { FollowButton } from "element/follow-button";
import { MuteButton } from "element/mute-button";
import { useProfile } from "hooks/profile";
import useTopZappers from "hooks/top-zappers";
import usePlaceholder from "hooks/placeholders";
import { Text } from "element/text";
import { StreamState, System } from "index";
import { findTag } from "utils";
@ -52,6 +53,7 @@ export function ProfilePage() {
const navigate = useNavigate();
const params = useParams();
const link = parseNostrLink(params.npub!);
const placeholder = usePlaceholder(link.id);
const profile = useUserProfile(System, link.id);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const { streams, zaps } = useProfile(link, true);
@ -91,16 +93,22 @@ export function ProfilePage() {
src={profile?.banner || defaultBanner}
/>
<div className="profile-content">
{profile?.picture && (
{profile?.picture ? (
<img
className="avatar"
alt={profile.name || link.id}
src={profile.picture}
/>
) : (
<img
className="avatar"
alt={profile?.name || link.id}
src={placeholder}
/>
)}
<div className="status-indicator">
{isLive ? (
<div className="icon-button pill live" onClick={goToLive}>
<div className="live-button pill live" onClick={goToLive}>
<Icon name="signal" />
<span>live</span>
</div>
@ -122,9 +130,9 @@ export function ProfilePage() {
lnurl={zapTarget}
button={
<button className="btn">
<div className="icon-button">
<span>Zap</span>
<div className="zap-button">
<Icon name="zap-filled" className="zap-button-icon" />
<span>Zap</span>
</div>
</button>
}