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:
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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 {
|
||||
|
@ -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 && (
|
||||
|
41
src/element/collapsible.css
Normal file
41
src/element/collapsible.css
Normal 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);
|
||||
}
|
74
src/element/collapsible.tsx
Normal file
74
src/element/collapsible.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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"}
|
||||
|
@ -15,6 +15,7 @@ export function ExternalLink({ children, href }: ExternalLinkProps) {
|
||||
}
|
||||
|
||||
interface ExternalIconLinkProps extends Omit<ExternalLinkProps, "children"> {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
|
@ -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>;
|
||||
|
@ -144,6 +144,7 @@ export function LiveChat({
|
||||
<h2 className="title">Stream Chat</h2>
|
||||
<Icon
|
||||
name="link"
|
||||
className="secondary"
|
||||
size={32}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
|
@ -18,8 +18,3 @@
|
||||
font-weight: 400;
|
||||
line-height: 29px; /* 161.111% */
|
||||
}
|
||||
|
||||
.markdown > img {
|
||||
max-height: 230px;
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -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>;
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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} />
|
||||
|
@ -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}
|
||||
|
@ -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"] {
|
||||
|
@ -1,5 +0,0 @@
|
||||
.custom-emoji {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
display: inline-block;
|
||||
}
|
@ -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}</>;
|
||||
|
9
src/hooks/placeholders.ts
Normal file
9
src/hooks/placeholders.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
Reference in New Issue
Block a user