Add prettier formatting (#214)

* chore: add prettier

* chore: format codebase
This commit is contained in:
ennmichael
2023-02-07 21:04:50 +01:00
committed by GitHub
parent 015f799cf7
commit 5ad4971fc0
182 changed files with 8686 additions and 6861 deletions

View File

@ -18,12 +18,14 @@ export const VoidCatHost = "https://void.cat";
/**
* Kierans pubkey
*/
export const KieranPubKey = "npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
export const KieranPubKey =
"npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
/**
* Official snort account
*/
export const SnortPubKey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
export const SnortPubKey =
"npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
/**
* Websocket re-connect timeout
@ -33,59 +35,61 @@ export const DefaultConnectTimeout = 2000;
/**
* How long profile cache should be considered valid for
*/
export const ProfileCacheExpire = (1_000 * 60 * 5);
export const ProfileCacheExpire = 1_000 * 60 * 5;
/**
* Default bootstrap relays
*/
export const DefaultRelays = new Map<string, RelaySettings>([
["wss://relay.snort.social", { read: true, write: true }],
["wss://eden.nostr.land", { read: true, write: true }],
["wss://atlas.nostr.land", { read: true, write: true }]
["wss://relay.snort.social", { read: true, write: true }],
["wss://eden.nostr.land", { read: true, write: true }],
["wss://atlas.nostr.land", { read: true, write: true }],
]);
/**
* Default search relays
*/
export const SearchRelays = new Map<string, RelaySettings>([
["wss://relay.nostr.band", { read: true, write: false }],
["wss://relay.nostr.band", { read: true, write: false }],
]);
/**
* List of recommended follows for new users
*/
export const RecommendedFollows = [
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri
"A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor
"E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK
"C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers
"85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston
"C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut
"83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth
"3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss
"472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri
"A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor
"E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK
"C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers
"85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston
"C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut
"83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth
"3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss
"472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol
];
/**
* Regex to match email address
*/
export const EmailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const EmailRegex =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
/**
* Generic URL regex
*/
export const UrlRegex = /((?:http|ftp|https):\/\/(?:[\w+?\.\w+])+(?:[a-zA-Z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*)?)/i;
export const UrlRegex =
/((?:http|ftp|https):\/\/(?:[\w+?\.\w+])+(?:[a-zA-Z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*)?)/i;
/**
* Extract file extensions regex
@ -105,12 +109,14 @@ export const InvoiceRegex = /(lnbc\w+)/i;
/**
* YouTube URL regex
*/
export const YoutubeUrlRegex = /(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
export const YoutubeUrlRegex =
/(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
/**
* Tweet Regex
*/
export const TweetUrlRegex = /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/
export const TweetUrlRegex =
/https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
/**
* Hashtag regex
@ -125,12 +131,15 @@ export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
/**
* SoundCloud regex
*/
export const SoundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/
export const SoundCloudRegex =
/soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
/**
* Mixcloud regex
*/
export const MixCloudRegex = /mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/
export const MixCloudRegex =
/mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
export const SpotifyRegex = /open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/
export const SpotifyRegex =
/open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/;

View File

@ -3,21 +3,21 @@ import { TaggedRawEvent, u256 } from "Nostr";
import { MetadataCache } from "State/Users";
import { hexToBech32 } from "Util";
export const NAME = 'snortDB'
export const VERSION = 3
export const NAME = "snortDB";
export const VERSION = 3;
export interface SubCache {
id: string,
ids: u256[],
until?: number,
since?: number,
id: string;
ids: u256[];
until?: number;
since?: number;
}
const STORES = {
users: '++pubkey, name, display_name, picture, nip05, npub',
events: '++id, pubkey, created_at',
feeds: '++id'
}
users: "++pubkey, name, display_name, picture, nip05, npub",
events: "++id, pubkey, created_at",
feeds: "++id",
};
export class SnortDB extends Dexie {
users!: Table<MetadataCache>;
@ -26,11 +26,16 @@ export class SnortDB extends Dexie {
constructor() {
super(NAME);
this.version(VERSION).stores(STORES).upgrade(async tx => {
await tx.table("users").toCollection().modify(user => {
user.npub = hexToBech32("npub", user.pubkey)
this.version(VERSION)
.stores(STORES)
.upgrade(async (tx) => {
await tx
.table("users")
.toCollection()
.modify((user) => {
user.npub = hexToBech32("npub", user.pubkey);
});
});
});
}
}

View File

@ -1,27 +1,31 @@
import { useState } from "react"
import { useState } from "react";
export default function AsyncButton(props: any) {
const [loading, setLoading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
async function handle(e : any) {
if(loading) return;
setLoading(true);
try {
if (typeof props.onClick === "function") {
let f = props.onClick(e);
if (f instanceof Promise) {
await f;
}
}
}
finally {
setLoading(false);
async function handle(e: any) {
if (loading) return;
setLoading(true);
try {
if (typeof props.onClick === "function") {
let f = props.onClick(e);
if (f instanceof Promise) {
await f;
}
}
} finally {
setLoading(false);
}
}
return (
<button type="button" disabled={loading} {...props} onClick={(e) => handle(e)}>
{props.children}
</button>
)
}
return (
<button
type="button"
disabled={loading}
{...props}
onClick={(e) => handle(e)}
>
{props.children}
</button>
);
}

View File

@ -1,19 +1,19 @@
.avatar {
border-radius: 50%;
height: 210px;
width: 210px;
background-image: var(--img-url);
border: 1px solid transparent;
background-origin: border-box;
background-clip: content-box, border-box;
background-size: cover;
box-sizing: border-box;
border-radius: 50%;
height: 210px;
width: 210px;
background-image: var(--img-url);
border: 1px solid transparent;
background-origin: border-box;
background-clip: content-box, border-box;
background-size: cover;
box-sizing: border-box;
}
.avatar[data-domain="snort.social"] {
background-image: var(--img-url), var(--snort-gradient);
background-image: var(--img-url), var(--snort-gradient);
}
.avatar[data-domain="strike.army"] {
background-image: var(--img-url), var(--strike-army-gradient);
.avatar[data-domain="strike.army"] {
background-image: var(--img-url), var(--strike-army-gradient);
}

View File

@ -4,30 +4,35 @@ import { CSSProperties, useEffect, useState } from "react";
import type { UserMetadata } from "Nostr";
import useImgProxy from "Feed/ImgProxy";
const Avatar = ({ user, ...rest }: { user?: UserMetadata, onClick?: () => void }) => {
const Avatar = ({
user,
...rest
}: {
user?: UserMetadata;
onClick?: () => void;
}) => {
const [url, setUrl] = useState<string>(Nostrich);
const { proxy } = useImgProxy();
useEffect(() => {
if (user?.picture) {
proxy(user.picture, 120)
.then(a => setUrl(a))
.then((a) => setUrl(a))
.catch(console.warn);
}
}, [user]);
const backgroundImage = `url(${url})`
const style = { '--img-url': backgroundImage } as CSSProperties
const domain = user?.nip05 && user.nip05.split('@')[1]
const backgroundImage = `url(${url})`;
const style = { "--img-url": backgroundImage } as CSSProperties;
const domain = user?.nip05 && user.nip05.split("@")[1];
return (
<div
{...rest}
style={style}
className="avatar"
data-domain={domain?.toLowerCase()}
>
</div>
)
}
></div>
);
};
export default Avatar
export default Avatar;

View File

@ -7,7 +7,7 @@
}
.back-button svg {
margin-right: .5em;
margin-right: 0.5em;
}
.back-button:hover {

View File

@ -1,24 +1,25 @@
import "./BackButton.css"
import "./BackButton.css";
import ArrowBack from "Icons/ArrowBack";
interface BackButtonProps {
text?: string
onClick?(): void
text?: string;
onClick?(): void;
}
const BackButton = ({ text = "Back", onClick }: BackButtonProps) => {
const onClickHandler = () => {
if (onClick) {
onClick()
onClick();
}
}
};
return (
<button className="back-button" type="button" onClick={onClickHandler}>
<ArrowBack />{text}
<ArrowBack />
{text}
</button>
)
}
);
};
export default BackButton
export default BackButton;

View File

@ -2,20 +2,20 @@ import { HexKey } from "Nostr";
import useModeration from "Hooks/useModeration";
interface BlockButtonProps {
pubkey: HexKey
pubkey: HexKey;
}
const BlockButton = ({ pubkey }: BlockButtonProps) => {
const { block, unblock, isBlocked } = useModeration()
const { block, unblock, isBlocked } = useModeration();
return isBlocked(pubkey) ? (
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
Unblock
Unblock
</button>
) : (
<button className="secondary" type="button" onClick={() => block(pubkey)}>
Block
Block
</button>
)
}
);
};
export default BlockButton
export default BlockButton;

View File

@ -1,7 +1,8 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey } from "Nostr"; import type { RootState } from "State/Store";
import { HexKey } from "Nostr";
import type { RootState } from "State/Store";
import MuteButton from "Element/MuteButton";
import BlockButton from "Element/BlockButton";
import ProfilePreview from "Element/ProfilePreview";
@ -9,31 +10,45 @@ import useMutedFeed, { getMuted } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
interface BlockListProps {
variant: "muted" | "blocked"
variant: "muted" | "blocked";
}
export default function BlockList({ variant }: BlockListProps) {
const { publicKey } = useSelector((s: RootState) => s.login)
const { blocked, muted } = useModeration();
const { publicKey } = useSelector((s: RootState) => s.login);
const { blocked, muted } = useModeration();
return (
<div className="main-content">
{variant === "muted" && (
<>
<h4>{muted.length} muted</h4>
{muted.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
</>
)}
{variant === "blocked" && (
<>
<h4>{blocked.length} blocked</h4>
{blocked.map(a => {
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
</>
)}
</div>
)
return (
<div className="main-content">
{variant === "muted" && (
<>
<h4>{muted.length} muted</h4>
{muted.map((a) => {
return (
<ProfilePreview
actions={<MuteButton pubkey={a} />}
pubkey={a}
options={{ about: false }}
key={a}
/>
);
})}
</>
)}
{variant === "blocked" && (
<>
<h4>{blocked.length} blocked</h4>
{blocked.map((a) => {
return (
<ProfilePreview
actions={<BlockButton pubkey={a} />}
pubkey={a}
options={{ about: false }}
key={a}
/>
);
})}
</>
)}
</div>
);
}

View File

@ -3,22 +3,25 @@ import { useState, ReactNode } from "react";
import ShowMore from "Element/ShowMore";
interface CollapsedProps {
text?: string
children: ReactNode
collapsed: boolean
setCollapsed(b: boolean): void
text?: string;
children: ReactNode;
collapsed: boolean;
setCollapsed(b: boolean): void;
}
const Collapsed = ({ text, children, collapsed, setCollapsed }: CollapsedProps) => {
const Collapsed = ({
text,
children,
collapsed,
setCollapsed,
}: CollapsedProps) => {
return collapsed ? (
<div className="collapsed">
<ShowMore text={text} onClick={() => setCollapsed(false)} />
</div>
) : (
<div className="uncollapsed">
{children}
</div>
)
}
<div className="uncollapsed">{children}</div>
);
};
export default Collapsed
export default Collapsed;

View File

@ -4,9 +4,9 @@
}
.copy .body {
font-size: var(--font-size-small);
color: var(--font-color);
margin-right: 6px;
font-size: var(--font-size-small);
color: var(--font-color);
margin-right: 6px;
}
.copy .icon {

View File

@ -4,22 +4,30 @@ import CopyIcon from "Icons/Copy";
import { useCopy } from "useCopy";
export interface CopyProps {
text: string,
maxSize?: number
text: string;
maxSize?: number;
}
export default function Copy({ text, maxSize = 32 }: CopyProps) {
const { copy, copied, error } = useCopy();
const sliceLength = maxSize / 2
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text
const { copy, copied, error } = useCopy();
const sliceLength = maxSize / 2;
const trimmed =
text.length > maxSize
? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}`
: text;
return (
<div className="flex flex-row copy" onClick={() => copy(text)}>
<span className="body">
{trimmed}
</span>
<span className="icon" style={{ color: copied ? 'var(--success)' : 'var(--highlight)' }}>
{copied ? <Check width={13} height={13} />: <CopyIcon width={13} height={13} />}
</span>
</div>
)
return (
<div className="flex flex-row copy" onClick={() => copy(text)}>
<span className="body">{trimmed}</span>
<span
className="icon"
style={{ color: copied ? "var(--success)" : "var(--highlight)" }}
>
{copied ? (
<Check width={13} height={13} />
) : (
<CopyIcon width={13} height={13} />
)}
</span>
</div>
);
}

View File

@ -1,23 +1,23 @@
.dm {
padding: 8px;
background-color: var(--gray);
margin-bottom: 5px;
border-radius: 5px;
width: fit-content;
min-width: 100px;
max-width: 90%;
overflow: hidden;
min-height: 40px;
white-space: pre-wrap;
padding: 8px;
background-color: var(--gray);
margin-bottom: 5px;
border-radius: 5px;
width: fit-content;
min-width: 100px;
max-width: 90%;
overflow: hidden;
min-height: 40px;
white-space: pre-wrap;
}
.dm > div:first-child {
color: var(--gray-light);
font-size: small;
margin-bottom: 3px;
color: var(--gray-light);
font-size: small;
margin-bottom: 3px;
}
.dm.me {
align-self: flex-end;
background-color: var(--gray-secondary);
align-self: flex-end;
background-color: var(--gray-secondary);
}

View File

@ -1,7 +1,7 @@
import "./DM.css";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useInView } from 'react-intersection-observer';
import { useInView } from "react-intersection-observer";
import useEventPublisher from "Feed/EventPublisher";
import Event from "Nostr/Event";
@ -13,42 +13,53 @@ import { HexKey, TaggedRawEvent } from "Nostr";
import { incDmInteraction } from "State/Login";
export type DMProps = {
data: TaggedRawEvent
}
data: TaggedRawEvent;
};
export default function DM(props: DMProps) {
const dispatch = useDispatch();
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const publisher = useEventPublisher();
const [content, setContent] = useState("Loading...");
const [decrypted, setDecrypted] = useState(false);
const { ref, inView } = useInView();
const isMe = props.data.pubkey === pubKey;
const otherPubkey = isMe ? pubKey : props.data.tags.find(a => a[0] === "p")![1];
const dispatch = useDispatch();
const pubKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
const publisher = useEventPublisher();
const [content, setContent] = useState("Loading...");
const [decrypted, setDecrypted] = useState(false);
const { ref, inView } = useInView();
const isMe = props.data.pubkey === pubKey;
const otherPubkey = isMe
? pubKey
: props.data.tags.find((a) => a[0] === "p")![1];
async function decrypt() {
let e = new Event(props.data);
let decrypted = await publisher.decryptDm(e);
setContent(decrypted || "<ERROR>");
if (!isMe) {
setLastReadDm(e.PubKey);
dispatch(incDmInteraction());
}
async function decrypt() {
let e = new Event(props.data);
let decrypted = await publisher.decryptDm(e);
setContent(decrypted || "<ERROR>");
if (!isMe) {
setLastReadDm(e.PubKey);
dispatch(incDmInteraction());
}
}
useEffect(() => {
if (!decrypted && inView) {
setDecrypted(true);
decrypt().catch(console.error);
}
}, [inView, props.data]);
useEffect(() => {
if (!decrypted && inView) {
setDecrypted(true);
decrypt().catch(console.error);
}
}, [inView, props.data]);
return (
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
<div><NoteTime from={props.data.created_at * 1000} fallback={'Just now'} /></div>
<div className="w-max">
<Text content={content} tags={[]} users={new Map()} creator={otherPubkey} />
</div>
</div>
)
return (
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
<div>
<NoteTime from={props.data.created_at * 1000} fallback={"Just now"} />
</div>
<div className="w-max">
<Text
content={content}
tags={[]}
users={new Map()}
creator={otherPubkey}
/>
</div>
</div>
);
}

View File

@ -8,32 +8,34 @@ import { RootState } from "State/Store";
import { parseId } from "Util";
export interface FollowButtonProps {
pubkey: HexKey,
className?: string
pubkey: HexKey;
className?: string;
}
export default function FollowButton(props: FollowButtonProps) {
const pubkey = parseId(props.pubkey);
const publiser = useEventPublisher();
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
const baseClassname = `${props.className} follow-button`
const pubkey = parseId(props.pubkey);
const publiser = useEventPublisher();
const isFollowing = useSelector<RootState, boolean>(
(s) => s.login.follows?.includes(pubkey) ?? false
);
const baseClassname = `${props.className} follow-button`;
async function follow(pubkey: HexKey) {
let ev = await publiser.addFollow(pubkey);
publiser.broadcast(ev);
}
async function follow(pubkey: HexKey) {
let ev = await publiser.addFollow(pubkey);
publiser.broadcast(ev);
}
async function unfollow(pubkey: HexKey) {
let ev = await publiser.removeFollow(pubkey);
publiser.broadcast(ev);
}
async function unfollow(pubkey: HexKey) {
let ev = await publiser.removeFollow(pubkey);
publiser.broadcast(ev);
}
return (
<button
type="button"
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}
>
{isFollowing ? 'Unfollow' : 'Follow'}
</button>
)
return (
<button
type="button"
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
onClick={() => (isFollowing ? unfollow(pubkey) : follow(pubkey))}
>
{isFollowing ? "Unfollow" : "Follow"}
</button>
);
}

View File

@ -3,24 +3,35 @@ import { HexKey } from "Nostr";
import ProfilePreview from "Element/ProfilePreview";
export interface FollowListBaseProps {
pubkeys: HexKey[],
title?: string
pubkeys: HexKey[];
title?: string;
}
export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) {
const publisher = useEventPublisher();
export default function FollowListBase({
pubkeys,
title,
}: FollowListBaseProps) {
const publisher = useEventPublisher();
async function followAll() {
let ev = await publisher.addFollow(pubkeys);
publisher.broadcast(ev);
}
async function followAll() {
let ev = await publisher.addFollow(pubkeys);
publisher.broadcast(ev);
}
return (
<div className="main-content">
<div className="flex mt10 mb10">
<div className="f-grow bold">{title}</div>
<button className="transparent" type="button" onClick={() => followAll()}>Follow All</button>
</div>
{pubkeys?.map(a => <ProfilePreview pubkey={a} key={a} />)}
</div>
)
return (
<div className="main-content">
<div className="flex mt10 mb10">
<div className="f-grow bold">{title}</div>
<button
className="transparent"
type="button"
onClick={() => followAll()}
>
Follow All
</button>
</div>
{pubkeys?.map((a) => (
<ProfilePreview pubkey={a} key={a} />
))}
</div>
);
}

View File

@ -5,16 +5,22 @@ import EventKind from "Nostr/EventKind";
import FollowListBase from "Element/FollowListBase";
export interface FollowersListProps {
pubkey: HexKey
pubkey: HexKey;
}
export default function FollowersList({ pubkey }: FollowersListProps) {
const feed = useFollowersFeed(pubkey);
const feed = useFollowersFeed(pubkey);
const pubkeys = useMemo(() => {
let contactLists = feed?.store.notes.filter(a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey));
return [...new Set(contactLists?.map(a => a.pubkey))];
}, [feed]);
const pubkeys = useMemo(() => {
let contactLists = feed?.store.notes.filter(
(a) =>
a.kind === EventKind.ContactList &&
a.tags.some((b) => b[0] === "p" && b[1] === pubkey)
);
return [...new Set(contactLists?.map((a) => a.pubkey))];
}, [feed]);
return <FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`} />
}
return (
<FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`} />
);
}

View File

@ -2,18 +2,20 @@ import { useMemo } from "react";
import useFollowsFeed from "Feed/FollowsFeed";
import { HexKey } from "Nostr";
import FollowListBase from "Element/FollowListBase";
import { getFollowers} from "Feed/FollowsFeed";
import { getFollowers } from "Feed/FollowsFeed";
export interface FollowsListProps {
pubkey: HexKey
pubkey: HexKey;
}
export default function FollowsList({ pubkey }: FollowsListProps) {
const feed = useFollowsFeed(pubkey);
const feed = useFollowsFeed(pubkey);
const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey);
}, [feed]);
const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey);
}, [feed]);
return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
}
return (
<FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
);
}

View File

@ -1,6 +1,6 @@
.follows-you {
color: var(--font-secondary-color);
font-size: var(--font-size-tiny);
margin-left: .2em;
font-weight: normal
margin-left: 0.2em;
font-weight: normal;
}

View File

@ -3,26 +3,26 @@ import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey } from "Nostr";
import { RootState } from "State/Store";
import useFollowsFeed from "Feed/FollowsFeed";
import useFollowsFeed from "Feed/FollowsFeed";
import { getFollowers } from "Feed/FollowsFeed";
export interface FollowsYouProps {
pubkey: HexKey
pubkey: HexKey;
}
export default function FollowsYou({ pubkey }: FollowsYouProps ) {
const feed = useFollowsFeed(pubkey);
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
export default function FollowsYou({ pubkey }: FollowsYouProps) {
const feed = useFollowsFeed(pubkey);
const loginPubKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey);
}, [feed]);
const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey);
}, [feed]);
const followsMe = pubkeys.includes(loginPubKey!) ?? false ;
const followsMe = pubkeys.includes(loginPubKey!) ?? false;
return (
<>
{ followsMe ? <span className="follows-you">follows you</span> : null }
</>
)
return (
<>{followsMe ? <span className="follows-you">follows you</span> : null}</>
);
}

View File

@ -1,3 +1,3 @@
.hashtag {
color: var(--highlight);
color: var(--highlight);
}

View File

@ -1,12 +1,14 @@
import { Link } from 'react-router-dom'
import './Hashtag.css'
import { Link } from "react-router-dom";
import "./Hashtag.css";
const Hashtag = ({ tag }: { tag: string }) => {
return (
<span className="hashtag">
<Link to={`/t/${tag}`} onClick={(e) => e.stopPropagation()}>#{tag}</Link>
<Link to={`/t/${tag}`} onClick={(e) => e.stopPropagation()}>
#{tag}
</Link>
</span>
)
}
);
};
export default Hashtag
export default Hashtag;

View File

@ -1,106 +1,153 @@
import { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useCallback } from "react";
import { useSelector } from "react-redux";
import { TwitterTweetEmbed } from "react-twitter-embed";
import {
FileExtensionRegex,
YoutubeUrlRegex,
TweetUrlRegex,
TidalRegex,
SoundCloudRegex,
MixCloudRegex,
SpotifyRegex
FileExtensionRegex,
YoutubeUrlRegex,
TweetUrlRegex,
TidalRegex,
SoundCloudRegex,
MixCloudRegex,
SpotifyRegex,
} from "Const";
import { RootState } from 'State/Store';
import SoundCloudEmbed from 'Element/SoundCloudEmded'
import MixCloudEmbed from 'Element/MixCloudEmbed';
import { RootState } from "State/Store";
import SoundCloudEmbed from "Element/SoundCloudEmded";
import MixCloudEmbed from "Element/MixCloudEmbed";
import SpotifyEmbed from "Element/SpotifyEmbed";
import TidalEmbed from "Element/TidalEmbed";
import { ProxyImg } from 'Element/ProxyImg';
import { HexKey } from 'Nostr';
import { ProxyImg } from "Element/ProxyImg";
import { HexKey } from "Nostr";
export default function HyperText({ link, creator }: { link: string, creator: HexKey }) {
const pref = useSelector((s: RootState) => s.login.preferences);
const follows = useSelector((s: RootState) => s.login.follows);
export default function HyperText({
link,
creator,
}: {
link: string;
creator: HexKey;
}) {
const pref = useSelector((s: RootState) => s.login.preferences);
const follows = useSelector((s: RootState) => s.login.follows);
const render = useCallback(() => {
const a = link;
try {
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(creator);
if (pref.autoLoadMedia === "none" || hideNonFollows) {
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
}
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const spotifyId = SpotifyRegex.test(a);
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
if (extension) {
switch (extension) {
case "gif":
case "jpg":
case "jpeg":
case "png":
case "bmp":
case "webp": {
return <ProxyImg key={url.toString()} src={url.toString()} />;
}
case "wav":
case "mp3":
case "ogg": {
return <audio key={url.toString()} src={url.toString()} controls />
}
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v": {
return <video key={url.toString()} src={url.toString()} controls />
}
default:
return <a key={url.toString()} href={url.toString()} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{url.toString()}</a>
}
} else if (tweetId) {
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />
</div>
)
} else if (youtubeId) {
return (
<>
<br />
<iframe
className="w-max"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
<br />
</>
)
} else if (tidalId) {
return <TidalEmbed link={a} />
} else if (soundcloundId) {
return <SoundCloudEmbed link={a} />
} else if (mixcloudId) {
return <MixCloudEmbed link={a} />
} else if (spotifyId) {
return <SpotifyEmbed link={a} />
} else {
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
}
} catch (error) {
const render = useCallback(() => {
const a = link;
try {
const hideNonFollows =
pref.autoLoadMedia === "follows-only" && !follows.includes(creator);
if (pref.autoLoadMedia === "none" || hideNonFollows) {
return (
<a
href={a}
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext"
>
{a}
</a>
);
}
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const spotifyId = SpotifyRegex.test(a);
const extension =
FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
if (extension) {
switch (extension) {
case "gif":
case "jpg":
case "jpeg":
case "png":
case "bmp":
case "webp": {
return <ProxyImg key={url.toString()} src={url.toString()} />;
}
case "wav":
case "mp3":
case "ogg": {
return <audio key={url.toString()} src={url.toString()} controls />;
}
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v": {
return <video key={url.toString()} src={url.toString()} controls />;
}
default:
return (
<a
key={url.toString()}
href={url.toString()}
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext"
>
{url.toString()}
</a>
);
}
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
} else if (tweetId) {
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />
</div>
);
} else if (youtubeId) {
return (
<>
<br />
<iframe
className="w-max"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
<br />
</>
);
} else if (tidalId) {
return <TidalEmbed link={a} />;
} else if (soundcloundId) {
return <SoundCloudEmbed link={a} />;
} else if (mixcloudId) {
return <MixCloudEmbed link={a} />;
} else if (spotifyId) {
return <SpotifyEmbed link={a} />;
} else {
return (
<a
href={a}
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext"
>
{a}
</a>
);
}
} catch (error) {}
return (
<a
href={a}
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext"
>
{a}
</a>
);
}, [link]);
}, [link]);
return render();
return render();
}

View File

@ -1,22 +1,16 @@
import type { ReactNode } from "react";
interface IconButtonProps {
onClick(): void
children: ReactNode
onClick(): void;
children: ReactNode;
}
const IconButton = ({ onClick, children }: IconButtonProps) => {
return (
<button
className="icon"
type="button"
onClick={onClick}
>
<div className="icon-wrapper">
{children}
</div>
<button className="icon" type="button" onClick={onClick}>
<div className="icon-wrapper">{children}</div>
</button>
)
}
);
};
export default IconButton
export default IconButton;

View File

@ -9,95 +9,107 @@ import ZapCircle from "Icons/ZapCircle";
import useWebln from "Hooks/useWebln";
export interface InvoiceProps {
invoice: string
invoice: string;
}
export default function Invoice(props: InvoiceProps) {
const invoice = props.invoice;
const webln = useWebln();
const [showInvoice, setShowInvoice] = useState(false);
const invoice = props.invoice;
const webln = useWebln();
const [showInvoice, setShowInvoice] = useState(false);
const info = useMemo(() => {
try {
let parsed = invoiceDecode(invoice);
const info = useMemo(() => {
try {
let parsed = invoiceDecode(invoice);
let amount = parseInt(parsed.sections.find((a: any) => a.name === "amount")?.value);
let timestamp = parseInt(parsed.sections.find((a: any) => a.name === "timestamp")?.value);
let expire = parseInt(parsed.sections.find((a: any) => a.name === "expiry")?.value);
let description = parsed.sections.find((a: any) => a.name === "description")?.value;
let ret = {
amount: !isNaN(amount) ? (amount / 1000) : 0,
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
description,
expired: false
};
if (ret.expire) {
ret.expired = ret.expire < (new Date().getTime() / 1000);
}
return ret;
} catch (e) {
console.error(e);
}
}, [invoice]);
const [isPaid, setIsPaid] = useState(false);
const isExpired = info?.expired
const amount = info?.amount ?? 0
const description = info?.description
function header() {
return (
<>
<h4>Lightning Invoice</h4>
<ZapCircle className="zap-circle" />
<SendSats title="Pay Invoice" invoice={invoice} show={showInvoice} onClose={() => setShowInvoice(false)} />
</>
)
let amount = parseInt(
parsed.sections.find((a: any) => a.name === "amount")?.value
);
let timestamp = parseInt(
parsed.sections.find((a: any) => a.name === "timestamp")?.value
);
let expire = parseInt(
parsed.sections.find((a: any) => a.name === "expiry")?.value
);
let description = parsed.sections.find(
(a: any) => a.name === "description"
)?.value;
let ret = {
amount: !isNaN(amount) ? amount / 1000 : 0,
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
description,
expired: false,
};
if (ret.expire) {
ret.expired = ret.expire < new Date().getTime() / 1000;
}
return ret;
} catch (e) {
console.error(e);
}
}, [invoice]);
async function payInvoice(e: any) {
e.stopPropagation();
if (webln?.enabled) {
try {
await webln.sendPayment(invoice);
setIsPaid(true)
} catch (error) {
setShowInvoice(true);
}
} else {
const [isPaid, setIsPaid] = useState(false);
const isExpired = info?.expired;
const amount = info?.amount ?? 0;
const description = info?.description;
function header() {
return (
<>
<h4>Lightning Invoice</h4>
<ZapCircle className="zap-circle" />
<SendSats
title="Pay Invoice"
invoice={invoice}
show={showInvoice}
onClose={() => setShowInvoice(false)}
/>
</>
);
}
async function payInvoice(e: any) {
e.stopPropagation();
if (webln?.enabled) {
try {
await webln.sendPayment(invoice);
setIsPaid(true);
} catch (error) {
setShowInvoice(true);
}
} else {
setShowInvoice(true);
}
}
return (
<>
<div className={`note-invoice flex ${isExpired ? 'expired' : ''} ${isPaid ? 'paid' : ''}`}>
<div className="invoice-header">
{header()}
</div>
return (
<>
<div
className={`note-invoice flex ${isExpired ? "expired" : ""} ${
isPaid ? "paid" : ""
}`}
>
<div className="invoice-header">{header()}</div>
<p className="invoice-amount">
{amount > 0 && (
<>
{amount.toLocaleString()} <span className="sats">sat{amount === 1 ? '' : 's'}</span>
</>
)}
</p>
<p className="invoice-amount">
{amount > 0 && (
<>
{amount.toLocaleString()}{" "}
<span className="sats">sat{amount === 1 ? "" : "s"}</span>
</>
)}
</p>
<div className="invoice-body">
{description && <p>{description}</p>}
{isPaid ? (
<div className="paid">
Paid
</div>
) : (
<button disabled={isExpired} type="button" onClick={payInvoice}>
{isExpired ? "Expired" : "Pay"}
</button>
)}
</div>
</div>
</>
)
<div className="invoice-body">
{description && <p>{description}</p>}
{isPaid ? (
<div className="paid">Paid</div>
) : (
<button disabled={isExpired} type="button" onClick={payInvoice}>
{isExpired ? "Expired" : "Pay"}
</button>
)}
</div>
</div>
</>
);
}

View File

@ -1,22 +1,34 @@
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
export default function LoadMore({ onLoadMore, shouldLoadMore, children }: { onLoadMore: () => void, shouldLoadMore: boolean, children?: React.ReactNode }) {
const { ref, inView } = useInView();
const [tick, setTick] = useState<number>(0);
export default function LoadMore({
onLoadMore,
shouldLoadMore,
children,
}: {
onLoadMore: () => void;
shouldLoadMore: boolean;
children?: React.ReactNode;
}) {
const { ref, inView } = useInView();
const [tick, setTick] = useState<number>(0);
useEffect(() => {
if (inView === true && shouldLoadMore === true) {
onLoadMore();
}
}, [inView, shouldLoadMore, tick]);
useEffect(() => {
if (inView === true && shouldLoadMore === true) {
onLoadMore();
}
}, [inView, shouldLoadMore, tick]);
useEffect(() => {
let t = setInterval(() => {
setTick(x => x += 1);
}, 500);
return () => clearInterval(t);
}, []);
useEffect(() => {
let t = setInterval(() => {
setTick((x) => (x += 1));
}, 500);
return () => clearInterval(t);
}, []);
return <div ref={ref} className="mb10">{children ?? 'Loading...'}</div>;
}
return (
<div ref={ref} className="mb10">
{children ?? "Loading..."}
</div>
);
}

View File

@ -3,12 +3,19 @@ import { useNavigate } from "react-router-dom";
import { logout } from "State/Login";
export default function LogoutButton(){
const dispatch = useDispatch()
const navigate = useNavigate()
export default function LogoutButton() {
const dispatch = useDispatch();
const navigate = useNavigate();
return (
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>
<button
className="secondary"
type="button"
onClick={() => {
dispatch(logout());
navigate("/");
}}
>
Logout
</button>
)
);
}

View File

@ -5,17 +5,21 @@ import { HexKey } from "Nostr";
import { hexToBech32, profileLink } from "Util";
export default function Mention({ pubkey }: { pubkey: HexKey }) {
const user = useUserProfile(pubkey)
const user = useUserProfile(pubkey);
const name = useMemo(() => {
let name = hexToBech32("npub", pubkey).substring(0, 12);
if ((user?.display_name?.length ?? 0) > 0) {
name = user!.display_name!;
} else if ((user?.name?.length ?? 0) > 0) {
name = user!.name!;
}
return name;
}, [user, pubkey]);
const name = useMemo(() => {
let name = hexToBech32("npub", pubkey).substring(0, 12);
if ((user?.display_name?.length ?? 0) > 0) {
name = user!.display_name!;
} else if ((user?.name?.length ?? 0) > 0) {
name = user!.name!;
}
return name;
}, [user, pubkey]);
return <Link to={profileLink(pubkey)} onClick={(e) => e.stopPropagation()}>@{name}</Link>
return (
<Link to={profileLink(pubkey)} onClick={(e) => e.stopPropagation()}>
@{name}
</Link>
);
}

View File

@ -2,26 +2,30 @@ import { MixCloudRegex } from "Const";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
const MixCloudEmbed = ({link}: {link: string}) => {
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath =
(MixCloudRegex.test(link) && RegExp.$1) +
"%2F" +
(MixCloudRegex.test(link) && RegExp.$2);
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + ( MixCloudRegex.test(link) && RegExp.$2)
const lightTheme = useSelector<RootState, boolean>(
(s) => s.login.preferences.theme === "light"
);
const lightTheme = useSelector<RootState, boolean>(s => s.login.preferences.theme === "light");
const lightParams = lightTheme ? "light=1" : "light=0";
const lightParams = lightTheme ? "light=1" : "light=0";
return (
<>
<br />
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
/>
</>
);
};
return(
<>
<br/>
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
/>
</>
)
}
export default MixCloudEmbed;
export default MixCloudEmbed;

View File

@ -1,27 +1,27 @@
.modal {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background-color: var(--modal-bg-color);
display: flex;
justify-content: center;
align-items: center;
z-index: 42;
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background-color: var(--modal-bg-color);
display: flex;
justify-content: center;
align-items: center;
z-index: 42;
}
.modal-body {
background-color: var(--note-bg);
padding: 10px;
border-radius: 10px;
width: 500px;
min-height: 10vh;
background-color: var(--note-bg);
padding: 10px;
border-radius: 10px;
width: 500px;
min-height: 10vh;
}
@media(max-width: 720px) {
.modal-body {
width: 100vw;
margin: 0 10px;
}
@media (max-width: 720px) {
.modal-body {
width: 100vw;
margin: 0 10px;
}
}

View File

@ -1,18 +1,18 @@
import "./Modal.css";
import { useEffect, useRef } from "react"
import { useEffect, useRef } from "react";
import * as React from "react";
export interface ModalProps {
className?: string
onClose?: () => void,
children: React.ReactNode
className?: string;
onClose?: () => void;
children: React.ReactNode;
}
function useOnClickOutside(ref: any, onClickOutside: () => void) {
useEffect(() => {
function handleClickOutside(ev: any) {
if (ref && ref.current && !ref.current.contains(ev.target)) {
onClickOutside()
onClickOutside();
}
}
document.addEventListener("mousedown", handleClickOutside);
@ -23,21 +23,21 @@ function useOnClickOutside(ref: any, onClickOutside: () => void) {
}
export default function Modal(props: ModalProps) {
const ref = useRef(null);
const onClose = props.onClose || (() => { });
const className = props.className || ''
useOnClickOutside(ref, onClose)
const ref = useRef(null);
const onClose = props.onClose || (() => {});
const className = props.className || "";
useOnClickOutside(ref, onClose);
useEffect(() => {
document.body.classList.add("scroll-lock");
return () => document.body.classList.remove("scroll-lock");
}, []);
useEffect(() => {
document.body.classList.add("scroll-lock");
return () => document.body.classList.remove("scroll-lock");
}, []);
return (
<div className={`modal ${className}`}>
<div ref={ref} className="modal-body">
{props.children}
</div>
</div>
)
return (
<div className={`modal ${className}`}>
<div ref={ref} className="modal-body">
{props.children}
</div>
</div>
);
}

View File

@ -2,20 +2,20 @@ import { HexKey } from "Nostr";
import useModeration from "Hooks/useModeration";
interface MuteButtonProps {
pubkey: HexKey
pubkey: HexKey;
}
const MuteButton = ({ pubkey }: MuteButtonProps) => {
const { mute, unmute, isMuted } = useModeration()
const { mute, unmute, isMuted } = useModeration();
return isMuted(pubkey) ? (
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
Unmute
Unmute
</button>
) : (
<button type="button" onClick={() => mute(pubkey)}>
Mute
Mute
</button>
)
}
);
};
export default MuteButton
export default MuteButton;

View File

@ -1,38 +1,48 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey } from "Nostr"; import type { RootState } from "State/Store";
import { HexKey } from "Nostr";
import type { RootState } from "State/Store";
import MuteButton from "Element/MuteButton";
import ProfilePreview from "Element/ProfilePreview";
import useMutedFeed, { getMuted } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
export interface MutedListProps {
pubkey: HexKey
pubkey: HexKey;
}
export default function MutedList({ pubkey }: MutedListProps) {
const { muted, isMuted, mute, unmute, muteAll } = useModeration();
const feed = useMutedFeed(pubkey)
const pubkeys = useMemo(() => {
return getMuted(feed.store, pubkey);
}, [feed, pubkey]);
const hasAllMuted = pubkeys.every(isMuted)
const { muted, isMuted, mute, unmute, muteAll } = useModeration();
const feed = useMutedFeed(pubkey);
const pubkeys = useMemo(() => {
return getMuted(feed.store, pubkey);
}, [feed, pubkey]);
const hasAllMuted = pubkeys.every(isMuted);
return (
<div className="main-content">
<div className="flex mt10">
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
<button
disabled={hasAllMuted || pubkeys.length === 0}
className="transparent" type="button" onClick={() => muteAll(pubkeys)}
>
Mute all
</button>
</div>
{pubkeys?.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
</div>
)
return (
<div className="main-content">
<div className="flex mt10">
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
<button
disabled={hasAllMuted || pubkeys.length === 0}
className="transparent"
type="button"
onClick={() => muteAll(pubkeys)}
>
Mute all
</button>
</div>
{pubkeys?.map((a) => {
return (
<ProfilePreview
actions={<MuteButton pubkey={a} />}
pubkey={a}
options={{ about: false }}
key={a}
/>
);
})}
</div>
);
}

View File

@ -47,5 +47,5 @@
}
.nip05 .badge {
margin: .1em .2em;
margin: 0.1em 0.2em;
}

View File

@ -1,13 +1,17 @@
import { useQuery } from "react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleCheck, faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import {
faCircleCheck,
faSpinner,
faTriangleExclamation,
} from "@fortawesome/free-solid-svg-icons";
import './Nip05.css'
import "./Nip05.css";
import { HexKey } from "Nostr";
interface NostrJson {
names: Record<string, string>
names: Record<string, string>;
}
async function fetchNip05Pubkey(name: string, domain: string) {
@ -15,54 +19,60 @@ async function fetchNip05Pubkey(name: string, domain: string) {
return undefined;
}
try {
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
const res = await fetch(
`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(
name
)}`
);
const data: NostrJson = await res.json();
const match = Object.keys(data.names).find(n => {
const match = Object.keys(data.names).find((n) => {
return n.toLowerCase() === name.toLowerCase();
});
return match ? data.names[match] : undefined;
} catch (error) {
return undefined
return undefined;
}
}
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000;
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000;
export function useIsVerified(pubkey: HexKey, nip05?: string) {
const [name, domain] = nip05 ? nip05.split('@') : []
const [name, domain] = nip05 ? nip05.split("@") : [];
const { isError, isSuccess, data } = useQuery(
['nip05', nip05],
["nip05", nip05],
() => fetchNip05Pubkey(name, domain),
{
retry: false,
retryOnMount: false,
cacheTime: VERIFICATION_CACHE_TIME,
staleTime: VERIFICATION_STALE_TIMEOUT,
},
)
const isVerified = isSuccess && data === pubkey
const cantVerify = isSuccess && data !== pubkey
return { isVerified, couldNotVerify: isError || cantVerify }
}
);
const isVerified = isSuccess && data === pubkey;
const cantVerify = isSuccess && data !== pubkey;
return { isVerified, couldNotVerify: isError || cantVerify };
}
export interface Nip05Params {
nip05?: string,
pubkey: HexKey
nip05?: string;
pubkey: HexKey;
}
const Nip05 = (props: Nip05Params) => {
const [name, domain] = props.nip05 ? props.nip05.split('@') : []
const isDefaultUser = name === '_'
const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05)
const [name, domain] = props.nip05 ? props.nip05.split("@") : [];
const isDefaultUser = name === "_";
const { isVerified, couldNotVerify } = useIsVerified(
props.pubkey,
props.nip05
);
return (
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}>
{!isDefaultUser && (
<div className="nick">
{`${name}@`}
</div>
)}
<div
className={`flex nip05${couldNotVerify ? " failed" : ""}`}
onClick={(ev) => ev.stopPropagation()}
>
{!isDefaultUser && <div className="nick">{`${name}@`}</div>}
<span className="domain" data-domain={domain?.toLowerCase()}>
{domain}
</span>
@ -90,7 +100,7 @@ const Nip05 = (props: Nip05Params) => {
)}
</span>
</div>
)
}
);
};
export default Nip05
export default Nip05;

View File

@ -2,195 +2,260 @@ import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
ServiceProvider,
ServiceConfig,
ServiceError,
HandleAvailability,
ServiceErrorCode,
HandleRegisterResponse,
CheckRegisterResponse
ServiceProvider,
ServiceConfig,
ServiceError,
HandleAvailability,
ServiceErrorCode,
HandleRegisterResponse,
CheckRegisterResponse,
} from "Nip05/ServiceProvider";
import AsyncButton from "Element/AsyncButton";
import SendSats from "Element/SendSats";
import Copy from "Element/Copy";
import { useUserProfile }from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import useEventPublisher from "Feed/EventPublisher";
import { debounce, hexToBech32 } from "Util";
import { UserMetadata } from "Nostr";
type Nip05ServiceProps = {
name: string,
service: URL | string,
about: JSX.Element,
link: string,
supportLink: string
name: string;
service: URL | string;
about: JSX.Element;
link: string;
supportLink: string;
};
type ReduxStore = any;
export default function Nip5Service(props: Nip05ServiceProps) {
const navigate = useNavigate();
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
const user = useUserProfile(pubkey);
const publisher = useEventPublisher();
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
const [error, setError] = useState<ServiceError>();
const [handle, setHandle] = useState<string>("");
const [domain, setDomain] = useState<string>("");
const [availabilityResponse, setAvailabilityResponse] = useState<HandleAvailability>();
const [registerResponse, setRegisterResponse] = useState<HandleRegisterResponse>();
const [showInvoice, setShowInvoice] = useState<boolean>(false);
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
const navigate = useNavigate();
const pubkey = useSelector<ReduxStore, string>((s) => s.login.publicKey);
const user = useUserProfile(pubkey);
const publisher = useEventPublisher();
const svc = useMemo(
() => new ServiceProvider(props.service),
[props.service]
);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
const [error, setError] = useState<ServiceError>();
const [handle, setHandle] = useState<string>("");
const [domain, setDomain] = useState<string>("");
const [availabilityResponse, setAvailabilityResponse] =
useState<HandleAvailability>();
const [registerResponse, setRegisterResponse] =
useState<HandleRegisterResponse>();
const [showInvoice, setShowInvoice] = useState<boolean>(false);
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain, serviceConfig]);
const domainConfig = useMemo(
() => serviceConfig?.domains.find((a) => a.name === domain),
[domain, serviceConfig]
);
useEffect(() => {
svc.GetConfig()
.then(a => {
if ('error' in a) {
setError(a as ServiceError)
} else {
let svc = a as ServiceConfig;
setServiceConfig(svc);
let defaultDomain = svc.domains.find(a => a.default)?.name || svc.domains[0].name;
setDomain(defaultDomain);
}
})
.catch(console.error)
}, [props, svc]);
useEffect(() => {
setError(undefined);
setAvailabilityResponse(undefined);
if (handle && domain) {
if (handle.length < (domainConfig?.length[0] ?? 2)) {
setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
return;
}
if (handle.length > (domainConfig?.length[1] ?? 20)) {
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
return;
}
let rx = new RegExp(domainConfig?.regex[0] ?? "", domainConfig?.regex[1] ?? "");
if (!rx.test(handle)) {
setAvailabilityResponse({ available: false, why: "REGEX" });
return;
}
return debounce(500, () => {
svc.CheckAvailable(handle, domain)
.then(a => {
if ('error' in a) {
setError(a as ServiceError);
} else {
setAvailabilityResponse(a as HandleAvailability);
}
})
.catch(console.error);
});
}
}, [handle, domain, domainConfig, svc]);
useEffect(() => {
if (registerResponse && showInvoice) {
let t = setInterval(async () => {
let status = await svc.CheckRegistration(registerResponse.token);
if ('error' in status) {
setError(status);
setRegisterResponse(undefined);
setShowInvoice(false);
} else {
let result: CheckRegisterResponse = status;
if (result.available && result.paid) {
setShowInvoice(false);
setRegisterStatus(status);
setRegisterResponse(undefined);
setError(undefined);
}
}
}, 2_000);
return () => clearInterval(t);
}
}, [registerResponse, showInvoice, svc])
function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
let whyMap = new Map([
["TOO_SHORT", "name too short"],
["TOO_LONG", "name too long"],
["REGEX", "name has disallowed characters"],
["REGISTERED", "name is registered"],
["DISALLOWED_null", "name is blocked"],
["DISALLOWED_later", "name will be available later"],
]);
return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e);
}
async function startBuy(handle: string, domain: string) {
if (registerResponse) {
setShowInvoice(true);
return;
}
let rsp = await svc.RegisterHandle(handle, domain, pubkey);
if ('error' in rsp) {
setError(rsp);
useEffect(() => {
svc
.GetConfig()
.then((a) => {
if ("error" in a) {
setError(a as ServiceError);
} else {
setRegisterResponse(rsp);
setShowInvoice(true);
let svc = a as ServiceConfig;
setServiceConfig(svc);
let defaultDomain =
svc.domains.find((a) => a.default)?.name || svc.domains[0].name;
setDomain(defaultDomain);
}
})
.catch(console.error);
}, [props, svc]);
useEffect(() => {
setError(undefined);
setAvailabilityResponse(undefined);
if (handle && domain) {
if (handle.length < (domainConfig?.length[0] ?? 2)) {
setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
return;
}
if (handle.length > (domainConfig?.length[1] ?? 20)) {
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
return;
}
let rx = new RegExp(
domainConfig?.regex[0] ?? "",
domainConfig?.regex[1] ?? ""
);
if (!rx.test(handle)) {
setAvailabilityResponse({ available: false, why: "REGEX" });
return;
}
return debounce(500, () => {
svc
.CheckAvailable(handle, domain)
.then((a) => {
if ("error" in a) {
setError(a as ServiceError);
} else {
setAvailabilityResponse(a as HandleAvailability);
}
})
.catch(console.error);
});
}
}, [handle, domain, domainConfig, svc]);
useEffect(() => {
if (registerResponse && showInvoice) {
let t = setInterval(async () => {
let status = await svc.CheckRegistration(registerResponse.token);
if ("error" in status) {
setError(status);
setRegisterResponse(undefined);
setShowInvoice(false);
} else {
let result: CheckRegisterResponse = status;
if (result.available && result.paid) {
setShowInvoice(false);
setRegisterStatus(status);
setRegisterResponse(undefined);
setError(undefined);
}
}
}, 2_000);
return () => clearInterval(t);
}
}, [registerResponse, showInvoice, svc]);
function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
let whyMap = new Map([
["TOO_SHORT", "name too short"],
["TOO_LONG", "name too long"],
["REGEX", "name has disallowed characters"],
["REGISTERED", "name is registered"],
["DISALLOWED_null", "name is blocked"],
["DISALLOWED_later", "name will be available later"],
]);
return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e);
}
async function startBuy(handle: string, domain: string) {
if (registerResponse) {
setShowInvoice(true);
return;
}
async function updateProfile(handle: string, domain: string) {
if (user) {
let newProfile = {
...user,
nip05: `${handle}@${domain}`
} as UserMetadata;
let ev = await publisher.metadata(newProfile);
publisher.broadcast(ev);
navigate("/settings");
}
let rsp = await svc.RegisterHandle(handle, domain, pubkey);
if ("error" in rsp) {
setError(rsp);
} else {
setRegisterResponse(rsp);
setShowInvoice(true);
}
}
return (
<>
<h3>{props.name}</h3>
{props.about}
<p>Find out more info about {props.name} at <a href={props.link} target="_blank" rel="noreferrer">{props.link}</a></p>
{error && <b className="error">{error.error}</b>}
{!registerStatus && <div className="flex mb10">
<input type="text" placeholder="Handle" value={handle} onChange={(e) => setHandle(e.target.value.toLowerCase())} />
&nbsp;@&nbsp;
<select value={domain} onChange={(e) => setDomain(e.target.value)}>
{serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)}
</select>
</div>}
{availabilityResponse?.available && !registerStatus && <div className="flex">
<div className="mr10">
{availabilityResponse.quote?.price.toLocaleString()} sats<br />
<small>{availabilityResponse.quote?.data.type}</small>
</div>
<input type="text" className="f-grow mr10" placeholder="pubkey" value={hexToBech32("npub", pubkey)} disabled />
<AsyncButton onClick={() => startBuy(handle, domain)}>Buy Now</AsyncButton>
</div>}
{availabilityResponse?.available === false && !registerStatus && <div className="flex">
<b className="error">Not available: {mapError(availabilityResponse.why!, availabilityResponse.reasonTag || null)}</b>
</div>}
<SendSats
invoice={registerResponse?.invoice}
show={showInvoice}
onClose={() => setShowInvoice(false)}
title={`Buying ${handle}@${domain}`} />
{registerStatus?.paid && <div className="flex f-col">
<h4>Order Paid!</h4>
<p>Your new NIP-05 handle is: <code>{handle}@{domain}</code></p>
<h3>Account Support</h3>
<p>Please make sure to save the following password in order to manage your handle in the future</p>
<Copy text={registerStatus.password} />
<p>Go to <a href={props.supportLink} target="_blank" rel="noreferrer">account page</a></p>
<h4>Activate Now</h4>
<AsyncButton onClick={() => updateProfile(handle, domain)}>Add to Profile</AsyncButton>
</div>}
</>
)
async function updateProfile(handle: string, domain: string) {
if (user) {
let newProfile = {
...user,
nip05: `${handle}@${domain}`,
} as UserMetadata;
let ev = await publisher.metadata(newProfile);
publisher.broadcast(ev);
navigate("/settings");
}
}
return (
<>
<h3>{props.name}</h3>
{props.about}
<p>
Find out more info about {props.name} at{" "}
<a href={props.link} target="_blank" rel="noreferrer">
{props.link}
</a>
</p>
{error && <b className="error">{error.error}</b>}
{!registerStatus && (
<div className="flex mb10">
<input
type="text"
placeholder="Handle"
value={handle}
onChange={(e) => setHandle(e.target.value.toLowerCase())}
/>
&nbsp;@&nbsp;
<select value={domain} onChange={(e) => setDomain(e.target.value)}>
{serviceConfig?.domains.map((a) => (
<option key={a.name}>{a.name}</option>
))}
</select>
</div>
)}
{availabilityResponse?.available && !registerStatus && (
<div className="flex">
<div className="mr10">
{availabilityResponse.quote?.price.toLocaleString()} sats
<br />
<small>{availabilityResponse.quote?.data.type}</small>
</div>
<input
type="text"
className="f-grow mr10"
placeholder="pubkey"
value={hexToBech32("npub", pubkey)}
disabled
/>
<AsyncButton onClick={() => startBuy(handle, domain)}>
Buy Now
</AsyncButton>
</div>
)}
{availabilityResponse?.available === false && !registerStatus && (
<div className="flex">
<b className="error">
Not available:{" "}
{mapError(
availabilityResponse.why!,
availabilityResponse.reasonTag || null
)}
</b>
</div>
)}
<SendSats
invoice={registerResponse?.invoice}
show={showInvoice}
onClose={() => setShowInvoice(false)}
title={`Buying ${handle}@${domain}`}
/>
{registerStatus?.paid && (
<div className="flex f-col">
<h4>Order Paid!</h4>
<p>
Your new NIP-05 handle is:{" "}
<code>
{handle}@{domain}
</code>
</p>
<h3>Account Support</h3>
<p>
Please make sure to save the following password in order to manage
your handle in the future
</p>
<Copy text={registerStatus.password} />
<p>
Go to{" "}
<a href={props.supportLink} target="_blank" rel="noreferrer">
account page
</a>
</p>
<h4>Activate Now</h4>
<AsyncButton onClick={() => updateProfile(handle, domain)}>
Add to Profile
</AsyncButton>
</div>
)}
</>
);
}

View File

@ -2,27 +2,27 @@
min-height: 110px;
}
.note>.header .reply {
.note > .header .reply {
font-size: 13px;
color: var(--font-secondary-color);
}
.note>.header .reply a {
.note > .header .reply a {
color: var(--highlight);
}
.note>.header .reply a:hover {
.note > .header .reply a:hover {
text-decoration-color: var(--highlight);
}
.note>.header>.info {
.note > .header > .info {
font-size: var(--font-size);
margin-left: 4px;
white-space: nowrap;
color: var(--font-secondary-color);
}
.note>.body {
.note > .body {
margin-top: 4px;
margin-bottom: 24px;
padding-left: 56px;
@ -33,7 +33,7 @@
overflow-y: visible;
}
.note>.footer {
.note > .footer {
padding-left: 46px;
}
@ -49,7 +49,7 @@
}
}
.note>.footer .ctx-menu {
.note > .footer .ctx-menu {
background-color: var(--note-bg);
color: var(--font-secondary-color);
border: 1px solid var(--font-secondary-color);
@ -57,7 +57,7 @@
min-width: 0;
}
.note>.footer .ctx-menu li {
.note > .footer .ctx-menu li {
display: grid;
grid-template-columns: 2rem auto;
}
@ -66,11 +66,13 @@
color: var(--error);
}
.note>.header img:hover, .note>.header .name>.reply:hover, .note .body:hover {
.note > .header img:hover,
.note > .header .name > .reply:hover,
.note .body:hover {
cursor: pointer;
}
.note>.note-creator {
.note > .note-creator {
margin-top: 12px;
margin-left: 56px;
}
@ -116,7 +118,7 @@
}
.hidden-note button {
max-height: 30px;
max-height: 30px;
}
.expand-note {

View File

@ -1,5 +1,11 @@
import "./Note.css";
import { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
import {
useCallback,
useMemo,
useState,
useLayoutEffect,
ReactNode,
} from "react";
import { useNavigate, Link } from "react-router-dom";
import { useInView } from "react-intersection-observer";
@ -17,49 +23,57 @@ import { TaggedRawEvent, u256 } from "Nostr";
import useModeration from "Hooks/useModeration";
export interface NoteProps {
data?: TaggedRawEvent,
className?: string
related: TaggedRawEvent[],
highlight?: boolean,
ignoreModeration?: boolean,
data?: TaggedRawEvent;
className?: string;
related: TaggedRawEvent[];
highlight?: boolean;
ignoreModeration?: boolean;
options?: {
showHeader?: boolean,
showTime?: boolean,
showFooter?: boolean
},
["data-ev"]?: NEvent
showHeader?: boolean;
showTime?: boolean;
showFooter?: boolean;
};
["data-ev"]?: NEvent;
}
const HiddenNote = ({ children }: any) => {
const [show, setShow] = useState(false)
return show ? children : (
const [show, setShow] = useState(false);
return show ? (
children
) : (
<div className="card note hidden-note">
<div className="header">
<p>
This author has been muted
</p>
<button onClick={() => setShow(true)}>
Show
</button>
<p>This author has been muted</p>
<button onClick={() => setShow(true)}>Show</button>
</div>
</div>
)
}
);
};
export default function Note(props: NoteProps) {
const navigate = useNavigate();
const { data, className, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props
const {
data,
className,
related,
highlight,
options: opt,
["data-ev"]: parsedEvent,
ignoreModeration = false,
} = props;
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
const users = useUserProfiles(pubKeys);
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
const { isMuted } = useModeration()
const isOpMuted = isMuted(ev.PubKey)
const deletions = useMemo(
() => getReactions(related, ev.Id, EventKind.Deletion),
[related]
);
const { isMuted } = useModeration();
const isOpMuted = isMuted(ev.PubKey);
const { ref, inView, entry } = useInView({ triggerOnce: true });
const [extendable, setExtendable] = useState<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(false);
const baseClassname = `note card ${props.className ? props.className : ''}`
const baseClassname = `note card ${props.className ? props.className : ""}`;
const [translated, setTranslated] = useState<Translation>();
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
@ -67,15 +81,22 @@ export default function Note(props: NoteProps) {
showHeader: true,
showTime: true,
showFooter: true,
...opt
...opt,
};
const transformBody = useCallback(() => {
let body = ev?.Content ?? "";
if (deletions?.length > 0) {
return (<b className="error">Deleted</b>);
return <b className="error">Deleted</b>;
}
return <Text content={body} tags={ev.Tags} users={users || new Map()} creator={ev.PubKey}/>;
return (
<Text
content={body}
tags={ev.Tags}
users={users || new Map()}
creator={ev.PubKey}
/>
);
}, [ev]);
useLayoutEffect(() => {
@ -99,47 +120,45 @@ export default function Note(props: NoteProps) {
const maxMentions = 2;
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
let mentions: { pk: string, name: string, link: ReactNode }[] = [];
let mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (let pk of ev.Thread?.PubKeys) {
const u = users?.get(pk);
const npub = hexToBech32("npub", pk)
const npub = hexToBech32("npub", pk);
const shortNpub = npub.substring(0, 12);
if (u) {
mentions.push({
pk,
name: u.name ?? shortNpub,
link: (
<Link to={`/p/${npub}`}>
{u.name ? `@${u.name}` : shortNpub}
</Link>
)
<Link to={`/p/${npub}`}>{u.name ? `@${u.name}` : shortNpub}</Link>
),
});
} else {
mentions.push({
pk,
name: shortNpub,
link: (
<Link to={`/p/${npub}`}>
{shortNpub}
</Link>
)
link: <Link to={`/p/${npub}`}>{shortNpub}</Link>,
});
}
}
mentions.sort((a, b) => a.name.startsWith("npub") ? 1 : -1);
let othersLength = mentions.length - maxMentions
mentions.sort((a, b) => (a.name.startsWith("npub") ? 1 : -1));
let othersLength = mentions.length - maxMentions;
const renderMention = (m: any, idx: number) => {
return (
<>
{idx > 0 && ", "}
{m.link}
</>
)
}
const pubMentions = mentions.length > maxMentions ? (
mentions?.slice(0, maxMentions).map(renderMention)
) : mentions?.map(renderMention);
const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : ''
);
};
const pubMentions =
mentions.length > maxMentions
? mentions?.slice(0, maxMentions).map(renderMention)
: mentions?.map(renderMention);
const others =
mentions.length > maxMentions
? ` & ${othersLength} other${othersLength > 1 ? "s" : ""}`
: "";
return (
<div className="reply">
re:&nbsp;
@ -148,68 +167,95 @@ export default function Note(props: NoteProps) {
{pubMentions}
{others}
</>
) : replyId && (
<Link to={eventLink(replyId)}>
{hexToBech32("note", replyId)?.substring(0, 12)}
</Link>
) : (
replyId && (
<Link to={eventLink(replyId)}>
{hexToBech32("note", replyId)?.substring(0, 12)}
</Link>
)
)}
</div>
)
);
}
if (ev.Kind !== EventKind.TextNote) {
return (
<>
<h4>Unknown event kind: {ev.Kind}</h4>
<pre>
{JSON.stringify(ev.ToObject(), undefined, ' ')}
</pre>
<pre>{JSON.stringify(ev.ToObject(), undefined, " ")}</pre>
</>
);
}
function translation() {
if (translated && translated.confidence > 0.5) {
return <>
<p className="highlight">Translated from {translated.fromLanguage}:</p>
{translated.text}
</>
return (
<>
<p className="highlight">
Translated from {translated.fromLanguage}:
</p>
{translated.text}
</>
);
} else if (translated) {
return <p className="highlight">Translation failed</p>
return <p className="highlight">Translation failed</p>;
}
}
function content() {
if (!inView) return null;
return (
<>
{options.showHeader ?
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
{options.showTime ?
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
</div> : null}
</div> : null}
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
{translation()}
</div>
{extendable && !showMore && (
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
Show more
</span>
)}
{options.showFooter && <NoteFooter ev={ev} related={related} onTranslated={(t) => setTranslated(t)} />}
</>
)
<>
{options.showHeader ? (
<div className="header flex">
<ProfileImage
pubkey={ev.RootPubKey}
subHeader={replyTag() ?? undefined}
/>
{options.showTime ? (
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
</div>
) : null}
</div>
) : null}
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
{translation()}
</div>
{extendable && !showMore && (
<span
className="expand-note mt10 flex f-center"
onClick={() => setShowMore(true)}
>
Show more
</span>
)}
{options.showFooter && (
<NoteFooter
ev={ev}
related={related}
onTranslated={(t) => setTranslated(t)}
/>
)}
</>
);
}
const note = (
<div className={`${baseClassname}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`} ref={ref}>
<div
className={`${baseClassname}${highlight ? " active " : " "}${
extendable && !showMore ? " note-expand" : ""
}`}
ref={ref}
>
{content()}
</div>
)
);
return !ignoreModeration && isOpMuted ? <HiddenNote>{note}</HiddenNote> : note
return !ignoreModeration && isOpMuted ? (
<HiddenNote>{note}</HiddenNote>
) : (
note
);
}

View File

@ -1,25 +1,25 @@
.note-creator {
margin-bottom: 10px;
background-color: var(--note-bg);
border: none;
border-radius: 10px;
padding: 6px;
position: relative;
margin-bottom: 10px;
background-color: var(--note-bg);
border: none;
border-radius: 10px;
padding: 6px;
position: relative;
}
.note-reply {
margin: 10px;
margin: 10px;
}
.note-creator textarea {
border: none;
outline: none;
resize: none;
background-color: var(--note-bg);
border-radius: 10px 10px 0 0;
min-height: 120px;
max-width: stretch;
min-width: stretch;
border: none;
outline: none;
resize: none;
background-color: var(--note-bg);
border-radius: 10px 10px 0 0;
min-height: 120px;
max-width: stretch;
min-width: stretch;
}
.note-creator textarea::placeholder {
@ -29,20 +29,24 @@
}
@media (min-width: 520px) {
.note-creator textarea { min-height: 210px; }
.note-creator textarea {
min-height: 210px;
}
}
@media (min-width: 720px) {
.note-creator textarea { min-height: 321px; }
.note-creator textarea {
min-height: 321px;
}
}
.note-creator-actions {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-bottom: 5px;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-bottom: 5px;
}
.note-creator .attachment {
@ -75,24 +79,24 @@
}
.note-creator-actions button:not(:last-child) {
margin-right: 4px;
margin-right: 4px;
}
.note-creator .error {
position: absolute;
left: 16px;
bottom: 12px;
font-color: var(--error);
margin-right: 12px;
font-size: 16px;
position: absolute;
left: 16px;
bottom: 12px;
font-color: var(--error);
margin-right: 12px;
font-size: 16px;
}
.note-creator .btn {
border-radius: 20px;
font-weight: bold;
background-color: var(--bg-color);
color: var(--font-color);
font-size: var(--font-size);
border-radius: 20px;
font-weight: bold;
background-color: var(--bg-color);
color: var(--font-color);
font-size: var(--font-size);
}
.note-create-button {

View File

@ -11,7 +11,7 @@ import { default as NEvent } from "Nostr/Event";
import useFileUpload from "Upload";
interface NotePreviewProps {
note: NEvent
note: NEvent;
}
function NotePreview({ note }: NotePreviewProps) {
@ -20,32 +20,34 @@ function NotePreview({ note }: NotePreviewProps) {
<ProfileImage pubkey={note.PubKey} />
<div className="note-preview-body">
{note.Content.slice(0, 136)}
{note.Content.length > 140 && '...'}
{note.Content.length > 140 && "..."}
</div>
</div>
)
);
}
export interface NoteCreatorProps {
show: boolean
setShow: (s: boolean) => void
replyTo?: NEvent,
onSend?: Function,
autoFocus: boolean
show: boolean;
setShow: (s: boolean) => void;
replyTo?: NEvent;
onSend?: Function;
autoFocus: boolean;
}
export function NoteCreator(props: NoteCreatorProps) {
const { show, setShow, replyTo, onSend, autoFocus } = props
const { show, setShow, replyTo, onSend, autoFocus } = props;
const publisher = useEventPublisher();
const [note, setNote] = useState<string>();
const [error, setError] = useState<string>();
const [active, setActive] = useState<boolean>(false);
const uploader = useFileUpload();
const hasErrors = (error?.length ?? 0) > 0
const hasErrors = (error?.length ?? 0) > 0;
async function sendNote() {
if (note) {
let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
let ev = replyTo
? await publisher.reply(replyTo, note)
: await publisher.note(note);
console.debug("Sending note: ", ev);
publisher.broadcast(ev);
setNote("");
@ -63,29 +65,29 @@ export function NoteCreator(props: NoteCreatorProps) {
if (file) {
let rx = await uploader.upload(file, file.name);
if (rx.url) {
setNote(n => `${n ? `${n}\n` : ""}${rx.url}`);
setNote((n) => `${n ? `${n}\n` : ""}${rx.url}`);
} else if (rx?.error) {
setError(rx.error);
}
}
} catch (error: any) {
setError(error?.message)
setError(error?.message);
}
}
function onChange(ev: any) {
const { value } = ev.target
setNote(value)
const { value } = ev.target;
setNote(value);
if (value) {
setActive(true)
setActive(true);
} else {
setActive(false)
setActive(false);
}
}
function cancel(ev: any) {
setShow(false)
setNote("")
setShow(false);
setNote("");
}
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
@ -96,14 +98,9 @@ export function NoteCreator(props: NoteCreatorProps) {
return (
<>
{show && (
<Modal
className="note-creator-modal"
onClose={() => setShow(false)}
>
{replyTo && (
<NotePreview note={replyTo} />
)}
<div className={`flex note-creator ${replyTo ? 'note-reply' : ''}`}>
<Modal className="note-creator-modal" onClose={() => setShow(false)}>
{replyTo && <NotePreview note={replyTo} />}
<div className={`flex note-creator ${replyTo ? "note-reply" : ""}`}>
<div className="flex f-col mr10 f-grow">
<Textarea
autoFocus={autoFocus}
@ -112,7 +109,11 @@ export function NoteCreator(props: NoteCreatorProps) {
value={note}
onFocus={() => setActive(true)}
/>
<button type="button" className="attachment" onClick={(e) => attachFile()}>
<button
type="button"
className="attachment"
onClick={(e) => attachFile()}
>
<Attachment />
</button>
</div>
@ -123,7 +124,7 @@ export function NoteCreator(props: NoteCreatorProps) {
Cancel
</button>
<button type="button" onClick={onSubmit}>
{replyTo ? 'Reply' : 'Send'}
{replyTo ? "Reply" : "Send"}
</button>
</div>
</Modal>

View File

@ -1,8 +1,16 @@
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash, faBan, faLanguage } from "@fortawesome/free-solid-svg-icons";
import {
faTrash,
faRepeat,
faShareNodes,
faCopy,
faCommentSlash,
faBan,
faLanguage,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Menu, MenuItem } from '@szhsin/react-menu';
import { Menu, MenuItem } from "@szhsin/react-menu";
import Dislike from "Icons/Dislike";
import Heart from "Icons/Heart";
@ -25,55 +33,76 @@ import useModeration from "Hooks/useModeration";
import { TranslateHost } from "Const";
export interface Translation {
text: string,
fromLanguage: string,
confidence: number
text: string;
fromLanguage: string;
confidence: number;
}
export interface NoteFooterProps {
related: TaggedRawEvent[],
ev: NEvent,
onTranslated?: (content: Translation) => void
related: TaggedRawEvent[];
ev: NEvent;
onTranslated?: (content: Translation) => void;
}
export default function NoteFooter(props: NoteFooterProps) {
const { related, ev } = props;
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const login = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
const { mute, block } = useModeration();
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const prefs = useSelector<RootState, UserPreferences>(
(s) => s.login.preferences
);
const author = useUserProfile(ev.RootPubKey);
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], { type: "language" });
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related, ev]);
const zaps = useMemo(() =>
getReactions(related, ev.Id, EventKind.ZapReceipt).map(parseZap).filter(z => z.valid && z.zapper !== ev.PubKey),
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const reactions = useMemo(
() => getReactions(related, ev.Id, EventKind.Reaction),
[related, ev]
);
const reposts = useMemo(
() => getReactions(related, ev.Id, EventKind.Repost),
[related, ev]
);
const zaps = useMemo(
() =>
getReactions(related, ev.Id, EventKind.ZapReceipt)
.map(parseZap)
.filter((z) => z.valid && z.zapper !== ev.PubKey),
[related]
);
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0)
const didZap = zaps.some(a => a.zapper === login);
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = zaps.some((a) => a.zapper === login);
const groupReactions = useMemo(() => {
return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(content);
const amount = acc[r] || 0
return { ...acc, [r]: amount + 1 }
}, {
[Reaction.Positive]: 0,
[Reaction.Negative]: 0
});
return reactions?.reduce(
(acc, { content }) => {
let r = normalizeReaction(content);
const amount = acc[r] || 0;
return { ...acc, [r]: amount + 1 };
},
{
[Reaction.Positive]: 0,
[Reaction.Negative]: 0,
}
);
}, [reactions]);
function hasReacted(emoji: string) {
return reactions?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login)
return reactions?.some(
({ pubkey, content }) =>
normalizeReaction(content) === emoji && pubkey === login
);
}
function hasReposted() {
return reposts.some(a => a.pubkey === login);
return reposts.some((a) => a.pubkey === login);
}
async function react(content: string) {
@ -84,7 +113,11 @@ export default function NoteFooter(props: NoteFooterProps) {
}
async function deleteEvent() {
if (window.confirm(`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`)) {
if (
window.confirm(
`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`
)
) {
let evDelete = await publisher.delete(ev.Id);
publisher.broadcast(evDelete);
}
@ -92,7 +125,10 @@ export default function NoteFooter(props: NoteFooterProps) {
async function repost() {
if (!hasReposted()) {
if (!prefs.confirmReposts || window.confirm(`Are you sure you want to repost: ${ev.Id}`)) {
if (
!prefs.confirmReposts ||
window.confirm(`Are you sure you want to repost: ${ev.Id}`)
) {
let evRepost = await publisher.repost(ev);
publisher.broadcast(evRepost);
}
@ -104,21 +140,31 @@ export default function NoteFooter(props: NoteFooterProps) {
if (service) {
return (
<>
<div className={`reaction-pill ${didZap ? 'reacted' : ''}`} onClick={() => setTip(true)}>
<div
className={`reaction-pill ${didZap ? "reacted" : ""}`}
onClick={() => setTip(true)}
>
<div className="reaction-pill-icon">
<Zap />
</div>
{zapTotal > 0 && (<div className="reaction-pill-number">{formatShort(zapTotal)}</div>)}
{zapTotal > 0 && (
<div className="reaction-pill-number">
{formatShort(zapTotal)}
</div>
)}
</div>
</>
)
);
}
return null;
}
function repostIcon() {
return (
<div className={`reaction-pill ${hasReposted() ? 'reacted' : ''}`} onClick={() => repost()}>
<div
className={`reaction-pill ${hasReposted() ? "reacted" : ""}`}
onClick={() => repost()}
>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faRepeat} />
</div>
@ -128,7 +174,7 @@ export default function NoteFooter(props: NoteFooterProps) {
</div>
)}
</div>
)
);
}
function reactionIcons() {
@ -137,7 +183,10 @@ export default function NoteFooter(props: NoteFooterProps) {
}
return (
<>
<div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={() => react("+")}>
<div
className={`reaction-pill ${hasReacted("+") ? "reacted" : ""} `}
onClick={() => react("+")}
>
<div className="reaction-pill-icon">
<Heart />
</div>
@ -147,15 +196,17 @@ export default function NoteFooter(props: NoteFooterProps) {
</div>
{repostIcon()}
</>
)
);
}
async function share() {
const url = `${window.location.protocol}//${window.location.host}/e/${hexToBech32("note", ev.Id)}`;
const url = `${window.location.protocol}//${
window.location.host
}/e/${hexToBech32("note", ev.Id)}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url
url: url,
});
} else {
await navigator.clipboard.writeText(url);
@ -170,7 +221,7 @@ export default function NoteFooter(props: NoteFooterProps) {
source: "auto",
target: lang.split("-")[0],
}),
headers: { "Content-Type": "application/json" }
headers: { "Content-Type": "application/json" },
});
if (res.ok) {
@ -179,7 +230,7 @@ export default function NoteFooter(props: NoteFooterProps) {
props.onTranslated({
text: result.translatedText,
fromLanguage: langNames.of(result.detectedLanguage.language),
confidence: result.detectedLanguage.confidence
confidence: result.detectedLanguage.confidence,
} as Translation);
}
}
@ -190,7 +241,9 @@ export default function NoteFooter(props: NoteFooterProps) {
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, ' '));
await navigator.clipboard.writeText(
JSON.stringify(ev.Original, undefined, " ")
);
}
function menuItems() {
@ -200,8 +253,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<MenuItem onClick={() => react("-")}>
<Dislike />
{formatShort(groupReactions[Reaction.Negative])}
&nbsp;
Dislike
&nbsp; Dislike
</MenuItem>
)}
<MenuItem onClick={() => share()}>
@ -237,49 +289,55 @@ export default function NoteFooter(props: NoteFooterProps) {
</MenuItem>
)}
</>
)
);
}
return (
<>
<div className="footer">
<div className="footer-reactions">
{tipButton()}
{reactionIcons()}
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
<div className="reaction-pill-icon">
<Reply />
<div className="footer">
<div className="footer-reactions">
{tipButton()}
{reactionIcons()}
<div
className={`reaction-pill ${reply ? "reacted" : ""}`}
onClick={(e) => setReply((s) => !s)}
>
<div className="reaction-pill-icon">
<Reply />
</div>
</div>
<Menu
menuButton={
<div className="reaction-pill">
<div className="reaction-pill-icon">
<Dots />
</div>
</div>
}
menuClassName="ctx-menu"
>
{menuItems()}
</Menu>
</div>
<Menu menuButton={<div className="reaction-pill">
<div className="reaction-pill-icon">
<Dots />
</div>
</div>}
menuClassName="ctx-menu"
>
{menuItems()}
</Menu>
<NoteCreator
autoFocus={true}
replyTo={ev}
onSend={() => setReply(false)}
show={reply}
setShow={setReply}
/>
<SendSats
svc={author?.lud16 || author?.lud06}
onClose={() => setTip(false)}
show={tip}
author={author?.pubkey}
target={author?.display_name || author?.name}
note={ev.Id}
/>
</div>
<div className="zaps-container">
<ZapsSummary zaps={zaps} />
</div>
<NoteCreator
autoFocus={true}
replyTo={ev}
onSend={() => setReply(false)}
show={reply}
setShow={setReply}
/>
<SendSats
svc={author?.lud16 || author?.lud06}
onClose={() => setTip(false)}
show={tip}
author={author?.pubkey}
target={author?.display_name || author?.name}
note={ev.Id}
/>
</div>
<div className="zaps-container">
<ZapsSummary zaps={zaps} />
</div>
</>
)
);
}

View File

@ -2,17 +2,14 @@ import "./Note.css";
import ProfileImage from "Element/ProfileImage";
export default function NoteGhost(props: any) {
const className = `note card ${props.className ? props.className : ''}`
return (
<div className={className}>
<div className="header">
<ProfileImage pubkey="" />
</div>
<div className="body">
{props.children}
</div>
<div className="footer">
</div>
</div>
);
const className = `note card ${props.className ? props.className : ""}`;
return (
<div className={className}>
<div className="header">
<ProfileImage pubkey="" />
</div>
<div className="body">{props.children}</div>
<div className="footer"></div>
</div>
);
}

View File

@ -2,22 +2,22 @@
}
.reaction > .note {
margin: 10px 0;
margin: 10px 0;
}
.reaction > .header {
display: flex;
flex-direction: row;
justify-content: space-between;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.reaction > .header .reply {
font-size: var(--font-size-small);
font-size: var(--font-size-small);
}
.reaction > .header > .info {
font-size: var(--font-size);
white-space: nowrap;
color: var(--font-secondary-color);
margin-right: 24px;
font-size: var(--font-size);
white-space: nowrap;
color: var(--font-secondary-color);
margin-right: 24px;
}

View File

@ -12,62 +12,72 @@ import { RawEvent, TaggedRawEvent } from "Nostr";
import useModeration from "Hooks/useModeration";
export interface NoteReactionProps {
data?: TaggedRawEvent,
["data-ev"]?: NEvent,
root?: TaggedRawEvent
data?: TaggedRawEvent;
["data-ev"]?: NEvent;
root?: TaggedRawEvent;
}
export default function NoteReaction(props: NoteReactionProps) {
const { ["data-ev"]: dataEv, data } = props;
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv])
const { isMuted } = useModeration();
const { ["data-ev"]: dataEv, data } = props;
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]);
const { isMuted } = useModeration();
const refEvent = useMemo(() => {
if (ev) {
let eTags = ev.Tags.filter(a => a.Key === "e");
if (eTags.length > 0) {
return eTags[0].Event;
}
}
return null;
}, [ev]);
if (ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost) {
return null;
const refEvent = useMemo(() => {
if (ev) {
let eTags = ev.Tags.filter((a) => a.Key === "e");
if (eTags.length > 0) {
return eTags[0].Event;
}
}
return null;
}, [ev]);
/**
* Some clients embed the reposted note in the content
*/
function extractRoot() {
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0 && ev.Content !== "#[0]") {
try {
let r: RawEvent = JSON.parse(ev.Content);
return r as TaggedRawEvent;
} catch (e) {
console.error("Could not load reposted content", e);
}
}
return props.root;
if (ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost) {
return null;
}
/**
* Some clients embed the reposted note in the content
*/
function extractRoot() {
if (
ev?.Kind === EventKind.Repost &&
ev.Content.length > 0 &&
ev.Content !== "#[0]"
) {
try {
let r: RawEvent = JSON.parse(ev.Content);
return r as TaggedRawEvent;
} catch (e) {
console.error("Could not load reposted content", e);
}
}
return props.root;
}
const root = extractRoot();
const isOpMuted = root && isMuted(root.pubkey)
const opt = {
showHeader: ev?.Kind === EventKind.Repost,
showFooter: false,
};
const root = extractRoot();
const isOpMuted = root && isMuted(root.pubkey);
const opt = {
showHeader: ev?.Kind === EventKind.Repost,
showFooter: false,
};
return isOpMuted ? null : (
<div className="reaction">
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} />
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
</div>
</div>
{root ? <Note data={root} options={opt} related={[]}/> : null}
{!root && refEvent ? <p><Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link></p> : null}
return isOpMuted ? null : (
<div className="reaction">
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} />
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
</div>
);
</div>
{root ? <Note data={root} options={opt} related={[]} /> : null}
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent)}>
#{hexToBech32("note", refEvent).substring(0, 12)}
</Link>
</p>
) : null}
</div>
);
}

View File

@ -5,48 +5,63 @@ const HourInMs = MinuteInMs * 60;
const DayInMs = HourInMs * 24;
export interface NoteTimeProps {
from: number,
fallback?: string
from: number;
fallback?: string;
}
export default function NoteTime(props: NoteTimeProps) {
const [time, setTime] = useState<string>();
const { from, fallback } = props;
const absoluteTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'long'}).format(from);
const isoDate = new Date(from).toISOString();
const [time, setTime] = useState<string>();
const { from, fallback } = props;
const absoluteTime = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from);
const isoDate = new Date(from).toISOString();
function calcTime() {
let fromDate = new Date(from);
let ago = (new Date().getTime()) - from;
let absAgo = Math.abs(ago);
if (absAgo > DayInMs) {
return fromDate.toLocaleDateString(undefined, { year: "2-digit", month: "short", day: "2-digit", weekday: "short" });
} else if (absAgo > HourInMs) {
return `${fromDate.getHours().toString().padStart(2, '0')}:${fromDate.getMinutes().toString().padStart(2, '0')}`;
} else if (absAgo < MinuteInMs) {
return fallback
} else {
let mins = Math.floor(absAgo / MinuteInMs);
if(ago < 0) {
return `in ${mins}m`;
}
return `${mins}m`;
}
function calcTime() {
let fromDate = new Date(from);
let ago = new Date().getTime() - from;
let absAgo = Math.abs(ago);
if (absAgo > DayInMs) {
return fromDate.toLocaleDateString(undefined, {
year: "2-digit",
month: "short",
day: "2-digit",
weekday: "short",
});
} else if (absAgo > HourInMs) {
return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate
.getMinutes()
.toString()
.padStart(2, "0")}`;
} else if (absAgo < MinuteInMs) {
return fallback;
} else {
let mins = Math.floor(absAgo / MinuteInMs);
if (ago < 0) {
return `in ${mins}m`;
}
return `${mins}m`;
}
}
useEffect(() => {
setTime(calcTime());
let t = setInterval(() => {
setTime(s => {
let newTime = calcTime();
if (newTime !== s) {
return newTime;
}
return s;
})
}, MinuteInMs);
return () => clearInterval(t);
}, [from]);
useEffect(() => {
setTime(calcTime());
let t = setInterval(() => {
setTime((s) => {
let newTime = calcTime();
if (newTime !== s) {
return newTime;
}
return s;
});
}, MinuteInMs);
return () => clearInterval(t);
}, [from]);
return <time dateTime={isoDate} title={absoluteTime}>{time}</time>
return (
<time dateTime={isoDate} title={absoluteTime}>
{time}
</time>
);
}

View File

@ -1,6 +1,6 @@
.nts {
display: flex;
align-items: center;
display: flex;
align-items: center;
}
.note-to-self {
@ -13,20 +13,20 @@
}
.nts .avatar {
border-width: 1px;
width: 40px;
height: 40px;
border-width: 1px;
width: 40px;
height: 40px;
}
.nts .avatar.clickable {
cursor: pointer;
}
.nts a {
text-decoration: none;
text-decoration: none;
}
.nts .name {
margin-top: -.2em;
margin-top: -0.2em;
display: flex;
flex-direction: column;
font-weight: bold;
@ -34,5 +34,5 @@
.nts .nip05 {
margin: 0;
margin-top: -.2em;
margin-top: -0.2em;
}

View File

@ -2,55 +2,63 @@ import "./NoteToSelf.css";
import { Link, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons"
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons";
import { useUserProfile } from "Feed/ProfileFeed";
import Nip05 from "Element/Nip05";
import { profileLink } from "Util";
export interface NoteToSelfProps {
pubkey: string,
clickable?: boolean
className?: string,
link?: string
};
function NoteLabel({pubkey, link}:NoteToSelfProps) {
const user = useUserProfile(pubkey);
return (
<div>
Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div>
)
pubkey: string;
clickable?: boolean;
className?: string;
link?: string;
}
export default function NoteToSelf({ pubkey, clickable, className, link }: NoteToSelfProps) {
const navigate = useNavigate();
function NoteLabel({ pubkey, link }: NoteToSelfProps) {
const user = useUserProfile(pubkey);
return (
<div>
Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div>
);
}
const clickLink = () => {
if(clickable) {
navigate(link ?? profileLink(pubkey))
}
export default function NoteToSelf({
pubkey,
clickable,
className,
link,
}: NoteToSelfProps) {
const navigate = useNavigate();
const clickLink = () => {
if (clickable) {
navigate(link ?? profileLink(pubkey));
}
};
return (
<div className={`nts${className ? ` ${className}` : ""}`}>
<div className="avatar-wrapper">
<div className={`avatar${clickable ? " clickable" : ""}`}>
<FontAwesomeIcon onClick={clickLink} className="note-to-self" icon={faBook} size="2xl" />
</div>
</div>
<div className="f-grow">
<div className="name">
{clickable && (
<Link to={link ?? profileLink(pubkey)}>
<NoteLabel pubkey={pubkey} />
</Link>
) || (
<NoteLabel pubkey={pubkey} />
)}
</div>
</div>
return (
<div className={`nts${className ? ` ${className}` : ""}`}>
<div className="avatar-wrapper">
<div className={`avatar${clickable ? " clickable" : ""}`}>
<FontAwesomeIcon
onClick={clickLink}
className="note-to-self"
icon={faBook}
size="2xl"
/>
</div>
)
</div>
<div className="f-grow">
<div className="name">
{(clickable && (
<Link to={link ?? profileLink(pubkey)}>
<NoteLabel pubkey={pubkey} />
</Link>
)) || <NoteLabel pubkey={pubkey} />}
</div>
</div>
</div>
);
}

View File

@ -10,9 +10,9 @@
}
.pfp .avatar {
width: 48px;
height: 48px;
cursor: pointer;
width: 48px;
height: 48px;
cursor: pointer;
}
.pfp a {

View File

@ -4,55 +4,69 @@ import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useUserProfile } from "Feed/ProfileFeed";
import { hexToBech32, profileLink } from "Util";
import Avatar from "Element/Avatar"
import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05";
import { HexKey } from "Nostr";
import { MetadataCache } from "State/Users";
export interface ProfileImageProps {
pubkey: HexKey,
subHeader?: JSX.Element,
showUsername?: boolean,
className?: string,
link?: string
};
pubkey: HexKey;
subHeader?: JSX.Element;
showUsername?: boolean;
className?: string;
link?: string;
}
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }: ProfileImageProps) {
const navigate = useNavigate();
const user = useUserProfile(pubkey);
export default function ProfileImage({
pubkey,
subHeader,
showUsername = true,
className,
link,
}: ProfileImageProps) {
const navigate = useNavigate();
const user = useUserProfile(pubkey);
const name = useMemo(() => {
return getDisplayName(user, pubkey);
}, [user, pubkey]);
const name = useMemo(() => {
return getDisplayName(user, pubkey);
}, [user, pubkey]);
return (
<div className={`pfp${className ? ` ${className}` : ""}`}>
<div className="avatar-wrapper">
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
</div>
{showUsername && (
<div className="profile-name f-grow">
<div className="username">
<Link className="display-name" key={pubkey} to={link ?? profileLink(pubkey)}>
{name}
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</Link>
</div>
<div className="subheader">
{subHeader}
</div>
</div>
)}
return (
<div className={`pfp${className ? ` ${className}` : ""}`}>
<div className="avatar-wrapper">
<Avatar
user={user}
onClick={() => navigate(link ?? profileLink(pubkey))}
/>
</div>
{showUsername && (
<div className="profile-name f-grow">
<div className="username">
<Link
className="display-name"
key={pubkey}
to={link ?? profileLink(pubkey)}
>
{name}
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</Link>
</div>
<div className="subheader">{subHeader}</div>
</div>
)
)}
</div>
);
}
export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) {
let name = hexToBech32("npub", pubkey).substring(0, 12);
if ((user?.display_name?.length ?? 0) > 0) {
name = user!.display_name!;
} else if ((user?.name?.length ?? 0) > 0) {
name = user!.name!;
}
return name;
export function getDisplayName(
user: MetadataCache | undefined,
pubkey: HexKey
) {
let name = hexToBech32("npub", pubkey).substring(0, 12);
if ((user?.display_name?.length ?? 0) > 0) {
name = user!.display_name!;
} else if ((user?.name?.length ?? 0) > 0) {
name = user!.name!;
}
return name;
}

View File

@ -1,15 +1,15 @@
.profile-preview {
display: flex;
align-items: center;
min-height: 40px;
display: flex;
align-items: center;
min-height: 40px;
}
.profile-preview .pfp {
flex-grow: 1;
min-width: 200px;
flex-grow: 1;
min-width: 200px;
}
.profile-preview .about {
font-size: small;
color: var(--gray-light);
}
font-size: small;
color: var(--gray-light);
}

View File

@ -8,35 +8,46 @@ import { HexKey } from "Nostr";
import { useInView } from "react-intersection-observer";
export interface ProfilePreviewProps {
pubkey: HexKey,
options?: {
about?: boolean
},
actions?: ReactNode,
className?: string
pubkey: HexKey;
options?: {
about?: boolean;
};
actions?: ReactNode;
className?: string;
}
export default function ProfilePreview(props: ProfilePreviewProps) {
const pubkey = props.pubkey;
const user = useUserProfile(pubkey);
const { ref, inView } = useInView({ triggerOnce: true });
const options = {
about: true,
...props.options
};
const pubkey = props.pubkey;
const user = useUserProfile(pubkey);
const { ref, inView } = useInView({ triggerOnce: true });
const options = {
about: true,
...props.options,
};
return (
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}>
{inView && <>
<ProfileImage pubkey={pubkey} subHeader=
{options.about ? <div className="f-ellipsis about">
{user?.about}
</div> : undefined} />
{props.actions ?? (
<div className="follow-button-container">
<FollowButton pubkey={pubkey} />
</div>
)}
</>}
</div>
)
return (
<div
className={`profile-preview${
props.className ? ` ${props.className}` : ""
}`}
ref={ref}
>
{inView && (
<>
<ProfileImage
pubkey={pubkey}
subHeader={
options.about ? (
<div className="f-ellipsis about">{user?.about}</div>
) : undefined
}
/>
{props.actions ?? (
<div className="follow-button-container">
<FollowButton pubkey={pubkey} />
</div>
)}
</>
)}
</div>
);
}

View File

@ -2,17 +2,17 @@ import useImgProxy from "Feed/ImgProxy";
import { useEffect, useState } from "react";
export const ProxyImg = (props: any) => {
const { src, size, ...rest } = props;
const [url, setUrl] = useState<string>();
const { proxy } = useImgProxy();
const { src, size, ...rest } = props;
const [url, setUrl] = useState<string>();
const { proxy } = useImgProxy();
useEffect(() => {
if (src) {
proxy(src, size)
.then(a => setUrl(a))
.catch(console.warn);
}
}, [src]);
useEffect(() => {
if (src) {
proxy(src, size)
.then((a) => setUrl(a))
.catch(console.warn);
}
}, [src]);
return <img src={url} {...rest} />
}
return <img src={url} {...rest} />;
};

View File

@ -2,51 +2,54 @@ import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef } from "react";
export interface QrCodeProps {
data?: string,
link?: string,
avatar?: string,
height?: number,
width?: number,
className?: string
data?: string;
link?: string;
avatar?: string;
height?: number;
width?: number;
className?: string;
}
export default function QrCode(props: QrCodeProps) {
const qrRef = useRef<HTMLDivElement>(null);
const qrRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
let qr = new QRCodeStyling({
width: props.width || 256,
height: props.height || 256,
data: props.data,
margin: 5,
type: 'canvas',
image: props.avatar,
dotsOptions: {
type: 'rounded'
},
cornersSquareOptions: {
type: 'extra-rounded'
},
imageOptions: {
crossOrigin: "anonymous"
}
});
qrRef.current.innerHTML = "";
qr.append(qrRef.current);
if (props.link) {
qrRef.current.onclick = function (e) {
let elm = document.createElement("a");
elm.href = props.link!;
elm.click();
}
}
} else if (qrRef.current) {
qrRef.current.innerHTML = "";
}
}, [props.data, props.link]);
useEffect(() => {
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
let qr = new QRCodeStyling({
width: props.width || 256,
height: props.height || 256,
data: props.data,
margin: 5,
type: "canvas",
image: props.avatar,
dotsOptions: {
type: "rounded",
},
cornersSquareOptions: {
type: "extra-rounded",
},
imageOptions: {
crossOrigin: "anonymous",
},
});
qrRef.current.innerHTML = "";
qr.append(qrRef.current);
if (props.link) {
qrRef.current.onclick = function (e) {
let elm = document.createElement("a");
elm.href = props.link!;
elm.click();
};
}
} else if (qrRef.current) {
qrRef.current.innerHTML = "";
}
}, [props.data, props.link]);
return (
<div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div>
);
}
return (
<div
className={`qr${props.className ? ` ${props.className}` : ""}`}
ref={qrRef}
></div>
);
}

View File

@ -1,25 +1,25 @@
.relay {
margin-top: 10px;
background-color: var(--gray-secondary);
border-radius: 5px;
text-align: start;
display: grid;
grid-template-columns: min-content auto;
overflow: hidden;
font-size: var(--font-size-small);
margin-top: 10px;
background-color: var(--gray-secondary);
border-radius: 5px;
text-align: start;
display: grid;
grid-template-columns: min-content auto;
overflow: hidden;
font-size: var(--font-size-small);
}
.relay > div {
padding: 5px;
padding: 5px;
}
.relay-extra {
padding: 5px;
margin: 0 5px;
background-color: var(--gray-tertiary);
border-radius: 0 0 5px 5px;
white-space: nowrap;
font-size: var(--font-size-small);
padding: 5px;
margin: 0 5px;
background-color: var(--gray-tertiary);
border-radius: 0 0 5px 5px;
white-space: nowrap;
font-size: var(--font-size-small);
}
.icon-btn {
@ -35,7 +35,7 @@
}
.checkmark {
margin-left: .5em;
margin-left: 0.5em;
padding: 2px 10px;
background-color: var(--gray);
border-radius: 10px;

View File

@ -1,6 +1,13 @@
import "./Relay.css"
import "./Relay.css";
import { faPlug, faSquareCheck, faSquareXmark, faWifi, faPlugCircleXmark, faGear } from "@fortawesome/free-solid-svg-icons";
import {
faPlug,
faSquareCheck,
faSquareXmark,
faWifi,
faPlugCircleXmark,
faGear,
} from "@fortawesome/free-solid-svg-icons";
import useRelayState from "Feed/RelayState";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo } from "react";
@ -11,65 +18,92 @@ import { RelaySettings } from "Nostr/Connection";
import { useNavigate } from "react-router-dom";
export interface RelayProps {
addr: string
addr: string;
}
export default function Relay(props: RelayProps) {
const dispatch = useDispatch();
const navigate = useNavigate();
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relaySettings = allRelaySettings[props.addr];
const state = useRelayState(props.addr);
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
const dispatch = useDispatch();
const navigate = useNavigate();
const allRelaySettings = useSelector<
RootState,
Record<string, RelaySettings>
>((s) => s.login.relays);
const relaySettings = allRelaySettings[props.addr];
const state = useRelayState(props.addr);
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
function configure(o: RelaySettings) {
dispatch(setRelays({
relays: {
...allRelaySettings,
[props.addr]: o
},
createdAt: Math.floor(new Date().getTime() / 1000)
}));
}
function configure(o: RelaySettings) {
dispatch(
setRelays({
relays: {
...allRelaySettings,
[props.addr]: o,
},
createdAt: Math.floor(new Date().getTime() / 1000),
})
);
}
let latency = Math.floor(state?.avgLatency ?? 0);
return (
<>
<div className={`relay w-max`}>
<div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}>
<FontAwesomeIcon icon={faPlug} />
</div>
<div className="f-grow f-col">
<div className="flex mb10">
<b className="f-2">{name}</b>
<div className="f-1">
Write
<span className="checkmark" onClick={() => configure({ write: !relaySettings.write, read: relaySettings.read })}>
<FontAwesomeIcon icon={relaySettings.write ? faSquareCheck : faSquareXmark} />
</span>
</div>
<div className="f-1">
Read
<span className="checkmark" onClick={() => configure({ write: relaySettings.write, read: !relaySettings.read })}>
<FontAwesomeIcon icon={relaySettings.read ? faSquareCheck : faSquareXmark} />
</span>
</div>
</div>
<div className="flex">
<div className="f-grow">
<FontAwesomeIcon icon={faWifi} /> {latency > 2000 ? `${(latency / 1000).toFixed(0)} secs` : `${latency.toLocaleString()} ms`}
&nbsp;
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
</div>
<div>
<span className="icon-btn" onClick={() => navigate(state!.id)}>
<FontAwesomeIcon icon={faGear} />
</span>
</div>
</div>
</div>
let latency = Math.floor(state?.avgLatency ?? 0);
return (
<>
<div className={`relay w-max`}>
<div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}>
<FontAwesomeIcon icon={faPlug} />
</div>
<div className="f-grow f-col">
<div className="flex mb10">
<b className="f-2">{name}</b>
<div className="f-1">
Write
<span
className="checkmark"
onClick={() =>
configure({
write: !relaySettings.write,
read: relaySettings.read,
})
}
>
<FontAwesomeIcon
icon={relaySettings.write ? faSquareCheck : faSquareXmark}
/>
</span>
</div>
</>
)
<div className="f-1">
Read
<span
className="checkmark"
onClick={() =>
configure({
write: relaySettings.write,
read: !relaySettings.read,
})
}
>
<FontAwesomeIcon
icon={relaySettings.read ? faSquareCheck : faSquareXmark}
/>
</span>
</div>
</div>
<div className="flex">
<div className="f-grow">
<FontAwesomeIcon icon={faWifi} />{" "}
{latency > 2000
? `${(latency / 1000).toFixed(0)} secs`
: `${latency.toLocaleString()} ms`}
&nbsp;
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
</div>
<div>
<span className="icon-btn" onClick={() => navigate(state!.id)}>
<FontAwesomeIcon icon={faGear} />
</span>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -10,7 +10,7 @@
.lnurl-tip {
padding: 24px 32px;
background-color: #1B1B1B;
background-color: #1b1b1b;
border-radius: 16px;
position: relative;
}
@ -28,7 +28,7 @@
.lnurl-tip h3 {
color: var(--font-secondary-color);
font-size: 11px;
letter-spacing: .11em;
letter-spacing: 0.11em;
font-weight: 600;
line-height: 13px;
text-transform: uppercase;
@ -62,9 +62,9 @@
}
.lnurl-tip .btn {
background-color: inherit;
width: 210px;
margin: 0 0 10px 0;
background-color: inherit;
width: 210px;
margin: 0 0 10px 0;
}
.lnurl-tip .btn:hover {
@ -86,7 +86,7 @@
.sat-amount {
text-align: center;
display: inline-block;
background-color: #2A2A2A;
background-color: #2a2a2a;
color: var(--font-color);
padding: 12px 16px;
border-radius: 100px;
@ -115,21 +115,21 @@
}
.lnurl-tip .invoice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.lnurl-tip .invoice .actions {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: center;
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: center;
}
.lnurl-tip .invoice .actions .copy-action {
margin: 10px auto;
margin: 10px auto;
}
.lnurl-tip .invoice .actions .wallet-action {

View File

@ -16,307 +16,318 @@ import useWebln from "Hooks/useWebln";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
interface LNURLService {
nostrPubkey?: HexKey
minSendable?: number,
maxSendable?: number,
metadata: string,
callback: string,
commentAllowed?: number
nostrPubkey?: HexKey;
minSendable?: number;
maxSendable?: number;
metadata: string;
callback: string;
commentAllowed?: number;
}
interface LNURLInvoice {
pr: string,
successAction?: LNURLSuccessAction
pr: string;
successAction?: LNURLSuccessAction;
}
interface LNURLSuccessAction {
description?: string,
url?: string
description?: string;
url?: string;
}
export interface LNURLTipProps {
onClose?: () => void,
svc?: string,
show?: boolean,
invoice?: string, // shortcut to invoice qr tab
title?: string,
notice?: string
target?: string
note?: HexKey
author?: HexKey
onClose?: () => void;
svc?: string;
show?: boolean;
invoice?: string; // shortcut to invoice qr tab
title?: string;
notice?: string;
target?: string;
note?: HexKey;
author?: HexKey;
}
export default function LNURLTip(props: LNURLTipProps) {
const onClose = props.onClose || (() => { });
const service = props.svc;
const show = props.show || false;
const { note, author, target } = props
const amounts = [500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
const emojis: Record<number, string> = {
1_000: "👍",
5_000: "💜",
10_000: "😍",
20_000: "🤩",
50_000: "🔥",
100_000: "🚀",
1_000_000: "🤯",
}
const [payService, setPayService] = useState<LNURLService>();
const [amount, setAmount] = useState<number>(500);
const [customAmount, setCustomAmount] = useState<number>();
const [invoice, setInvoice] = useState<LNURLInvoice>();
const [comment, setComment] = useState<string>();
const [error, setError] = useState<string>();
const [success, setSuccess] = useState<LNURLSuccessAction>();
const webln = useWebln(show);
const publisher = useEventPublisher();
const horizontalScroll = useHorizontalScroll();
const onClose = props.onClose || (() => {});
const service = props.svc;
const show = props.show || false;
const { note, author, target } = props;
const amounts = [
500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000,
];
const emojis: Record<number, string> = {
1_000: "👍",
5_000: "💜",
10_000: "😍",
20_000: "🤩",
50_000: "🔥",
100_000: "🚀",
1_000_000: "🤯",
};
const [payService, setPayService] = useState<LNURLService>();
const [amount, setAmount] = useState<number>(500);
const [customAmount, setCustomAmount] = useState<number>();
const [invoice, setInvoice] = useState<LNURLInvoice>();
const [comment, setComment] = useState<string>();
const [error, setError] = useState<string>();
const [success, setSuccess] = useState<LNURLSuccessAction>();
const webln = useWebln(show);
const publisher = useEventPublisher();
const horizontalScroll = useHorizontalScroll();
useEffect(() => {
if (show && !props.invoice) {
loadService()
.then(a => setPayService(a!))
.catch(() => setError("Failed to load LNURL service"));
useEffect(() => {
if (show && !props.invoice) {
loadService()
.then((a) => setPayService(a!))
.catch(() => setError("Failed to load LNURL service"));
} else {
setPayService(undefined);
setError(undefined);
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
setAmount(500);
setComment(undefined);
setSuccess(undefined);
}
}, [show, service]);
const serviceAmounts = useMemo(() => {
if (payService) {
let min = (payService.minSendable ?? 0) / 1000;
let max = (payService.maxSendable ?? 0) / 1000;
return amounts.filter((a) => a >= min && a <= max);
}
return [];
}, [payService]);
const metadata = useMemo(() => {
if (payService) {
let meta: string[][] = JSON.parse(payService.metadata);
let desc = meta.find((a) => a[0] === "text/plain");
let image = meta.find((a) => a[0] === "image/png;base64");
return {
description: desc ? desc[1] : null,
image: image ? image[1] : null,
};
}
return null;
}, [payService]);
const selectAmount = (a: number) => {
setError(undefined);
setInvoice(undefined);
setAmount(a);
};
async function fetchJson<T>(url: string) {
let rsp = await fetch(url);
if (rsp.ok) {
let data: T = await rsp.json();
console.log(data);
setError(undefined);
return data;
}
return null;
}
async function loadService(): Promise<LNURLService | null> {
if (service) {
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
if (isServiceUrl) {
let serviceUrl = bech32ToText(service);
return await fetchJson(serviceUrl);
} else {
let ns = service.split("@");
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
}
}
return null;
}
async function loadInvoice() {
if (!amount || !payService) return null;
let url = "";
const amountParam = `amount=${Math.floor(amount * 1000)}`;
const commentParam = comment
? `&comment=${encodeURIComponent(comment)}`
: "";
if (payService.nostrPubkey && author) {
const ev = await publisher.zap(author, note, comment);
const nostrParam =
ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`;
url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`;
} else {
url = `${payService.callback}?${amountParam}${commentParam}`;
}
try {
let rsp = await fetch(url);
if (rsp.ok) {
let data = await rsp.json();
console.log(data);
if (data.status === "ERROR") {
setError(data.reason);
} else {
setPayService(undefined);
setError(undefined);
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
setAmount(500);
setComment(undefined);
setSuccess(undefined);
setInvoice(data);
setError("");
payWebLNIfEnabled(data);
}
}, [show, service]);
const serviceAmounts = useMemo(() => {
if (payService) {
let min = (payService.minSendable ?? 0) / 1000;
let max = (payService.maxSendable ?? 0) / 1000;
return amounts.filter(a => a >= min && a <= max);
}
return [];
}, [payService]);
const metadata = useMemo(() => {
if (payService) {
let meta: string[][] = JSON.parse(payService.metadata);
let desc = meta.find(a => a[0] === "text/plain");
let image = meta.find(a => a[0] === "image/png;base64");
return {
description: desc ? desc[1] : null,
image: image ? image[1] : null
};
}
return null;
}, [payService]);
const selectAmount = (a: number) => {
setError(undefined);
setInvoice(undefined);
setAmount(a);
};
async function fetchJson<T>(url: string) {
let rsp = await fetch(url);
if (rsp.ok) {
let data: T = await rsp.json();
console.log(data);
setError(undefined);
return data;
}
return null;
} else {
setError("Failed to load invoice");
}
} catch (e) {
setError("Failed to load invoice");
}
}
async function loadService(): Promise<LNURLService | null> {
if (service) {
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
if (isServiceUrl) {
let serviceUrl = bech32ToText(service);
return await fetchJson(serviceUrl);
} else {
let ns = service.split("@");
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
}
}
return null;
}
async function loadInvoice() {
if (!amount || !payService) return null;
let url = ''
const amountParam = `amount=${Math.floor(amount * 1000)}`
const commentParam = comment ? `&comment=${encodeURIComponent(comment)}` : ""
if (payService.nostrPubkey && author) {
const ev = await publisher.zap(author, note, comment)
const nostrParam = ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`
url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`;
} else {
url = `${payService.callback}?${amountParam}${commentParam}`;
}
try {
let rsp = await fetch(url);
if (rsp.ok) {
let data = await rsp.json();
console.log(data);
if (data.status === "ERROR") {
setError(data.reason);
} else {
setInvoice(data);
setError("");
payWebLNIfEnabled(data);
}
} else {
setError("Failed to load invoice");
}
} catch (e) {
setError("Failed to load invoice");
}
};
function custom() {
let min = (payService?.minSendable ?? 1000) / 1000;
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
return (
<div className="custom-amount flex">
<input
type="number"
min={min}
max={max}
className="f-grow mr10"
placeholder="Custom"
value={customAmount}
onChange={(e) => setCustomAmount(parseInt(e.target.value))}
/>
<button
className="secondary"
type="button"
disabled={!Boolean(customAmount)}
onClick={() => selectAmount(customAmount!)}
>
Confirm
</button>
</div>
);
}
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
try {
if (webln?.enabled) {
let res = await webln.sendPayment(invoice!.pr);
console.log(res);
setSuccess(invoice!.successAction || {});
}
} catch (e: any) {
setError(e.toString());
console.warn(e);
}
}
function invoiceForm() {
if (invoice) return null;
return (
<>
<h3>Zap amount in sats</h3>
<div className="amounts" ref={horizontalScroll}>
{serviceAmounts.map(a =>
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
{emojis[a] && <>{emojis[a]}&nbsp;</> }
{formatShort(a)}
</span>
)}
</div>
{payService && custom()}
<div className="flex">
{(payService?.commentAllowed ?? 0) > 0 &&
<input
type="text"
placeholder="Comment"
className="f-grow"
maxLength={payService?.commentAllowed}
onChange={(e) => setComment(e.target.value)}
/>
}
</div>
{(amount ?? 0) > 0 && (
<button type="button" className="zap-action" onClick={() => loadInvoice()}>
<div className="zap-action-container">
<Zap /> Zap
{target && ` ${target} `}
{formatShort(amount)} sats
</div>
</button>
)}
</>
)
}
function payInvoice() {
if (success) return null;
const pr = invoice?.pr;
return (
<>
<div className="invoice">
{props.notice && <b className="error">{props.notice}</b>}
<QrCode data={pr} link={`lightning:${pr}`} />
<div className="actions">
{pr && (
<>
<div className="copy-action">
<Copy text={pr} maxSize={26} />
</div>
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${pr}`)}>
Open Wallet
</button>
</>
)}
</div>
</div>
</>
)
}
function successAction() {
if (!success) return null;
return (
<div className="success-action">
<p className="paid">
<Check className="success mr10" />
{success?.description ?? "Paid!"}
</p>
{success.url &&
<p>
<a
href={success.url}
rel="noreferrer"
target="_blank"
>
{success.url}
</a>
</p>
}
</div>
)
}
const defaultTitle = payService?.nostrPubkey ? "Send zap" : "Send sats";
const title = target ? `${defaultTitle} to ${target}` : defaultTitle
if (!show) return null;
function custom() {
let min = (payService?.minSendable ?? 1000) / 1000;
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
return (
<Modal className="lnurl-modal" onClose={onClose}>
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}>
<div className="close" onClick={onClose}>
<Close />
</div>
<div className="lnurl-header">
{author && <ProfileImage pubkey={author} showUsername={false} />}
<h2>
{props.title || title}
</h2>
</div>
{invoiceForm()}
{error && <p className="error">{error}</p>}
{payInvoice()}
{successAction()}
<div className="custom-amount flex">
<input
type="number"
min={min}
max={max}
className="f-grow mr10"
placeholder="Custom"
value={customAmount}
onChange={(e) => setCustomAmount(parseInt(e.target.value))}
/>
<button
className="secondary"
type="button"
disabled={!Boolean(customAmount)}
onClick={() => selectAmount(customAmount!)}
>
Confirm
</button>
</div>
);
}
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
try {
if (webln?.enabled) {
let res = await webln.sendPayment(invoice!.pr);
console.log(res);
setSuccess(invoice!.successAction || {});
}
} catch (e: any) {
setError(e.toString());
console.warn(e);
}
}
function invoiceForm() {
if (invoice) return null;
return (
<>
<h3>Zap amount in sats</h3>
<div className="amounts" ref={horizontalScroll}>
{serviceAmounts.map((a) => (
<span
className={`sat-amount ${amount === a ? "active" : ""}`}
key={a}
onClick={() => selectAmount(a)}
>
{emojis[a] && <>{emojis[a]}&nbsp;</>}
{formatShort(a)}
</span>
))}
</div>
{payService && custom()}
<div className="flex">
{(payService?.commentAllowed ?? 0) > 0 && (
<input
type="text"
placeholder="Comment"
className="f-grow"
maxLength={payService?.commentAllowed}
onChange={(e) => setComment(e.target.value)}
/>
)}
</div>
{(amount ?? 0) > 0 && (
<button
type="button"
className="zap-action"
onClick={() => loadInvoice()}
>
<div className="zap-action-container">
<Zap /> Zap
{target && ` ${target} `}
{formatShort(amount)} sats
</div>
</button>
)}
</>
);
}
function payInvoice() {
if (success) return null;
const pr = invoice?.pr;
return (
<>
<div className="invoice">
{props.notice && <b className="error">{props.notice}</b>}
<QrCode data={pr} link={`lightning:${pr}`} />
<div className="actions">
{pr && (
<>
<div className="copy-action">
<Copy text={pr} maxSize={26} />
</div>
<button
className="wallet-action"
type="button"
onClick={() => window.open(`lightning:${pr}`)}
>
Open Wallet
</button>
</>
)}
</div>
</Modal>
)
</div>
</>
);
}
function successAction() {
if (!success) return null;
return (
<div className="success-action">
<p className="paid">
<Check className="success mr10" />
{success?.description ?? "Paid!"}
</p>
{success.url && (
<p>
<a href={success.url} rel="noreferrer" target="_blank">
{success.url}
</a>
</p>
)}
</div>
);
}
const defaultTitle = payService?.nostrPubkey ? "Send zap" : "Send sats";
const title = target ? `${defaultTitle} to ${target}` : defaultTitle;
if (!show) return null;
return (
<Modal className="lnurl-modal" onClose={onClose}>
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}>
<div className="close" onClick={onClose}>
<Close />
</div>
<div className="lnurl-header">
{author && <ProfileImage pubkey={author} showUsername={false} />}
<h2>{props.title || title}</h2>
</div>
{invoiceForm()}
{error && <p className="error">{error}</p>}
{payInvoice()}
{successAction()}
</div>
</Modal>
);
}

View File

@ -1,20 +1,24 @@
import './ShowMore.css'
import "./ShowMore.css";
interface ShowMoreProps {
text?: string
className?: string
onClick: () => void
text?: string;
className?: string;
onClick: () => void;
}
const ShowMore = ({ text = "Show more", onClick, className = "" }: ShowMoreProps) => {
const classNames = className ? `show-more ${className}` : "show-more"
const ShowMore = ({
text = "Show more",
onClick,
className = "",
}: ShowMoreProps) => {
const classNames = className ? `show-more ${className}` : "show-more";
return (
<div className="show-more-container">
<button className={classNames} onClick={onClick}>
{text}
</button>
</div>
)
}
);
};
export default ShowMore
export default ShowMore;

View File

@ -1,48 +1,48 @@
.skeleton {
display: inline-block;
height: 1em;
position: relative;
overflow: hidden;
background-color: #dddbdd;
border-radius: 16px;
}
.skeleton::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
content: "";
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
@media screen and (prefers-color-scheme: dark) {
.skeleton {
background-color: #50535a;
}
.skeleton::after {
background-image: linear-gradient(
90deg,
#50535a 0%,
#656871 20%,
#50535a 40%,
#50535a 100%
);
}
}
.skeleton {
display: inline-block;
height: 1em;
position: relative;
overflow: hidden;
background-color: #dddbdd;
border-radius: 16px;
}
.skeleton::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
content: "";
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
@media screen and (prefers-color-scheme: dark) {
.skeleton {
background-color: #50535a;
}
.skeleton::after {
background-image: linear-gradient(
90deg,
#50535a 0%,
#656871 20%,
#50535a 40%,
#50535a 100%
);
}
}

View File

@ -1,30 +1,30 @@
import "./Skeleton.css";
interface ISkepetonProps {
children?: React.ReactNode;
loading?: boolean;
width?: string;
height?: string;
margin?: string;
}
export default function Skeleton({
children,
width,
height,
margin,
loading = true,
}: ISkepetonProps) {
if (!loading) {
return <>{children}</>;
}
return (
<div
className="skeleton"
style={{ width: width, height: height, margin: margin }}
>
{children}
</div>
);
}
import "./Skeleton.css";
interface ISkepetonProps {
children?: React.ReactNode;
loading?: boolean;
width?: string;
height?: string;
margin?: string;
}
export default function Skeleton({
children,
width,
height,
margin,
loading = true,
}: ISkepetonProps) {
if (!loading) {
return <>{children}</>;
}
return (
<div
className="skeleton"
style={{ width: width, height: height, margin: margin }}
>
{children}
</div>
);
}

View File

@ -1,14 +1,13 @@
const SoundCloudEmbed = ({link}: {link: string}) => {
return(
<iframe
width="100%"
height="166"
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}>
</iframe>
)
}
const SoundCloudEmbed = ({ link }: { link: string }) => {
return (
<iframe
width="100%"
height="166"
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}
></iframe>
);
};
export default SoundCloudEmbed;

View File

@ -4,11 +4,11 @@
flex-direction: row;
overflow-x: scroll;
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* Firefox */
scrollbar-width: none; /* Firefox */
margin-bottom: 18px;
}
.tabs::-webkit-scrollbar{
.tabs::-webkit-scrollbar {
display: none;
}
@ -31,7 +31,6 @@
color: var(--font-color);
}
.tabs>div {
.tabs > div {
cursor: pointer;
}

View File

@ -1,39 +1,47 @@
import './Tabs.css'
import "./Tabs.css";
export interface Tab {
text: string, value: number
text: string;
value: number;
}
interface TabsProps {
tabs: Tab[]
tab: Tab
setTab: (t: Tab) => void
tabs: Tab[];
tab: Tab;
setTab: (t: Tab) => void;
}
interface TabElementProps extends Omit<TabsProps, 'tabs'> {
t: Tab
interface TabElementProps extends Omit<TabsProps, "tabs"> {
t: Tab;
}
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
return (
<div className={`tab ${tab.value === t.value ? "active" : ""}`} onClick={() => setTab(t)}>
<div
className={`tab ${tab.value === t.value ? "active" : ""}`}
onClick={() => setTab(t)}
>
{t.text}
</div>
)
}
);
};
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
return (
<div className="tabs">
{tabs.map((t) => {
return (
<div key={t.value} className={`tab ${tab.value === t.value ? "active" : ""}`} onClick={() => setTab(t)}>
<div
key={t.value}
className={`tab ${tab.value === t.value ? "active" : ""}`}
onClick={() => setTab(t)}
>
{t.text}
</div>
)
);
})}
</div>
)
}
);
};
export default Tabs
export default Tabs;

View File

@ -4,70 +4,74 @@
}
.text a {
color: var(--highlight);
text-decoration: none;
color: var(--highlight);
text-decoration: none;
}
.text a:hover {
text-decoration: underline;
text-decoration: underline;
}
.text h1 {
margin: 0;
margin: 0;
}
.text h2 {
margin: 0;
margin: 0;
}
.text h3 {
margin: 0;
margin: 0;
}
.text h4 {
margin: 0;
margin: 0;
}
.text h5 {
margin: 0;
margin: 0;
}
.text h6 {
margin: 0;
margin: 0;
}
.text p {
margin: 0;
margin-bottom: 4px;
margin: 0;
margin-bottom: 4px;
}
.text p:last-child {
margin-bottom: 0;
margin-bottom: 0;
}
.text pre {
margin: 0;
margin: 0;
}
.text li {
margin-top: -1em;
margin-top: -1em;
}
.text li:last-child {
margin-bottom: -2em;
margin-bottom: -2em;
}
.text hr {
border: 0;
height: 1px;
background-image: var(--gray-gradient);
margin: 20px;
border: 0;
height: 1px;
background-image: var(--gray-gradient);
margin: 20px;
}
.text img, .text video, .text iframe, .text audio {
max-width: 100%;
max-height: 500px;
margin: 10px auto;
display: block;
border-radius: 12px;
.text img,
.text video,
.text iframe,
.text audio {
max-width: 100%;
max-height: 500px;
margin: 10px auto;
display: block;
border-radius: 12px;
}
.text iframe, .text video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
.text iframe,
.text video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
.text blockquote {

View File

@ -1,4 +1,4 @@
import './Text.css'
import "./Text.css";
import { useMemo, useCallback } from "react";
import { Link } from "react-router-dom";
import ReactMarkdown from "react-markdown";
@ -12,154 +12,182 @@ import Hashtag from "Element/Hashtag";
import Tag from "Nostr/Tag";
import { MetadataCache } from "State/Users";
import Mention from "Element/Mention";
import HyperText from 'Element/HyperText';
import { HexKey } from 'Nostr';
import HyperText from "Element/HyperText";
import { HexKey } from "Nostr";
export type Fragment = string | JSX.Element;
export interface TextFragment {
body: Fragment[],
tags: Tag[],
users: Map<string, MetadataCache>
body: Fragment[];
tags: Tag[];
users: Map<string, MetadataCache>;
}
export interface TextProps {
content: string,
creator: HexKey,
tags: Tag[],
users: Map<string, MetadataCache>
content: string;
creator: HexKey;
tags: Tag[];
users: Map<string, MetadataCache>;
}
export default function Text({ content, tags, creator, users }: TextProps) {
function extractLinks(fragments: Fragment[]) {
return fragments.map(f => {
if (typeof f === "string") {
return f.split(UrlRegex).map(a => {
if (a.startsWith("http")) {
return <HyperText link={a} creator={creator} />
}
return a;
});
function extractLinks(fragments: Fragment[]) {
return fragments
.map((f) => {
if (typeof f === "string") {
return f.split(UrlRegex).map((a) => {
if (a.startsWith("http")) {
return <HyperText link={a} creator={creator} />;
}
return f;
}).flat();
}
function extractMentions(frag: TextFragment) {
return frag.body.map(f => {
if (typeof f === "string") {
return f.split(MentionRegex).map((match) => {
let matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
let idx = parseInt(matchTag[1]);
let ref = frag.tags?.find(a => a.Index === idx);
if (ref) {
switch (ref.Key) {
case "p": {
return <Mention pubkey={ref.PubKey!} />
}
case "e": {
let eText = hexToBech32("note", ref.Event!).substring(0, 12);
return <Link key={ref.Event} to={eventLink(ref.Event!)} onClick={(e) => e.stopPropagation()}>#{eText}</Link>;
}
case "t": {
return <Hashtag tag={ref.Hashtag!} />
}
}
}
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
} else {
return match;
}
});
}
return f;
}).flat();
}
function extractInvoices(fragments: Fragment[]) {
return fragments.map(f => {
if (typeof f === "string") {
return f.split(InvoiceRegex).map(i => {
if (i.toLowerCase().startsWith("lnbc")) {
return <Invoice key={i} invoice={i} />
} else {
return i;
}
});
}
return f;
}).flat();
}
function extractHashtags(fragments: Fragment[]) {
return fragments.map(f => {
if (typeof f === "string") {
return f.split(HashtagRegex).map(i => {
if (i.toLowerCase().startsWith("#")) {
return <Hashtag tag={i.substring(1)} />
} else {
return i;
}
});
}
return f;
}).flat();
}
function transformLi(frag: TextFragment) {
let fragments = transformText(frag)
return <li>{fragments}</li>
}
function transformParagraph(frag: TextFragment) {
const fragments = transformText(frag)
if (fragments.every(f => typeof f === 'string')) {
return <p>{fragments}</p>
return a;
});
}
return <>{fragments}</>
}
return f;
})
.flat();
}
function transformText(frag: TextFragment) {
if (frag.body === undefined) {
debugger;
}
let fragments = extractMentions(frag);
fragments = extractLinks(fragments);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
return fragments;
}
const components = useMemo(() => {
return {
p: (x: any) => transformParagraph({ body: x.children ?? [], tags, users }),
a: (x: any) => <HyperText link={x.href} creator={creator} />,
li: (x: any) => transformLi({ body: x.children ?? [], tags, users }),
};
}, [content]);
const disableMarkdownLinks = useCallback(() => (tree: any) => {
visit(tree, (node, index, parent) => {
if (
parent &&
typeof index === 'number' &&
(node.type === 'link' ||
node.type === 'linkReference' ||
node.type === 'image' ||
node.type === 'imageReference' ||
node.type === 'definition')
) {
node.type = 'text';
node.value = content.slice(node.position.start.offset, node.position.end.offset).replace(/\)$/, ' )');
return SKIP;
function extractMentions(frag: TextFragment) {
return frag.body
.map((f) => {
if (typeof f === "string") {
return f.split(MentionRegex).map((match) => {
let matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
let idx = parseInt(matchTag[1]);
let ref = frag.tags?.find((a) => a.Index === idx);
if (ref) {
switch (ref.Key) {
case "p": {
return <Mention pubkey={ref.PubKey!} />;
}
case "e": {
let eText = hexToBech32("note", ref.Event!).substring(
0,
12
);
return (
<Link
key={ref.Event}
to={eventLink(ref.Event!)}
onClick={(e) => e.stopPropagation()}
>
#{eText}
</Link>
);
}
case "t": {
return <Hashtag tag={ref.Hashtag!} />;
}
}
}
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
} else {
return match;
}
})
}, [content]);
return <ReactMarkdown
className="text"
components={components}
remarkPlugins={[disableMarkdownLinks]}
>{content}</ReactMarkdown>
});
}
return f;
})
.flat();
}
function extractInvoices(fragments: Fragment[]) {
return fragments
.map((f) => {
if (typeof f === "string") {
return f.split(InvoiceRegex).map((i) => {
if (i.toLowerCase().startsWith("lnbc")) {
return <Invoice key={i} invoice={i} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractHashtags(fragments: Fragment[]) {
return fragments
.map((f) => {
if (typeof f === "string") {
return f.split(HashtagRegex).map((i) => {
if (i.toLowerCase().startsWith("#")) {
return <Hashtag tag={i.substring(1)} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function transformLi(frag: TextFragment) {
let fragments = transformText(frag);
return <li>{fragments}</li>;
}
function transformParagraph(frag: TextFragment) {
const fragments = transformText(frag);
if (fragments.every((f) => typeof f === "string")) {
return <p>{fragments}</p>;
}
return <>{fragments}</>;
}
function transformText(frag: TextFragment) {
if (frag.body === undefined) {
debugger;
}
let fragments = extractMentions(frag);
fragments = extractLinks(fragments);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
return fragments;
}
const components = useMemo(() => {
return {
p: (x: any) =>
transformParagraph({ body: x.children ?? [], tags, users }),
a: (x: any) => <HyperText link={x.href} creator={creator} />,
li: (x: any) => transformLi({ body: x.children ?? [], tags, users }),
};
}, [content]);
const disableMarkdownLinks = useCallback(
() => (tree: any) => {
visit(tree, (node, index, parent) => {
if (
parent &&
typeof index === "number" &&
(node.type === "link" ||
node.type === "linkReference" ||
node.type === "image" ||
node.type === "imageReference" ||
node.type === "definition")
) {
node.type = "text";
node.value = content
.slice(node.position.start.offset, node.position.end.offset)
.replace(/\)$/, " )");
return SKIP;
}
});
},
[content]
);
return (
<ReactMarkdown
className="text"
components={components}
remarkPlugins={[disableMarkdownLinks]}
>
{content}
</ReactMarkdown>
);
}

View File

@ -4,12 +4,14 @@
.rta__item:not(:last-child) {
border: none;
}
.rta__entity--selected .user-item, .rta__entity--selected .emoji-item {
.rta__entity--selected .user-item,
.rta__entity--selected .emoji-item {
text-decoration: none;
background: var(--gray-secondary);
}
.user-item, .emoji-item {
.user-item,
.emoji-item {
color: var(--font-color);
background: var(--note-bg);
display: flex;
@ -19,7 +21,8 @@
padding: 10px;
}
.user-item:hover, .emoji-item:hover {
.user-item:hover,
.emoji-item:hover {
background: var(--gray-tertiary);
}
@ -37,9 +40,9 @@
}
.user-picture .avatar {
border-width: 1px;
width: 40px;
height: 40px;
border-width: 1px;
width: 40px;
height: 40px;
}
.user-details {
@ -57,8 +60,8 @@
}
.emoji-item .emoji {
margin-right: .2em;
min-width: 20px;
margin-right: 0.2em;
min-width: 20px;
}
.emoji-item .emoji-name {

View File

@ -13,8 +13,8 @@ import { MetadataCache } from "State/Users";
import { useQuery } from "State/Users/Hooks";
interface EmojiItemProps {
name: string
char: string
name: string;
char: string;
}
const EmojiItem = ({ entity: { name, char } }: { entity: EmojiItemProps }) => {
@ -23,11 +23,11 @@ const EmojiItem = ({ entity: { name, char } }: { entity: EmojiItemProps }) => {
<div className="emoji">{char}</div>
<div className="emoji-name">{name}</div>
</div>
)
}
);
};
const UserItem = (metadata: MetadataCache) => {
const { pubkey, display_name, picture, nip05, ...rest } = metadata
const { pubkey, display_name, picture, nip05, ...rest } = metadata;
return (
<div key={pubkey} className="user-item">
<div className="user-picture">
@ -38,24 +38,24 @@ const UserItem = (metadata: MetadataCache) => {
<Nip05 nip05={nip05} pubkey={pubkey} />
</div>
</div>
)
}
);
};
const Textarea = ({ users, onChange, ...rest }: any) => {
const [query, setQuery] = useState('')
const [query, setQuery] = useState("");
const allUsers = useQuery(query)
const allUsers = useQuery(query);
const userDataProvider = (token: string) => {
setQuery(token)
return allUsers
}
setQuery(token);
return allUsers;
};
const emojiDataProvider = (token: string) => {
return emoji(token)
.slice(0, 5)
.map(({ name, char }) => ({ name, char }));
}
};
return (
<ReactTextareaAutocomplete
@ -68,17 +68,17 @@ const Textarea = ({ users, onChange, ...rest }: any) => {
":": {
dataProvider: emojiDataProvider,
component: EmojiItem,
output: (item: EmojiItemProps, trigger) => item.char
output: (item: EmojiItemProps, trigger) => item.char,
},
"@": {
afterWhitespace: true,
dataProvider: userDataProvider,
component: (props: any) => <UserItem {...props.entity} />,
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`
}
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`,
},
}}
/>
)
}
);
};
export default Textarea
export default Textarea;

View File

@ -63,7 +63,7 @@
}
.subthread-container.subthread-multi .line-container:before {
content: '';
content: "";
position: absolute;
left: 36px;
top: 48px;
@ -78,7 +78,7 @@
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: '';
content: "";
position: absolute;
left: 36px;
top: 48px;
@ -87,13 +87,14 @@
}
@media (min-width: 720px) {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
.subthread-container.subthread-mid:not(.subthread-last)
.line-container:after {
left: 48px;
}
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: '';
content: "";
position: absolute;
border-left: 1px solid var(--gray-superdark);
left: 36px;
@ -102,13 +103,14 @@
}
@media (min-width: 720px) {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
.subthread-container.subthread-mid:not(.subthread-last)
.line-container:after {
left: 48px;
}
}
.subthread-container.subthread-last .line-container:before {
content: '';
content: "";
position: absolute;
border-left: 1px solid var(--gray-superdark);
left: 36px;
@ -137,7 +139,8 @@
margin-left: 80px;
}
.thread-container .collapsed, .thread-container .show-more-container {
.thread-container .collapsed,
.thread-container .show-more-container {
background: var(--note-bg);
min-height: 48px;
}
@ -147,7 +150,7 @@
border-bottom-right-radius: 16px;
}
.thread-container .collapsed {
.thread-container .collapsed {
background-color: var(--note-bg);
}

View File

@ -13,60 +13,75 @@ import NoteGhost from "Element/NoteGhost";
import Collapsed from "Element/Collapsed";
import type { RootState } from "State/Store";
function getParent(ev: HexKey, chains: Map<HexKey, NEvent[]>): HexKey | undefined {
function getParent(
ev: HexKey,
chains: Map<HexKey, NEvent[]>
): HexKey | undefined {
for (let [k, vs] of chains.entries()) {
const fs = vs.map(a => a.Id)
const fs = vs.map((a) => a.Id);
if (fs.includes(ev)) {
return k
return k;
}
}
}
interface DividerProps {
variant?: "regular" | "small"
variant?: "regular" | "small";
}
const Divider = ({ variant = "regular" }: DividerProps) => {
const className = variant === "small" ? "divider divider-small" : "divider"
const className = variant === "small" ? "divider divider-small" : "divider";
return (
<div className="divider-container">
<div className={className}>
</div>
<div className={className}></div>
</div>
)
}
);
};
interface SubthreadProps {
isLastSubthread?: boolean
from: u256
active: u256
path: u256[]
notes: NEvent[]
related: TaggedRawEvent[]
chains: Map<u256, NEvent[]>
onNavigate: (e: u256) => void
isLastSubthread?: boolean;
from: u256;
active: u256;
path: u256[];
notes: NEvent[];
related: TaggedRawEvent[];
chains: Map<u256, NEvent[]>;
onNavigate: (e: u256) => void;
}
const Subthread = ({ active, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const Subthread = ({
active,
path,
from,
notes,
related,
chains,
onNavigate,
}: SubthreadProps) => {
const renderSubthread = (a: NEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1
const replies = getReplies(a.Id, chains)
return (
<>
<div className={`subthread-container ${replies.length > 0 ? 'subthread-multi' : ''}`}>
<Divider />
<Note
highlight={active === a.Id}
className={`thread-note ${isLastSubthread && replies.length === 0 ? 'is-last-note' : ''}`}
data-ev={a}
key={a.Id}
related={related}
/>
<div className="line-container">
</div>
</div>
{replies.length > 0 && (
<TierTwo
const isLastSubthread = idx === notes.length - 1;
const replies = getReplies(a.Id, chains);
return (
<>
<div
className={`subthread-container ${
replies.length > 0 ? "subthread-multi" : ""
}`}
>
<Divider />
<Note
highlight={active === a.Id}
className={`thread-note ${
isLastSubthread && replies.length === 0 ? "is-last-note" : ""
}`}
data-ev={a}
key={a.Id}
related={related}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierTwo
active={active}
isLastSubthread={isLastSubthread}
path={path}
@ -75,78 +90,97 @@ const Subthread = ({ active, path, from, notes, related, chains, onNavigate }: S
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
</>
)
}
/>
)}
</>
);
};
return (
<div className="subthread">
{notes.map(renderSubthread)}
</div>
)
return <div className="subthread">{notes.map(renderSubthread)}</div>;
};
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: NEvent;
isLast: boolean;
}
interface ThreadNoteProps extends Omit<SubthreadProps, 'notes'> {
note: NEvent
isLast: boolean
}
const ThreadNote = ({ active, note, isLast, path, isLastSubthread, from, related, chains, onNavigate }: ThreadNoteProps) => {
const replies = getReplies(note.Id, chains)
const activeInReplies = replies.map(r => r.Id).includes(active)
const [collapsed, setCollapsed] = useState(!activeInReplies)
const hasMultipleNotes = replies.length > 0
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes
const className = `subthread-container ${isLast && collapsed ? 'subthread-last' : 'subthread-multi subthread-mid'}`
const ThreadNote = ({
active,
note,
isLast,
path,
isLastSubthread,
from,
related,
chains,
onNavigate,
}: ThreadNoteProps) => {
const replies = getReplies(note.Id, chains);
const activeInReplies = replies.map((r) => r.Id).includes(active);
const [collapsed, setCollapsed] = useState(!activeInReplies);
const hasMultipleNotes = replies.length > 0;
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
const className = `subthread-container ${
isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid"
}`;
return (
<>
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.Id}
className={`thread-note ${isLastVisibleNote ? 'is-last-note' : ''}`}
className={`thread-note ${isLastVisibleNote ? "is-last-note" : ""}`}
data-ev={note}
key={note.Id}
related={related}
/>
<div className="line-container">
</div>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
activeInReplies ? (
{replies.length > 0 &&
(activeInReplies ? (
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
) : (
<Collapsed text="Show replies" collapsed={collapsed} setCollapsed={setCollapsed}>
<Collapsed
text="Show replies"
collapsed={collapsed}
setCollapsed={setCollapsed}
>
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
</Collapsed>
)
)}
))}
</>
)
}
);
};
const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes
const TierTwo = ({
active,
isLastSubthread,
path,
from,
notes,
related,
chains,
onNavigate,
}: SubthreadProps) => {
const [first, ...rest] = notes;
return (
<>
@ -163,9 +197,9 @@ const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains,
/>
{rest.map((r: NEvent, idx: number) => {
const lastReply = idx === rest.length - 1
const lastReply = idx === rest.length - 1;
return (
<ThreadNote
<ThreadNote
active={active}
path={path}
from={from}
@ -176,218 +210,270 @@ const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains,
isLastSubthread={isLastSubthread}
isLast={lastReply}
/>
)
})
}
);
})}
</>
)
}
);
};
const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes
const replies = getReplies(first.Id, chains)
const activeInReplies = notes.map(r => r.Id).includes(active) || replies.map(r => r.Id).includes(active)
const hasMultipleNotes = rest.length > 0 || replies.length > 0
const isLast = replies.length === 0 && rest.length === 0
const TierThree = ({
active,
path,
isLastSubthread,
from,
notes,
related,
chains,
onNavigate,
}: SubthreadProps) => {
const [first, ...rest] = notes;
const replies = getReplies(first.Id, chains);
const activeInReplies =
notes.map((r) => r.Id).includes(active) ||
replies.map((r) => r.Id).includes(active);
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
const isLast = replies.length === 0 && rest.length === 0;
return (
<>
<div className={`subthread-container ${hasMultipleNotes ? 'subthread-multi' : ''} ${isLast ? 'subthread-last' : 'subthread-mid'}`}>
<div
className={`subthread-container ${
hasMultipleNotes ? "subthread-multi" : ""
} ${isLast ? "subthread-last" : "subthread-mid"}`}
>
<Divider variant="small" />
<Note
highlight={active === first.Id}
className={`thread-note ${isLastSubthread && isLast ? 'is-last-note' : ''}`}
className={`thread-note ${
isLastSubthread && isLast ? "is-last-note" : ""
}`}
data-ev={first}
key={first.Id}
related={related}
/>
<div className="line-container">
</div>
<div className="line-container"></div>
</div>
{path.length <= 1 || !activeInReplies ? (
replies.length > 0 && (
<div className="show-more-container">
<button className="show-more" type="button" onClick={() => onNavigate(from)}>
Show replies
</button>
</div>
)
) : (
replies.length > 0 && (
<TierThree
active={active}
path={path.slice(1)}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)
)}
{path.length <= 1 || !activeInReplies
? replies.length > 0 && (
<div className="show-more-container">
<button
className="show-more"
type="button"
onClick={() => onNavigate(from)}
>
Show replies
</button>
</div>
)
: replies.length > 0 && (
<TierThree
active={active}
path={path.slice(1)}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
{rest.map((r: NEvent, idx: number) => {
const lastReply = idx === rest.length - 1
const lastNote = isLastSubthread && lastReply
const lastReply = idx === rest.length - 1;
const lastNote = isLastSubthread && lastReply;
return (
<div key={r.Id} className={`subthread-container ${lastReply ? '' : 'subthread-multi'} ${lastReply ? 'subthread-last' : 'subthread-mid'}`}>
<div
key={r.Id}
className={`subthread-container ${
lastReply ? "" : "subthread-multi"
} ${lastReply ? "subthread-last" : "subthread-mid"}`}
>
<Divider variant="small" />
<Note
className={`thread-note ${lastNote ? 'is-last-note' : ''}`}
className={`thread-note ${lastNote ? "is-last-note" : ""}`}
highlight={active === r.Id}
data-ev={r}
key={r.Id}
related={related}
/>
<div className="line-container">
</div>
<div className="line-container"></div>
</div>
)
})
}
);
})}
</>
)
}
);
};
export interface ThreadProps {
this?: u256,
notes?: TaggedRawEvent[]
this?: u256;
notes?: TaggedRawEvent[];
}
export default function Thread(props: ThreadProps) {
const notes = props.notes ?? [];
const parsedNotes = notes.map(a => new NEvent(a));
// root note has no thread info
const root = useMemo(() => parsedNotes.find(a => a.Thread === null), [notes]);
const [path, setPath] = useState<HexKey[]>([])
const currentId = path.length > 0 && path[path.length - 1]
const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === currentId), [notes, currentId]);
const [navigated, setNavigated] = useState(false)
const navigate = useNavigate()
const isSingleNote = parsedNotes.filter(a => a.Kind === EventKind.TextNote).length === 1
const location = useLocation()
const urlNoteId = location?.pathname.slice(3)
const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId)
const rootNoteId = root && hexToBech32('note', root.Id)
const notes = props.notes ?? [];
const parsedNotes = notes.map((a) => new NEvent(a));
// root note has no thread info
const root = useMemo(
() => parsedNotes.find((a) => a.Thread === null),
[notes]
);
const [path, setPath] = useState<HexKey[]>([]);
const currentId = path.length > 0 && path[path.length - 1];
const currentRoot = useMemo(
() => parsedNotes.find((a) => a.Id === currentId),
[notes, currentId]
);
const [navigated, setNavigated] = useState(false);
const navigate = useNavigate();
const isSingleNote =
parsedNotes.filter((a) => a.Kind === EventKind.TextNote).length === 1;
const location = useLocation();
const urlNoteId = location?.pathname.slice(3);
const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId);
const rootNoteId = root && hexToBech32("note", root.Id);
const chains = useMemo(() => {
let chains = new Map<u256, NEvent[]>();
parsedNotes?.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
chains.get(replyTo)!.push(v);
}
} else if (v.Tags.length > 0) {
console.log("Not replying to anything: ", v);
}
});
return chains;
}, [notes]);
useEffect(() => {
if (!root) {
return
}
if (navigated) {
return
}
if (root.Id === urlNoteHex) {
setPath([root.Id])
setNavigated(true)
return
}
let subthreadPath = []
let parent = getParent(urlNoteHex, chains)
while (parent) {
subthreadPath.unshift(parent)
parent = getParent(parent, chains)
}
setPath(subthreadPath)
setNavigated(true)
}, [root, navigated, urlNoteHex, chains])
const brokenChains = useMemo(() => {
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
}, [chains]);
function renderRoot(note: NEvent) {
const className = `thread-root ${isSingleNote ? 'thread-root-single' : ''}`
if (note) {
return <Note className={className} key={note.Id} data-ev={note} related={notes} />
} else {
return (
<NoteGhost className={className}>
Loading thread root.. ({notes?.length} notes loaded)
</NoteGhost>
)
const chains = useMemo(() => {
let chains = new Map<u256, NEvent[]>();
parsedNotes
?.filter((a) => a.Kind === EventKind.TextNote)
.sort((a, b) => b.CreatedAt - a.CreatedAt)
.forEach((v) => {
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
chains.get(replyTo)!.push(v);
}
} else if (v.Tags.length > 0) {
console.log("Not replying to anything: ", v);
}
});
return chains;
}, [notes]);
useEffect(() => {
if (!root) {
return;
}
function onNavigate(to: u256) {
setPath([...path, to])
if (navigated) {
return;
}
function renderChain(from: u256): ReactNode {
if (!from || !chains) {
return
}
let replies = chains.get(from);
if (replies) {
return <Subthread active={urlNoteHex} path={path} from={from} notes={replies} related={notes} chains={chains} onNavigate={onNavigate} />
}
if (root.Id === urlNoteHex) {
setPath([root.Id]);
setNavigated(true);
return;
}
function goBack() {
if (path.length > 1) {
const newPath = path.slice(0, path.length - 1)
setPath(newPath)
} else {
navigate("/")
}
let subthreadPath = [];
let parent = getParent(urlNoteHex, chains);
while (parent) {
subthreadPath.unshift(parent);
parent = getParent(parent, chains);
}
setPath(subthreadPath);
setNavigated(true);
}, [root, navigated, urlNoteHex, chains]);
return (
<div className="main-content mt10">
<BackButton onClick={goBack} text={path?.length > 1 ? "Parent" : "Back"} />
<div className="thread-container">
{currentRoot && renderRoot(currentRoot)}
{currentRoot && renderChain(currentRoot.Id)}
{currentRoot === root && (
<>
{brokenChains.length > 0 && <h3>Other replies</h3>}
{brokenChains.map(a => {
return (
<div className="mb10">
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
</NoteGhost>
{renderChain(a)}
</div>
)
})}
</>
)}
</div>
</div>
const brokenChains = useMemo(() => {
return Array.from(chains?.keys()).filter(
(a) => !parsedNotes?.some((b) => b.Id === a)
);
}, [chains]);
function renderRoot(note: NEvent) {
const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`;
if (note) {
return (
<Note
className={className}
key={note.Id}
data-ev={note}
related={notes}
/>
);
} else {
return (
<NoteGhost className={className}>
Loading thread root.. ({notes?.length} notes loaded)
</NoteGhost>
);
}
}
function onNavigate(to: u256) {
setPath([...path, to]);
}
function renderChain(from: u256): ReactNode {
if (!from || !chains) {
return;
}
let replies = chains.get(from);
if (replies) {
return (
<Subthread
active={urlNoteHex}
path={path}
from={from}
notes={replies}
related={notes}
chains={chains}
onNavigate={onNavigate}
/>
);
}
}
function goBack() {
if (path.length > 1) {
const newPath = path.slice(0, path.length - 1);
setPath(newPath);
} else {
navigate("/");
}
}
return (
<div className="main-content mt10">
<BackButton
onClick={goBack}
text={path?.length > 1 ? "Parent" : "Back"}
/>
<div className="thread-container">
{currentRoot && renderRoot(currentRoot)}
{currentRoot && renderChain(currentRoot.Id)}
{currentRoot === root && (
<>
{brokenChains.length > 0 && <h3>Other replies</h3>}
{brokenChains.map((a) => {
return (
<div className="mb10">
<NoteGhost
className={`thread-note thread-root ghost-root`}
key={a}
>
Missing event{" "}
<Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
</NoteGhost>
{renderChain(a)}
</div>
);
})}
</>
)}
</div>
</div>
);
}
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
if (!from || !chains) {
return []
}
let replies = chains.get(from);
return replies ? replies : []
if (!from || !chains) {
return [];
}
let replies = chains.get(from);
return replies ? replies : [];
}

View File

@ -4,47 +4,70 @@ import { TidalRegex } from "Const";
// Re-use dom parser across instances of TidalEmbed
const domParser = new DOMParser();
async function oembedLookup (link: string) {
// Regex + re-construct to handle both tidal.com/type/id and tidal.com/browse/type/id links.
const regexResult = TidalRegex.exec(link);
async function oembedLookup(link: string) {
// Regex + re-construct to handle both tidal.com/type/id and tidal.com/browse/type/id links.
const regexResult = TidalRegex.exec(link);
if (!regexResult) {
return Promise.reject('Not a TIDAL link.');
}
if (!regexResult) {
return Promise.reject("Not a TIDAL link.");
}
const [, productType, productId] = regexResult;
const oembedApi = `https://oembed.tidal.com/?url=https://tidal.com/browse/${productType}/${productId}`;
const [, productType, productId] = regexResult;
const oembedApi = `https://oembed.tidal.com/?url=https://tidal.com/browse/${productType}/${productId}`;
const apiResponse = await fetch(oembedApi);
const json = await apiResponse.json();
const apiResponse = await fetch(oembedApi);
const json = await apiResponse.json();
const doc = domParser.parseFromString(json.html, 'text/html');
const iframe = doc.querySelector('iframe');
const doc = domParser.parseFromString(json.html, "text/html");
const iframe = doc.querySelector("iframe");
if (!iframe) {
return Promise.reject('No iframe delivered.');
}
if (!iframe) {
return Promise.reject("No iframe delivered.");
}
return {
source: iframe.getAttribute('src'),
height: json.height
};
return {
source: iframe.getAttribute("src"),
height: json.height,
};
}
const TidalEmbed = ({ link }: { link: string }) => {
const [source, setSource] = useState<string>();
const [height, setHeight] = useState<number>();
const extraStyles = link.includes('video') ? { aspectRatio: "16 / 9" } : { height };
const [source, setSource] = useState<string>();
const [height, setHeight] = useState<number>();
const extraStyles = link.includes("video")
? { aspectRatio: "16 / 9" }
: { height };
useEffect(() => {
oembedLookup(link).then(data => {
setSource(data.source || undefined);
setHeight(data.height);
}).catch(console.error);
}, [link]);
useEffect(() => {
oembedLookup(link)
.then((data) => {
setSource(data.source || undefined);
setHeight(data.height);
})
.catch(console.error);
}, [link]);
if (!source) return <a href={link} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} className="ext">{link}</a>;
return <iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} />;
}
if (!source)
return (
<a
href={link}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="ext"
>
{link}
</a>
);
return (
<iframe
src={source}
style={extraStyles}
width="100%"
title="TIDAL Embed"
frameBorder={0}
/>
);
};
export default TidalEmbed;

View File

@ -1,5 +1,5 @@
.latest-notes {
cursor: pointer;
font-weight: bold;
user-select: none;
cursor: pointer;
font-weight: bold;
user-select: none;
}

View File

@ -15,68 +15,97 @@ import ProfilePreview from "./ProfilePreview";
import Skeleton from "Element/Skeleton";
export interface TimelineProps {
postsOnly: boolean,
subject: TimelineSubject,
method: "TIME_RANGE" | "LIMIT_UNTIL"
ignoreModeration?: boolean,
window?: number
postsOnly: boolean;
subject: TimelineSubject;
method: "TIME_RANGE" | "LIMIT_UNTIL";
ignoreModeration?: boolean;
window?: number;
}
/**
* A list of notes by pubkeys
*/
export default function Timeline({ subject, postsOnly = false, method, ignoreModeration = false, window }: TimelineProps) {
const { muted, isMuted } = useModeration();
const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, {
method,
window: window
export default function Timeline({
subject,
postsOnly = false,
method,
ignoreModeration = false,
window,
}: TimelineProps) {
const { muted, isMuted } = useModeration();
const { main, related, latest, parent, loadMore, showLatest } =
useTimelineFeed(subject, {
method,
window: window,
});
const filterPosts = useCallback((nts: TaggedRawEvent[]) => {
return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => ignoreModeration || !isMuted(a.pubkey));
}, [postsOnly, muted]);
const filterPosts = useCallback(
(nts: TaggedRawEvent[]) => {
return [...nts]
.sort((a, b) => b.created_at - a.created_at)
?.filter((a) => (postsOnly ? !a.tags.some((b) => b[0] === "e") : true))
.filter((a) => ignoreModeration || !isMuted(a.pubkey));
},
[postsOnly, muted]
);
const mainFeed = useMemo(() => {
return filterPosts(main.notes);
}, [main, filterPosts]);
const mainFeed = useMemo(() => {
return filterPosts(main.notes);
}, [main, filterPosts]);
const latestFeed = useMemo(() => {
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id))
}, [latest, mainFeed, filterPosts]);
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.SetMetadata: {
return <ProfilePreview pubkey={e.pubkey} className="card" />
}
case EventKind.TextNote: {
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
}
case EventKind.ZapReceipt: {
const zap = parseZap(e)
return zap.e ? null : <Zap zap={zap} key={e.id} />
}
case EventKind.Reaction:
case EventKind.Repost: {
let eRef = e.tags.find(a => a[0] === "e")?.at(1);
return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)} />
}
}
}
return (
<div className="main-content">
{latestFeed.length > 1 && (<div className="card latest-notes pointer" onClick={() => showLatest()}>
<FontAwesomeIcon icon={faForward} size="xl" />
&nbsp;
Show latest {latestFeed.length - 1} notes
</div>)}
{mainFeed.map(eventElement)}
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}>
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
</LoadMore>
</div>
const latestFeed = useMemo(() => {
return filterPosts(latest.notes).filter(
(a) => !mainFeed.some((b) => b.id === a.id)
);
}, [latest, mainFeed, filterPosts]);
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.SetMetadata: {
return <ProfilePreview pubkey={e.pubkey} className="card" />;
}
case EventKind.TextNote: {
return (
<Note
key={e.id}
data={e}
related={related.notes}
ignoreModeration={ignoreModeration}
/>
);
}
case EventKind.ZapReceipt: {
const zap = parseZap(e);
return zap.e ? null : <Zap zap={zap} key={e.id} />;
}
case EventKind.Reaction:
case EventKind.Repost: {
let eRef = e.tags.find((a) => a[0] === "e")?.at(1);
return (
<NoteReaction
data={e}
key={e.id}
root={parent.notes.find((a) => a.id === eRef)}
/>
);
}
}
}
return (
<div className="main-content">
{latestFeed.length > 1 && (
<div className="card latest-notes pointer" onClick={() => showLatest()}>
<FontAwesomeIcon icon={faForward} size="xl" />
&nbsp; Show latest {latestFeed.length - 1} notes
</div>
)}
{mainFeed.map(eventElement)}
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}>
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
</LoadMore>
</div>
);
}

View File

@ -11,7 +11,7 @@
.pill.unread {
background-color: var(--gray);
color: var(--font-color);
color: var(--font-color);
}
.pill:hover {

View File

@ -1,11 +1,7 @@
import "./UnreadCount.css"
import "./UnreadCount.css";
const UnreadCount = ({ unread }: { unread: number }) => {
return (
<span className={`pill ${unread > 0 ? 'unread' : ''}`}>
{unread}
</span>
)
}
return <span className={`pill ${unread > 0 ? "unread" : ""}`}>{unread}</span>;
};
export default UnreadCount
export default UnreadCount;

View File

@ -41,7 +41,7 @@
}
.top-zap .amount:before {
content: '';
content: "";
}
.top-zap .summary {
@ -66,7 +66,7 @@
}
.top-zap .pfp {
margin-right: .3em;
margin-right: 0.3em;
}
.top-zap .avatar {

View File

@ -16,28 +16,32 @@ import { RootState } from "State/Store";
function findTag(e: TaggedRawEvent, tag: string) {
const maybeTag = e.tags.find((evTag) => {
return evTag[0] === tag
})
return maybeTag && maybeTag[1]
return evTag[0] === tag;
});
return maybeTag && maybeTag[1];
}
function getInvoice(zap: TaggedRawEvent) {
const bolt11 = findTag(zap, 'bolt11')
const decoded = invoiceDecode(bolt11)
const bolt11 = findTag(zap, "bolt11");
const decoded = invoiceDecode(bolt11);
const amount = decoded.sections.find((section: any) => section.name === 'amount')?.value
const hash = decoded.sections.find((section: any) => section.name === 'description_hash')?.value;
const amount = decoded.sections.find(
(section: any) => section.name === "amount"
)?.value;
const hash = decoded.sections.find(
(section: any) => section.name === "description_hash"
)?.value;
return { amount, hash: hash ? bytesToHex(hash) : undefined };
}
interface Zapper {
pubkey?: HexKey,
isValid: boolean
pubkey?: HexKey;
isValid: boolean;
}
function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
const zapRequest = findTag(zap, 'description')
const zapRequest = findTag(zap, "description");
if (zapRequest) {
const rawEvent: TaggedRawEvent = JSON.parse(zapRequest);
if (Array.isArray(rawEvent)) {
@ -45,27 +49,27 @@ function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
return { isValid: false };
}
const metaHash = sha256(zapRequest);
const ev = new Event(rawEvent)
const ev = new Event(rawEvent);
return { pubkey: ev.PubKey, isValid: dhash === metaHash };
}
return { isValid: false }
return { isValid: false };
}
interface ParsedZap {
id: HexKey
e?: HexKey
p: HexKey
amount: number
content: string
zapper?: HexKey
valid: boolean
id: HexKey;
e?: HexKey;
p: HexKey;
amount: number;
content: string;
zapper?: HexKey;
valid: boolean;
}
export function parseZap(zap: TaggedRawEvent): ParsedZap {
const { amount, hash } = getInvoice(zap)
const { amount, hash } = getInvoice(zap);
const zapper = hash ? getZapper(zap, hash) : { isValid: false };
const e = findTag(zap, 'e')
const p = findTag(zap, 'p')!
const e = findTag(zap, "e");
const p = findTag(zap, "p")!;
return {
id: zap.id,
e,
@ -74,12 +78,18 @@ export function parseZap(zap: TaggedRawEvent): ParsedZap {
zapper: zapper.pubkey,
content: zap.content,
valid: zapper.isValid,
}
};
}
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap, showZapped?: boolean }) => {
const { amount, content, zapper, valid, p } = zap
const pubKey = useSelector((s: RootState) => s.login.publicKey)
const Zap = ({
zap,
showZapped = true,
}: {
zap: ParsedZap;
showZapped?: boolean;
}) => {
const { amount, content, zapper, valid, p } = zap;
const pubKey = useSelector((s: RootState) => s.login.publicKey);
return valid ? (
<div className="zap note card">
@ -99,26 +109,28 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap, showZapped?: boolean
/>
</div>
</div>
) : null
}
) : null;
};
interface ZapsSummaryProps { zaps: ParsedZap[] }
interface ZapsSummaryProps {
zaps: ParsedZap[];
}
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
const sortedZaps = useMemo(() => {
const pub = [...zaps.filter(z => z.zapper)]
const priv = [...zaps.filter(z => !z.zapper)]
pub.sort((a, b) => b.amount - a.amount)
return pub.concat(priv)
}, [zaps])
const pub = [...zaps.filter((z) => z.zapper)];
const priv = [...zaps.filter((z) => !z.zapper)];
pub.sort((a, b) => b.amount - a.amount);
return pub.concat(priv);
}, [zaps]);
if (zaps.length === 0) {
return null
return null;
}
const [topZap, ...restZaps] = sortedZaps
const restZapsTotal = restZaps.reduce((acc, z) => acc + z.amount, 0)
const { zapper, amount, content, valid } = topZap
const [topZap, ...restZaps] = sortedZaps;
const restZapsTotal = restZaps.reduce((acc, z) => acc + z.amount, 0);
const { zapper, amount, content, valid } = topZap;
return (
<div className="zaps-summary">
@ -127,14 +139,16 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
<div className="summary">
{zapper && <ProfileImage pubkey={zapper} />}
{restZaps.length > 0 && (
<span>and {restZaps.length} other{restZaps.length > 1 ? 's' : ''}</span>
<span>
and {restZaps.length} other{restZaps.length > 1 ? "s" : ""}
</span>
)}
<span>&nbsp;zapped</span>
</div>
</div>
)}
</div>
)
}
);
};
export default Zap
export default Zap;

View File

@ -6,22 +6,27 @@ import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "Nostr";
import SendSats from "Element/SendSats";
const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey; svc?: string }) => {
const profile = useUserProfile(pubkey!);
const [zap, setZap] = useState(false);
const service = svc ?? (profile?.lud16 || profile?.lud06);
const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => {
const profile = useUserProfile(pubkey!)
const [zap, setZap] = useState(false);
const service = svc ?? (profile?.lud16 || profile?.lud06);
if (!service) return null;
if (!service) return null;
return (
<>
<div className="zap-button" onClick={(e) => setZap(true)}>
<FontAwesomeIcon icon={faBolt} />
</div>
<SendSats target={profile?.display_name || profile?.name} svc={service} show={zap} onClose={() => setZap(false)} author={pubkey} />
</>
)
}
return (
<>
<div className="zap-button" onClick={(e) => setZap(true)}>
<FontAwesomeIcon icon={faBolt} />
</div>
<SendSats
target={profile?.display_name || profile?.name}
svc={service}
show={zap}
onClose={() => setZap(false)}
author={pubkey}
/>
</>
);
};
export default ZapButton;

View File

@ -6,342 +6,371 @@ import EventKind from "Nostr/EventKind";
import Tag from "Nostr/Tag";
import { RootState } from "State/Store";
import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr";
import { bech32ToHex } from "Util"
import { bech32ToHex } from "Util";
import { DefaultRelays, HashtagRegex } from "Const";
import { RelaySettings } from "Nostr/Connection";
declare global {
interface Window {
nostr: {
getPublicKey: () => Promise<HexKey>,
signEvent: (event: RawEvent) => Promise<RawEvent>,
getRelays: () => Promise<Record<string, { read: boolean, write: boolean }>>,
nip04: {
encrypt: (pubkey: HexKey, content: string) => Promise<string>,
decrypt: (pubkey: HexKey, content: string) => Promise<string>
}
}
}
interface Window {
nostr: {
getPublicKey: () => Promise<HexKey>;
signEvent: (event: RawEvent) => Promise<RawEvent>;
getRelays: () => Promise<
Record<string, { read: boolean; write: boolean }>
>;
nip04: {
encrypt: (pubkey: HexKey, content: string) => Promise<string>;
decrypt: (pubkey: HexKey, content: string) => Promise<string>;
};
};
}
}
export default function useEventPublisher() {
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const relays = useSelector((s: RootState) => s.login.relays);
const hasNip07 = 'nostr' in window;
const pubKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
const privKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.privateKey
);
const follows = useSelector<RootState, HexKey[]>((s) => s.login.follows);
const relays = useSelector((s: RootState) => s.login.relays);
const hasNip07 = "nostr" in window;
async function signEvent(ev: NEvent): Promise<NEvent> {
if (hasNip07 && !privKey) {
ev.Id = await ev.CreateId();
let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject()));
return new NEvent(tmpEv);
} else if (privKey) {
await ev.Sign(privKey);
} else {
console.warn("Count not sign event, no private keys available");
}
return ev;
async function signEvent(ev: NEvent): Promise<NEvent> {
if (hasNip07 && !privKey) {
ev.Id = await ev.CreateId();
let tmpEv = await barierNip07(() =>
window.nostr.signEvent(ev.ToObject())
);
return new NEvent(tmpEv);
} else if (privKey) {
await ev.Sign(privKey);
} else {
console.warn("Count not sign event, no private keys available");
}
return ev;
}
function processContent(ev: NEvent, msg: string) {
const replaceNpub = (match: string) => {
const npub = match.slice(1);
try {
const hex = bech32ToHex(npub);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["p", hex], idx));
return `#[${idx}]`
} catch (error) {
return match
}
function processContent(ev: NEvent, msg: string) {
const replaceNpub = (match: string) => {
const npub = match.slice(1);
try {
const hex = bech32ToHex(npub);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["p", hex], idx));
return `#[${idx}]`;
} catch (error) {
return match;
}
};
const replaceNoteId = (match: string) => {
try {
const hex = bech32ToHex(match);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
return `#[${idx}]`;
} catch (error) {
return match;
}
};
const replaceHashtag = (match: string) => {
const tag = match.slice(1);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
return match;
};
const content = msg
.replace(/@npub[a-z0-9]+/g, replaceNpub)
.replace(/note[a-z0-9]+/g, replaceNoteId)
.replace(HashtagRegex, replaceHashtag);
ev.Content = content;
}
return {
nip42Auth: async (challenge: string, relay: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Auth;
ev.Content = "";
ev.Tags.push(new Tag(["relay", relay], 0));
ev.Tags.push(new Tag(["challenge", challenge], 1));
return await signEvent(ev);
}
},
broadcast: (ev: NEvent | undefined) => {
if (ev) {
console.debug("Sending event: ", ev);
System.BroadcastEvent(ev);
}
},
/**
* Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs
* If a user removes all the DefaultRelays from their relay list and saves that relay list,
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
*/
broadcastForBootstrap: (ev: NEvent | undefined) => {
if (ev) {
for (let [k, _] of DefaultRelays) {
System.WriteOnceToRelay(k, ev);
}
const replaceNoteId = (match: string) => {
try {
const hex = bech32ToHex(match);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
return `#[${idx}]`
} catch (error) {
return match
}
}
},
muted: async (keys: HexKey[], priv: HexKey[]) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Lists;
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length));
keys.forEach((p) => {
ev.Tags.push(new Tag(["p", p], ev.Tags.length));
});
let content = "";
if (priv.length > 0) {
const ps = priv.map((p) => ["p", p]);
const plaintext = JSON.stringify(ps);
if (hasNip07 && !privKey) {
content = await barierNip07(() =>
window.nostr.nip04.encrypt(pubKey, plaintext)
);
} else if (privKey) {
content = await ev.EncryptData(plaintext, pubKey, privKey);
}
}
const replaceHashtag = (match: string) => {
const tag = match.slice(1);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
return match;
}
const content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
.replace(/note[a-z0-9]+/g, replaceNoteId)
.replace(HashtagRegex, replaceHashtag);
ev.Content = content;
}
return {
nip42Auth: async (challenge: string, relay: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Auth;
ev.Content = "";
ev.Tags.push(new Tag(["relay", relay], 0));
ev.Tags.push(new Tag(["challenge", challenge], 1));
return await signEvent(ev);
}
},
broadcast: (ev: NEvent | undefined) => {
if (ev) {
console.debug("Sending event: ", ev);
System.BroadcastEvent(ev);
}
},
/**
* Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs
* If a user removes all the DefaultRelays from their relay list and saves that relay list,
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
*/
broadcastForBootstrap: (ev: NEvent | undefined) => {
if (ev) {
for (let [k, _] of DefaultRelays) {
System.WriteOnceToRelay(k, ev);
}
}
},
muted: async (keys: HexKey[], priv: HexKey[]) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Lists;
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length))
keys.forEach(p => {
ev.Tags.push(new Tag(["p", p], ev.Tags.length))
})
let content = ""
if (priv.length > 0) {
const ps = priv.map(p => ["p", p])
const plaintext = JSON.stringify(ps)
if (hasNip07 && !privKey) {
content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
} else if (privKey) {
content = await ev.EncryptData(plaintext, pubKey, privKey)
}
}
ev.Content = content;
return await signEvent(ev);
}
},
metadata: async (obj: UserMetadata) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.SetMetadata;
ev.Content = JSON.stringify(obj);
return await signEvent(ev);
}
},
note: async (msg: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
processContent(ev, msg);
return await signEvent(ev);
}
},
zap: async (author: HexKey, note?: HexKey, msg?: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ZapRequest;
if (note) {
// @ts-ignore
ev.Tags.push(new Tag(["e", note]))
}
// @ts-ignore
ev.Tags.push(new Tag(["p", author]))
// @ts-ignore
const relayTag = ['relays', ...Object.keys(relays).slice(0, 10)]
// @ts-ignore
ev.Tags.push(new Tag(relayTag))
processContent(ev, msg || '');
return await signEvent(ev);
}
},
/**
* Reply to a note
*/
reply: async (replyTo: NEvent, msg: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
let thread = replyTo.Thread;
if (thread) {
if (thread.Root || thread.ReplyTo) {
ev.Tags.push(new Tag(["e", thread.Root?.Event ?? thread.ReplyTo?.Event!, "", "root"], ev.Tags.length));
}
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
}
for (let pk of thread.PubKeys) {
if (pk === pubKey) {
continue; // dont tag self in replies
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
} else {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
}
}
processContent(ev, msg);
return await signEvent(ev);
}
},
react: async (evRef: NEvent, content = "+") => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Reaction;
ev.Content = content;
ev.Tags.push(new Tag(["e", evRef.Id], 0));
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
return await signEvent(ev);
}
},
saveRelays: async () => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(newRelays ?? relays);
let temp = new Set(follows);
if (Array.isArray(pkAdd)) {
pkAdd.forEach(a => temp.add(a));
} else {
temp.add(pkAdd);
}
for (let pk of temp) {
if (pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
removeFollow: async (pkRemove: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
if (pk === pkRemove || pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
/**
* Delete an event (NIP-09)
*/
delete: async (id: u256) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Deletion;
ev.Content = "";
ev.Tags.push(new Tag(["e", id], 0));
return await signEvent(ev);
}
},
/**
* Respot a note (NIP-18)
*/
repost: async (note: NEvent) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Repost;
ev.Content = JSON.stringify(note.Original);
ev.Tags.push(new Tag(["e", note.Id], 0));
ev.Tags.push(new Tag(["p", note.PubKey], 1));
return await signEvent(ev);
}
},
decryptDm: async (note: NEvent): Promise<string | undefined> => {
if (pubKey) {
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) {
return "<CANT DECRYPT>";
}
try {
let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey! : note.PubKey;
if (hasNip07 && !privKey) {
return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content));
} else if (privKey) {
await note.DecryptDm(privKey, otherPubKey);
return note.Content;
}
} catch (e) {
console.error("Decyrption failed", e);
return "<DECRYPTION FAILED>";
}
}
},
sendDm: async (content: string, to: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.DirectMessage;
ev.Content = content;
ev.Tags.push(new Tag(["p", to], 0));
try {
if (hasNip07 && !privKey) {
let cx: string = await barierNip07(() => window.nostr.nip04.encrypt(to, content));
ev.Content = cx;
return await signEvent(ev);
} else if (privKey) {
await ev.EncryptDmForPubkey(to, privKey);
return await signEvent(ev);
}
} catch (e) {
console.error("Encryption failed", e);
}
}
return await signEvent(ev);
}
},
metadata: async (obj: UserMetadata) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.SetMetadata;
ev.Content = JSON.stringify(obj);
return await signEvent(ev);
}
},
note: async (msg: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
processContent(ev, msg);
return await signEvent(ev);
}
},
zap: async (author: HexKey, note?: HexKey, msg?: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ZapRequest;
if (note) {
// @ts-ignore
ev.Tags.push(new Tag(["e", note]));
}
}
// @ts-ignore
ev.Tags.push(new Tag(["p", author]));
// @ts-ignore
const relayTag = ["relays", ...Object.keys(relays).slice(0, 10)];
// @ts-ignore
ev.Tags.push(new Tag(relayTag));
processContent(ev, msg || "");
return await signEvent(ev);
}
},
/**
* Reply to a note
*/
reply: async (replyTo: NEvent, msg: string) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
let thread = replyTo.Thread;
if (thread) {
if (thread.Root || thread.ReplyTo) {
ev.Tags.push(
new Tag(
["e", thread.Root?.Event ?? thread.ReplyTo?.Event!, "", "root"],
ev.Tags.length
)
);
}
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
}
for (let pk of thread.PubKeys) {
if (pk === pubKey) {
continue; // dont tag self in replies
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
} else {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
}
}
processContent(ev, msg);
return await signEvent(ev);
}
},
react: async (evRef: NEvent, content = "+") => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Reaction;
ev.Content = content;
ev.Tags.push(new Tag(["e", evRef.Id], 0));
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
return await signEvent(ev);
}
},
saveRelays: async () => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
addFollow: async (
pkAdd: HexKey | HexKey[],
newRelays?: Record<string, RelaySettings>
) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(newRelays ?? relays);
let temp = new Set(follows);
if (Array.isArray(pkAdd)) {
pkAdd.forEach((a) => temp.add(a));
} else {
temp.add(pkAdd);
}
for (let pk of temp) {
if (pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
removeFollow: async (pkRemove: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
if (pk === pkRemove || pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
return await signEvent(ev);
}
},
/**
* Delete an event (NIP-09)
*/
delete: async (id: u256) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Deletion;
ev.Content = "";
ev.Tags.push(new Tag(["e", id], 0));
return await signEvent(ev);
}
},
/**
* Respot a note (NIP-18)
*/
repost: async (note: NEvent) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Repost;
ev.Content = JSON.stringify(note.Original);
ev.Tags.push(new Tag(["e", note.Id], 0));
ev.Tags.push(new Tag(["p", note.PubKey], 1));
return await signEvent(ev);
}
},
decryptDm: async (note: NEvent): Promise<string | undefined> => {
if (pubKey) {
if (
note.PubKey !== pubKey &&
!note.Tags.some((a) => a.PubKey === pubKey)
) {
return "<CANT DECRYPT>";
}
try {
let otherPubKey =
note.PubKey === pubKey
? note.Tags.filter((a) => a.Key === "p")[0].PubKey!
: note.PubKey;
if (hasNip07 && !privKey) {
return await barierNip07(() =>
window.nostr.nip04.decrypt(otherPubKey, note.Content)
);
} else if (privKey) {
await note.DecryptDm(privKey, otherPubKey);
return note.Content;
}
} catch (e) {
console.error("Decyrption failed", e);
return "<DECRYPTION FAILED>";
}
}
},
sendDm: async (content: string, to: HexKey) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.DirectMessage;
ev.Content = content;
ev.Tags.push(new Tag(["p", to], 0));
try {
if (hasNip07 && !privKey) {
let cx: string = await barierNip07(() =>
window.nostr.nip04.encrypt(to, content)
);
ev.Content = cx;
return await signEvent(ev);
} else if (privKey) {
await ev.EncryptDmForPubkey(to, privKey);
return await signEvent(ev);
}
} catch (e) {
console.error("Encryption failed", e);
}
}
},
};
}
let isNip07Busy = false;
const delay = (t: number) => {
return new Promise((resolve, reject) => {
setTimeout(resolve, t);
});
}
return new Promise((resolve, reject) => {
setTimeout(resolve, t);
});
};
export const barierNip07 = async (then: () => Promise<any>) => {
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
};

View File

@ -5,14 +5,14 @@ import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription from "Feed/Subscription";
export default function useFollowersFeed(pubkey: HexKey) {
const sub = useMemo(() => {
let x = new Subscriptions();
x.Id = `followers:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.PTags = new Set([pubkey]);
const sub = useMemo(() => {
let x = new Subscriptions();
x.Id = `followers:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.PTags = new Set([pubkey]);
return x;
}, [pubkey]);
return x;
}, [pubkey]);
return useSubscription(sub);
}
return useSubscription(sub);
}

View File

@ -1,24 +1,28 @@
import { useMemo } from "react";
import { HexKey } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions} from "Nostr/Subscriptions";
import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription, { NoteStore } from "Feed/Subscription";
export default function useFollowsFeed(pubkey: HexKey) {
const sub = useMemo(() => {
let x = new Subscriptions();
x.Id = `follows:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.Authors = new Set([pubkey]);
const sub = useMemo(() => {
let x = new Subscriptions();
x.Id = `follows:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.Authors = new Set([pubkey]);
return x;
}, [pubkey]);
return x;
}, [pubkey]);
return useSubscription(sub);
return useSubscription(sub);
}
export function getFollowers(feed: NoteStore, pubkey: HexKey) {
let contactLists = feed?.notes.filter(a => a.kind === EventKind.ContactList && a.pubkey === pubkey);
let pTags = contactLists?.map(a => a.tags.filter(b => b[0] === "p").map(c => c[1]));
return [...new Set(pTags?.flat())];
let contactLists = feed?.notes.filter(
(a) => a.kind === EventKind.ContactList && a.pubkey === pubkey
);
let pTags = contactLists?.map((a) =>
a.tags.filter((b) => b[0] === "p").map((c) => c[1])
);
return [...new Set(pTags?.flat())];
}

View File

@ -1,39 +1,44 @@
import * as secp from "@noble/secp256k1"
import * as base64 from "@protobufjs/base64"
import * as secp from "@noble/secp256k1";
import * as base64 from "@protobufjs/base64";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
export interface ImgProxySettings {
url: string,
key: string,
salt: string
url: string;
key: string;
salt: string;
}
export default function useImgProxy() {
const settings = useSelector((s: RootState) => s.login.preferences.imgProxyConfig);
const te = new TextEncoder();
const settings = useSelector(
(s: RootState) => s.login.preferences.imgProxyConfig
);
const te = new TextEncoder();
function urlSafe(s: string) {
return s.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function urlSafe(s: string) {
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
async function signUrl(u: string) {
const result = await secp.utils.hmacSha256(
secp.utils.hexToBytes(settings!.key),
secp.utils.hexToBytes(settings!.salt),
te.encode(u));
return urlSafe(base64.encode(result, 0, result.byteLength));
}
async function signUrl(u: string) {
const result = await secp.utils.hmacSha256(
secp.utils.hexToBytes(settings!.key),
secp.utils.hexToBytes(settings!.salt),
te.encode(u)
);
return urlSafe(base64.encode(result, 0, result.byteLength));
}
return {
proxy: async (url: string, resize?: number) => {
if (!settings) return url;
const opt = resize ? `rs:fit:${resize}:${resize}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes, 0, urlBytes.byteLength));
const path = `/${opt}/${urlEncoded}`;
const sig = await signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
}
}
}
return {
proxy: async (url: string, resize?: number) => {
if (!settings) return url;
const opt = resize ? `rs:fit:${resize}:${resize}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(
base64.encode(urlBytes, 0, urlBytes.byteLength)
);
const path = `/${opt}/${urlEncoded}`;
const sig = await signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
},
};
}

View File

@ -6,7 +6,15 @@ import { TaggedRawEvent, HexKey, Lists } from "Nostr";
import EventKind from "Nostr/EventKind";
import Event from "Nostr/Event";
import { Subscriptions } from "Nostr/Subscriptions";
import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification, setLatestNotifications } from "State/Login";
import {
addDirectMessage,
setFollows,
setRelays,
setMuted,
setBlocked,
sendNotification,
setLatestNotifications,
} from "State/Login";
import { RootState } from "State/Store";
import { mapEventToProfile, MetadataCache } from "State/Users";
import { useDb } from "State/Users/Db";
@ -20,7 +28,12 @@ import useModeration from "Hooks/useModeration";
*/
export default function useLoginFeed() {
const dispatch = useDispatch();
const { publicKey: pubKey, privateKey: privKey, latestMuted, readNotifications } = useSelector((s: RootState) => s.login);
const {
publicKey: pubKey,
privateKey: privKey,
latestMuted,
readNotifications,
} = useSelector((s: RootState) => s.login);
const { isMuted } = useModeration();
const db = useDb();
@ -31,7 +44,7 @@ export default function useLoginFeed() {
sub.Id = `login:meta`;
sub.Authors = new Set([pubKey]);
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
sub.Limit = 2
sub.Limit = 2;
return sub;
}, [pubKey]);
@ -77,35 +90,49 @@ export default function useLoginFeed() {
return dms;
}, [pubKey]);
const metadataFeed = useSubscription(subMetadata, { leaveOpen: true, cache: true });
const notificationFeed = useSubscription(subNotification, { leaveOpen: true, cache: true });
const metadataFeed = useSubscription(subMetadata, {
leaveOpen: true,
cache: true,
});
const notificationFeed = useSubscription(subNotification, {
leaveOpen: true,
cache: true,
});
const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true });
const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true });
useEffect(() => {
let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
let metadata = metadataFeed.store.notes.filter(a => a.kind === EventKind.SetMetadata);
let profiles = metadata.map(a => mapEventToProfile(a))
.filter(a => a !== undefined)
.map(a => a!);
let contactList = metadataFeed.store.notes.filter(
(a) => a.kind === EventKind.ContactList
);
let metadata = metadataFeed.store.notes.filter(
(a) => a.kind === EventKind.SetMetadata
);
let profiles = metadata
.map((a) => mapEventToProfile(a))
.filter((a) => a !== undefined)
.map((a) => a!);
for (let cl of contactList) {
if (cl.content !== "" && cl.content !== "{}") {
let relays = JSON.parse(cl.content);
dispatch(setRelays({ relays, createdAt: cl.created_at }));
}
let pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]);
let pTags = cl.tags.filter((a) => a[0] === "p").map((a) => a[1]);
dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
}
(async () => {
let maxProfile = profiles.reduce((acc, v) => {
if (v.created > acc.created) {
acc.profile = v;
acc.created = v.created;
}
return acc;
}, { created: 0, profile: null as MetadataCache | null });
let maxProfile = profiles.reduce(
(acc, v) => {
if (v.created > acc.created) {
acc.profile = v;
acc.created = v.created;
}
return acc;
},
{ created: 0, profile: null as MetadataCache | null }
);
if (maxProfile.profile) {
let existing = await db.find(maxProfile.profile.pubkey);
if ((existing?.created ?? 0) < maxProfile.created) {
@ -116,52 +143,74 @@ export default function useLoginFeed() {
}, [dispatch, metadataFeed.store, db]);
useEffect(() => {
const replies = notificationFeed.store.notes.
filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications)
replies.forEach(nx => {
const replies = notificationFeed.store.notes.filter(
(a) =>
a.kind === EventKind.TextNote &&
!isMuted(a.pubkey) &&
a.created_at > readNotifications
);
replies.forEach((nx) => {
dispatch(setLatestNotifications(nx.created_at));
makeNotification(db, nx).then(notification => {
makeNotification(db, nx).then((notification) => {
if (notification) {
// @ts-ignore
dispatch(sendNotification(notification))
dispatch(sendNotification(notification));
}
})
})
});
});
}, [dispatch, notificationFeed.store, db, readNotifications]);
useEffect(() => {
const muted = getMutedKeys(mutedFeed.store.notes)
dispatch(setMuted(muted))
const muted = getMutedKeys(mutedFeed.store.notes);
dispatch(setMuted(muted));
const newest = getNewest(mutedFeed.store.notes)
if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) {
decryptBlocked(newest, pubKey, privKey).then((plaintext) => {
try {
const blocked = JSON.parse(plaintext)
const keys = blocked.filter((p: any) => p && p.length === 2 && p[0] === "p").map((p: any) => p[1])
dispatch(setBlocked({
keys,
createdAt: newest.created_at,
}))
} catch (error) {
console.debug("Couldn't parse JSON")
}
}).catch((error) => console.warn(error))
const newest = getNewest(mutedFeed.store.notes);
if (
newest &&
newest.content.length > 0 &&
pubKey &&
newest.created_at > latestMuted
) {
decryptBlocked(newest, pubKey, privKey)
.then((plaintext) => {
try {
const blocked = JSON.parse(plaintext);
const keys = blocked
.filter((p: any) => p && p.length === 2 && p[0] === "p")
.map((p: any) => p[1]);
dispatch(
setBlocked({
keys,
createdAt: newest.created_at,
})
);
} catch (error) {
console.debug("Couldn't parse JSON");
}
})
.catch((error) => console.warn(error));
}
}, [dispatch, mutedFeed.store])
}, [dispatch, mutedFeed.store]);
useEffect(() => {
let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
let dms = dmsFeed.store.notes.filter(
(a) => a.kind === EventKind.DirectMessage
);
dispatch(addDirectMessage(dms));
}, [dispatch, dmsFeed.store]);
}
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
const ev = new Event(raw)
async function decryptBlocked(
raw: TaggedRawEvent,
pubKey: HexKey,
privKey?: HexKey
) {
const ev = new Event(raw);
if (pubKey && privKey) {
return await ev.DecryptData(raw.content, privKey, pubKey)
return await ev.DecryptData(raw.content, privKey, pubKey);
} else {
return await barierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
return await barierNip07(() =>
window.nostr.nip04.decrypt(pubKey, raw.content)
);
}
}

View File

@ -6,41 +6,46 @@ import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription, { NoteStore } from "Feed/Subscription";
export default function useMutedFeed(pubkey: HexKey) {
const sub = useMemo(() => {
let sub = new Subscriptions();
sub.Id = `muted:${pubkey.slice(0, 12)}`;
sub.Kinds = new Set([EventKind.Lists]);
sub.Authors = new Set([pubkey]);
sub.DTags = new Set([Lists.Muted]);
sub.Limit = 1;
return sub;
}, [pubkey]);
const sub = useMemo(() => {
let sub = new Subscriptions();
sub.Id = `muted:${pubkey.slice(0, 12)}`;
sub.Kinds = new Set([EventKind.Lists]);
sub.Authors = new Set([pubkey]);
sub.DTags = new Set([Lists.Muted]);
sub.Limit = 1;
return sub;
}, [pubkey]);
return useSubscription(sub);
return useSubscription(sub);
}
export function getNewest(rawNotes: TaggedRawEvent[]){
const notes = [...rawNotes]
notes.sort((a, b) => a.created_at - b.created_at)
if (notes.length > 0) {
return notes[0]
}
export function getNewest(rawNotes: TaggedRawEvent[]) {
const notes = [...rawNotes];
notes.sort((a, b) => a.created_at - b.created_at);
if (notes.length > 0) {
return notes[0];
}
}
export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } {
const newest = getNewest(rawNotes)
if (newest) {
const { created_at, tags } = newest
const keys = tags.filter(t => t[0] === "p").map(t => t[1])
return {
keys,
createdAt: created_at,
}
}
return { createdAt: 0, keys: [] }
export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
createdAt: number;
keys: HexKey[];
} {
const newest = getNewest(rawNotes);
if (newest) {
const { created_at, tags } = newest;
const keys = tags.filter((t) => t[0] === "p").map((t) => t[1]);
return {
keys,
createdAt: created_at,
};
}
return { createdAt: 0, keys: [] };
}
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
let lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey);
return getMutedKeys(lists).keys;
let lists = feed?.notes.filter(
(a) => a.kind === EventKind.Lists && a.pubkey === pubkey
);
return getMutedKeys(lists).keys;
}

View File

@ -5,28 +5,29 @@ import { HexKey } from "Nostr";
import { System } from "Nostr/System";
export function useUserProfile(pubKey: HexKey): MetadataCache | undefined {
const users = useKey(pubKey);
const users = useKey(pubKey);
useEffect(() => {
if (pubKey) {
System.TrackMetadata(pubKey);
return () => System.UntrackMetadata(pubKey);
}
}, [pubKey]);
useEffect(() => {
if (pubKey) {
System.TrackMetadata(pubKey);
return () => System.UntrackMetadata(pubKey);
}
}, [pubKey]);
return users;
return users;
}
export function useUserProfiles(
pubKeys: Array<HexKey>
): Map<HexKey, MetadataCache> | undefined {
const users = useKeys(pubKeys);
export function useUserProfiles(pubKeys: Array<HexKey>): Map<HexKey, MetadataCache> | undefined {
const users = useKeys(pubKeys);
useEffect(() => {
if (pubKeys) {
System.TrackMetadata(pubKeys);
return () => System.UntrackMetadata(pubKeys);
}
}, [pubKeys]);
useEffect(() => {
if (pubKeys) {
System.TrackMetadata(pubKeys);
return () => System.UntrackMetadata(pubKeys);
}
}, [pubKeys]);
return users;
return users;
}

View File

@ -2,12 +2,17 @@ import { useSyncExternalStore } from "react";
import { System } from "Nostr/System";
import { CustomHook, StateSnapshot } from "Nostr/Connection";
const noop = (f: CustomHook) => { return () => { }; };
const noop = (f: CustomHook) => {
return () => {};
};
const noopState = (): StateSnapshot | undefined => {
return undefined;
return undefined;
};
export default function useRelayState(addr: string) {
let c = System.Sockets.get(addr);
return useSyncExternalStore<StateSnapshot | undefined>(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState);
}
let c = System.Sockets.get(addr);
return useSyncExternalStore<StateSnapshot | undefined>(
c?.StatusHook.bind(c) ?? noop,
c?.GetState.bind(c) ?? noopState
);
}

View File

@ -6,62 +6,59 @@ import { debounce } from "Util";
import { db } from "Db";
export type NoteStore = {
notes: Array<TaggedRawEvent>,
end: boolean
notes: Array<TaggedRawEvent>;
end: boolean;
};
export type UseSubscriptionOptions = {
leaveOpen: boolean,
cache: boolean
}
leaveOpen: boolean;
cache: boolean;
};
interface ReducerArg {
type: "END" | "EVENT" | "CLEAR",
ev?: TaggedRawEvent | Array<TaggedRawEvent>,
end?: boolean
type: "END" | "EVENT" | "CLEAR";
ev?: TaggedRawEvent | Array<TaggedRawEvent>;
end?: boolean;
}
function notesReducer(state: NoteStore, arg: ReducerArg) {
if (arg.type === "END") {
return {
notes: state.notes,
end: arg.end!
} as NoteStore;
}
if (arg.type === "CLEAR") {
return {
notes: [],
end: state.end,
} as NoteStore;
}
let evs = arg.ev!;
if (!Array.isArray(evs)) {
evs = [evs];
}
let existingIds = new Set(state.notes.map(a => a.id));
evs = evs.filter(a => !existingIds.has(a.id));
if (evs.length === 0) {
return state;
}
if (arg.type === "END") {
return {
notes: [
...state.notes,
...evs
]
notes: state.notes,
end: arg.end!,
} as NoteStore;
}
if (arg.type === "CLEAR") {
return {
notes: [],
end: state.end,
} as NoteStore;
}
let evs = arg.ev!;
if (!Array.isArray(evs)) {
evs = [evs];
}
let existingIds = new Set(state.notes.map((a) => a.id));
evs = evs.filter((a) => !existingIds.has(a.id));
if (evs.length === 0) {
return state;
}
return {
notes: [...state.notes, ...evs],
} as NoteStore;
}
const initStore: NoteStore = {
notes: [],
end: false
notes: [],
end: false,
};
export interface UseSubscriptionState {
store: NoteStore,
clear: () => void,
append: (notes: TaggedRawEvent[]) => void
store: NoteStore;
clear: () => void;
append: (notes: TaggedRawEvent[]) => void;
}
/**
@ -70,121 +67,131 @@ export interface UseSubscriptionState {
const DebounceMs = 200;
/**
*
* @param {Subscriptions} sub
* @param {any} opt
* @returns
*
* @param {Subscriptions} sub
* @param {any} opt
* @returns
*/
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions): UseSubscriptionState {
const [state, dispatch] = useReducer(notesReducer, initStore);
const [debounceOutput, setDebounceOutput] = useState<number>(0);
const [subDebounce, setSubDebounced] = useState<Subscriptions>();
const useCache = useMemo(() => options?.cache === true, [options]);
export default function useSubscription(
sub: Subscriptions | null,
options?: UseSubscriptionOptions
): UseSubscriptionState {
const [state, dispatch] = useReducer(notesReducer, initStore);
const [debounceOutput, setDebounceOutput] = useState<number>(0);
const [subDebounce, setSubDebounced] = useState<Subscriptions>();
const useCache = useMemo(() => options?.cache === true, [options]);
useEffect(() => {
if (sub) {
return debounce(DebounceMs, () => {
setSubDebounced(sub);
});
}
}, [sub, options]);
useEffect(() => {
if (subDebounce) {
dispatch({
type: "END",
end: false
});
if (useCache) {
// preload notes from db
PreloadNotes(subDebounce.Id)
.then(ev => {
dispatch({
type: "EVENT",
ev: ev
});
})
.catch(console.warn);
}
subDebounce.OnEvent = (e) => {
dispatch({
type: "EVENT",
ev: e
});
if (useCache) {
db.events.put(e);
}
};
subDebounce.OnEnd = (c) => {
if (!(options?.leaveOpen ?? false)) {
c.RemoveSubscription(subDebounce.Id);
if (subDebounce.IsFinished()) {
System.RemoveSubscription(subDebounce.Id);
}
}
dispatch({
type: "END",
end: true
});
};
console.debug("Adding sub: ", subDebounce.ToObject());
System.AddSubscription(subDebounce);
return () => {
console.debug("Removing sub: ", subDebounce.ToObject());
System.RemoveSubscription(subDebounce.Id);
};
}
}, [subDebounce, useCache]);
useEffect(() => {
if (subDebounce && useCache) {
return debounce(500, () => {
TrackNotesInFeed(subDebounce.Id, state.notes)
.catch(console.warn);
});
}
}, [state, useCache]);
useEffect(() => {
return debounce(DebounceMs, () => {
setDebounceOutput(s => s += 1);
});
}, [state]);
const stateDebounced = useMemo(() => state, [debounceOutput]);
return {
store: stateDebounced,
clear: () => {
dispatch({ type: "CLEAR" });
},
append: (n: TaggedRawEvent[]) => {
dispatch({
type: "EVENT",
ev: n
});
}
useEffect(() => {
if (sub) {
return debounce(DebounceMs, () => {
setSubDebounced(sub);
});
}
}, [sub, options]);
useEffect(() => {
if (subDebounce) {
dispatch({
type: "END",
end: false,
});
if (useCache) {
// preload notes from db
PreloadNotes(subDebounce.Id)
.then((ev) => {
dispatch({
type: "EVENT",
ev: ev,
});
})
.catch(console.warn);
}
subDebounce.OnEvent = (e) => {
dispatch({
type: "EVENT",
ev: e,
});
if (useCache) {
db.events.put(e);
}
};
subDebounce.OnEnd = (c) => {
if (!(options?.leaveOpen ?? false)) {
c.RemoveSubscription(subDebounce.Id);
if (subDebounce.IsFinished()) {
System.RemoveSubscription(subDebounce.Id);
}
}
dispatch({
type: "END",
end: true,
});
};
console.debug("Adding sub: ", subDebounce.ToObject());
System.AddSubscription(subDebounce);
return () => {
console.debug("Removing sub: ", subDebounce.ToObject());
System.RemoveSubscription(subDebounce.Id);
};
}
}, [subDebounce, useCache]);
useEffect(() => {
if (subDebounce && useCache) {
return debounce(500, () => {
TrackNotesInFeed(subDebounce.Id, state.notes).catch(console.warn);
});
}
}, [state, useCache]);
useEffect(() => {
return debounce(DebounceMs, () => {
setDebounceOutput((s) => (s += 1));
});
}, [state]);
const stateDebounced = useMemo(() => state, [debounceOutput]);
return {
store: stateDebounced,
clear: () => {
dispatch({ type: "CLEAR" });
},
append: (n: TaggedRawEvent[]) => {
dispatch({
type: "EVENT",
ev: n,
});
},
};
}
/**
* Lookup cached copy of feed
*/
const PreloadNotes = async (id: string): Promise<TaggedRawEvent[]> => {
const feed = await db.feeds.get(id);
if (feed) {
const events = await db.events.bulkGet(feed.ids);
return events.filter(a => a !== undefined).map(a => a!);
}
return [];
}
const feed = await db.feeds.get(id);
if (feed) {
const events = await db.events.bulkGet(feed.ids);
return events.filter((a) => a !== undefined).map((a) => a!);
}
return [];
};
const TrackNotesInFeed = async (id: string, notes: TaggedRawEvent[]) => {
const existing = await db.feeds.get(id);
const ids = Array.from(new Set([...(existing?.ids || []), ...notes.map(a => a.id)]));
const since = notes.reduce((acc, v) => acc > v.created_at ? v.created_at : acc, +Infinity);
const until = notes.reduce((acc, v) => acc < v.created_at ? v.created_at : acc, -Infinity);
await db.feeds.put({ id, ids, since, until });
}
const existing = await db.feeds.get(id);
const ids = Array.from(
new Set([...(existing?.ids || []), ...notes.map((a) => a.id)])
);
const since = notes.reduce(
(acc, v) => (acc > v.created_at ? v.created_at : acc),
+Infinity
);
const until = notes.reduce(
(acc, v) => (acc < v.created_at ? v.created_at : acc),
-Infinity
);
await db.feeds.put({ id, ids, since, until });
};

View File

@ -9,51 +9,66 @@ import { UserPreferences } from "State/Login";
import { debounce } from "Util";
export default function useThreadFeed(id: u256) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
const pref = useSelector<RootState, UserPreferences>(
(s) => s.login.preferences
);
function addId(id: u256[]) {
setTrackingEvent((s) => {
let orig = new Set(s);
if (id.some(a => !orig.has(a))) {
let tmp = new Set([...s, ...id]);
return Array.from(tmp);
} else {
return s;
}
})
function addId(id: u256[]) {
setTrackingEvent((s) => {
let orig = new Set(s);
if (id.some((a) => !orig.has(a))) {
let tmp = new Set([...s, ...id]);
return Array.from(tmp);
} else {
return s;
}
});
}
const sub = useMemo(() => {
const thisSub = new Subscriptions();
thisSub.Id = `thread:${id.substring(0, 8)}`;
thisSub.Ids = new Set(trackingEvents);
// get replies to this event
const subRelated = new Subscriptions();
subRelated.Kinds = new Set(
pref.enableReactions
? [
EventKind.Reaction,
EventKind.TextNote,
EventKind.Deletion,
EventKind.Repost,
EventKind.ZapReceipt,
]
: [EventKind.TextNote]
);
subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated);
return thisSub;
}, [trackingEvents, pref, id]);
const main = useSubscription(sub, { leaveOpen: true, cache: true });
useEffect(() => {
if (main.store) {
return debounce(200, () => {
let mainNotes = main.store.notes.filter(
(a) => a.kind === EventKind.TextNote
);
let eTags = mainNotes
.filter((a) => a.kind === EventKind.TextNote)
.map((a) => a.tags.filter((b) => b[0] === "e").map((b) => b[1]))
.flat();
let ids = mainNotes.map((a) => a.id);
let allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents));
});
}
}, [main.store]);
const sub = useMemo(() => {
const thisSub = new Subscriptions();
thisSub.Id = `thread:${id.substring(0, 8)}`;
thisSub.Ids = new Set(trackingEvents);
// get replies to this event
const subRelated = new Subscriptions();
subRelated.Kinds = new Set(pref.enableReactions ? [EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost, EventKind.ZapReceipt] : [EventKind.TextNote]);
subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated);
return thisSub;
}, [trackingEvents, pref, id]);
const main = useSubscription(sub, { leaveOpen: true, cache: true });
useEffect(() => {
if (main.store) {
return debounce(200, () => {
let mainNotes = main.store.notes.filter(a => a.kind === EventKind.TextNote);
let eTags = mainNotes
.filter(a => a.kind === EventKind.TextNote)
.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
let ids = mainNotes.map(a => a.id);
let allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents));
})
}
}, [main.store]);
return main.store;
return main.store;
}

View File

@ -9,169 +9,184 @@ import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
export interface TimelineFeedOptions {
method: "TIME_RANGE" | "LIMIT_UNTIL",
window?: number
method: "TIME_RANGE" | "LIMIT_UNTIL";
window?: number;
}
export interface TimelineSubject {
type: "pubkey" | "hashtag" | "global" | "ptag" | "keyword",
discriminator: string,
items: string[]
type: "pubkey" | "hashtag" | "global" | "ptag" | "keyword";
discriminator: string;
items: string[];
}
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow();
const [window] = useState<number>(options.window ?? 60 * 60);
const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
export default function useTimelineFeed(
subject: TimelineSubject,
options: TimelineFeedOptions
) {
const now = unixNow();
const [window] = useState<number>(options.window ?? 60 * 60);
const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(
(s) => s.login.preferences
);
const createSub = useCallback(() => {
if (subject.type !== "global" && subject.items.length === 0) {
return null;
const createSub = useCallback(() => {
if (subject.type !== "global" && subject.items.length === 0) {
return null;
}
let sub = new Subscriptions();
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
switch (subject.type) {
case "pubkey": {
sub.Authors = new Set(subject.items);
break;
}
case "hashtag": {
sub.HashTags = new Set(subject.items);
break;
}
case "ptag": {
sub.PTags = new Set(subject.items);
break;
}
case "keyword": {
sub.Kinds.add(EventKind.SetMetadata);
sub.Search = subject.items[0];
break;
}
}
return sub;
}, [subject.type, subject.items, subject.discriminator]);
const sub = useMemo(() => {
let sub = createSub();
if (sub) {
if (options.method === "LIMIT_UNTIL") {
sub.Until = until;
sub.Limit = 10;
} else {
sub.Since = since;
sub.Until = until;
if (since === undefined) {
sub.Limit = 50;
}
}
let sub = new Subscriptions();
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
switch (subject.type) {
case "pubkey": {
sub.Authors = new Set(subject.items);
break;
}
case "hashtag": {
sub.HashTags = new Set(subject.items);
break;
}
case "ptag": {
sub.PTags = new Set(subject.items);
break;
}
case "keyword": {
sub.Kinds.add(EventKind.SetMetadata);
sub.Search = subject.items[0];
break;
}
if (pref.autoShowLatest) {
// copy properties of main sub but with limit 0
// this will put latest directly into main feed
let latestSub = new Subscriptions();
latestSub.Authors = sub.Authors;
latestSub.HashTags = sub.HashTags;
latestSub.PTags = sub.PTags;
latestSub.Kinds = sub.Kinds;
latestSub.Search = sub.Search;
latestSub.Limit = 1;
latestSub.Since = Math.floor(new Date().getTime() / 1000);
sub.AddSubscription(latestSub);
}
}
return sub;
}, [until, since, options.method, pref, createSub]);
const main = useSubscription(sub, { leaveOpen: true, cache: true });
const subRealtime = useMemo(() => {
let subLatest = createSub();
if (subLatest && !pref.autoShowLatest) {
subLatest.Id = `${subLatest.Id}:latest`;
subLatest.Limit = 1;
subLatest.Since = Math.floor(new Date().getTime() / 1000);
}
return subLatest;
}, [pref, createSub]);
const latest = useSubscription(subRealtime, {
leaveOpen: true,
cache: false,
});
const subNext = useMemo(() => {
let sub: Subscriptions | undefined;
if (trackingEvents.length > 0 && pref.enableReactions) {
sub = new Subscriptions();
sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set([
EventKind.Reaction,
EventKind.Deletion,
EventKind.ZapReceipt,
]);
sub.ETags = new Set(trackingEvents);
}
return sub ?? null;
}, [trackingEvents, pref, subject.type]);
const others = useSubscription(subNext, { leaveOpen: true, cache: true });
const subParents = useMemo(() => {
if (trackingParentEvents.length > 0) {
let parents = new Subscriptions();
parents.Id = `timeline-parent:${subject.type}`;
parents.Ids = new Set(trackingParentEvents);
return parents;
}
return null;
}, [trackingParentEvents, subject.type]);
const parent = useSubscription(subParents);
useEffect(() => {
if (main.store.notes.length > 0) {
setTrackingEvent((s) => {
let ids = main.store.notes.map((a) => a.id);
if (ids.some((a) => !s.includes(a))) {
return Array.from(new Set([...s, ...ids]));
}
return sub;
}, [subject.type, subject.items, subject.discriminator]);
return s;
});
let reposts = main.store.notes
.filter((a) => a.kind === EventKind.Repost && a.content === "")
.map((a) => a.tags.find((b) => b[0] === "e"))
.filter((a) => a)
.map((a) => a![1]);
if (reposts.length > 0) {
setTrackingParentEvents((s) => {
if (reposts.some((a) => !s.includes(a))) {
let temp = new Set([...s, ...reposts]);
return Array.from(temp);
}
return s;
});
}
}
}, [main.store]);
const sub = useMemo(() => {
let sub = createSub();
if (sub) {
if (options.method === "LIMIT_UNTIL") {
sub.Until = until;
sub.Limit = 10;
} else {
sub.Since = since;
sub.Until = until;
if (since === undefined) {
sub.Limit = 50;
}
}
if (pref.autoShowLatest) {
// copy properties of main sub but with limit 0
// this will put latest directly into main feed
let latestSub = new Subscriptions();
latestSub.Authors = sub.Authors;
latestSub.HashTags = sub.HashTags;
latestSub.PTags = sub.PTags;
latestSub.Kinds = sub.Kinds;
latestSub.Search = sub.Search;
latestSub.Limit = 1;
latestSub.Since = Math.floor(new Date().getTime() / 1000);
sub.AddSubscription(latestSub);
}
}
return sub;
}, [until, since, options.method, pref, createSub]);
const main = useSubscription(sub, { leaveOpen: true, cache: true });
const subRealtime = useMemo(() => {
let subLatest = createSub();
if (subLatest && !pref.autoShowLatest) {
subLatest.Id = `${subLatest.Id}:latest`;
subLatest.Limit = 1;
subLatest.Since = Math.floor(new Date().getTime() / 1000);
}
return subLatest;
}, [pref, createSub]);
const latest = useSubscription(subRealtime, { leaveOpen: true, cache: false });
const subNext = useMemo(() => {
let sub: Subscriptions | undefined;
if (trackingEvents.length > 0 && pref.enableReactions) {
sub = new Subscriptions();
sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.ZapReceipt]);
sub.ETags = new Set(trackingEvents);
}
return sub ?? null;
}, [trackingEvents, pref, subject.type]);
const others = useSubscription(subNext, { leaveOpen: true, cache: true });
const subParents = useMemo(() => {
if (trackingParentEvents.length > 0) {
let parents = new Subscriptions();
parents.Id = `timeline-parent:${subject.type}`;
parents.Ids = new Set(trackingParentEvents);
return parents;
}
return null;
}, [trackingParentEvents, subject.type]);
const parent = useSubscription(subParents);
useEffect(() => {
if (main.store.notes.length > 0) {
setTrackingEvent(s => {
let ids = main.store.notes.map(a => a.id);
if (ids.some(a => !s.includes(a))) {
return Array.from(new Set([...s, ...ids]));
}
return s;
});
let reposts = main.store.notes
.filter(a => a.kind === EventKind.Repost && a.content === "")
.map(a => a.tags.find(b => b[0] === "e"))
.filter(a => a)
.map(a => a![1]);
if (reposts.length > 0) {
setTrackingParentEvents(s => {
if (reposts.some(a => !s.includes(a))) {
let temp = new Set([...s, ...reposts]);
return Array.from(temp);
}
return s;
})
}
}
}, [main.store]);
return {
main: main.store,
related: others.store,
latest: latest.store,
parent: parent.store,
loadMore: () => {
console.debug("Timeline load more!")
if (options.method === "LIMIT_UNTIL") {
let oldest = main.store.notes.reduce((acc, v) => acc = v.created_at < acc ? v.created_at : acc, unixNow());
setUntil(oldest);
} else {
setUntil(s => s - window);
setSince(s => s - window);
}
},
showLatest: () => {
main.append(latest.store.notes);
latest.clear();
}
};
return {
main: main.store,
related: others.store,
latest: latest.store,
parent: parent.store,
loadMore: () => {
console.debug("Timeline load more!");
if (options.method === "LIMIT_UNTIL") {
let oldest = main.store.notes.reduce(
(acc, v) => (acc = v.created_at < acc ? v.created_at : acc),
unixNow()
);
setUntil(oldest);
} else {
setUntil((s) => s - window);
setSince((s) => s - window);
}
},
showLatest: () => {
main.append(latest.store.notes);
latest.clear();
},
};
}

View File

@ -5,13 +5,13 @@ import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription from "./Subscription";
export default function useZapsFeed(pubkey: HexKey) {
const sub = useMemo(() => {
let x = new Subscriptions();
x.Id = `zaps:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ZapReceipt]);
x.PTags = new Set([pubkey]);
return x;
}, [pubkey]);
const sub = useMemo(() => {
let x = new Subscriptions();
x.Id = `zaps:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ZapReceipt]);
x.PTags = new Set([pubkey]);
return x;
}, [pubkey]);
return useSubscription(sub, { leaveOpen: true, cache: true });
return useSubscription(sub, { leaveOpen: true, cache: true });
}

View File

@ -16,7 +16,7 @@ function useHorizontalScroll() {
return () => el.removeEventListener("wheel", onWheel);
}
}, []);
return elRef as LegacyRef<HTMLDivElement> | undefined
return elRef as LegacyRef<HTMLDivElement> | undefined;
}
export default useHorizontalScroll;

View File

@ -5,74 +5,93 @@ import { HexKey } from "Nostr";
import useEventPublisher from "Feed/EventPublisher";
import { setMuted, setBlocked } from "State/Login";
export default function useModeration() {
const dispatch = useDispatch()
const { blocked, muted } = useSelector((s: RootState) => s.login)
const publisher = useEventPublisher()
const dispatch = useDispatch();
const { blocked, muted } = useSelector((s: RootState) => s.login);
const publisher = useEventPublisher();
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
try {
const ev = await publisher.muted(pub, priv)
const ev = await publisher.muted(pub, priv);
console.debug(ev);
publisher.broadcast(ev)
publisher.broadcast(ev);
} catch (error) {
console.debug("Couldn't change mute list")
console.debug("Couldn't change mute list");
}
}
function isMuted(id: HexKey) {
return muted.includes(id) || blocked.includes(id)
return muted.includes(id) || blocked.includes(id);
}
function isBlocked(id: HexKey) {
return blocked.includes(id)
return blocked.includes(id);
}
function unmute(id: HexKey) {
const newMuted = muted.filter(p => p !== id)
dispatch(setMuted({
createdAt: new Date().getTime(),
keys: newMuted
}))
setMutedList(newMuted, blocked)
const newMuted = muted.filter((p) => p !== id);
dispatch(
setMuted({
createdAt: new Date().getTime(),
keys: newMuted,
})
);
setMutedList(newMuted, blocked);
}
function unblock(id: HexKey) {
const newBlocked = blocked.filter(p => p !== id)
dispatch(setBlocked({
createdAt: new Date().getTime(),
keys: newBlocked
}))
setMutedList(muted, newBlocked)
const newBlocked = blocked.filter((p) => p !== id);
dispatch(
setBlocked({
createdAt: new Date().getTime(),
keys: newBlocked,
})
);
setMutedList(muted, newBlocked);
}
function mute(id: HexKey) {
const newMuted = muted.includes(id) ? muted : muted.concat([id])
setMutedList(newMuted, blocked)
dispatch(setMuted({
createdAt: new Date().getTime(),
keys: newMuted
}))
const newMuted = muted.includes(id) ? muted : muted.concat([id]);
setMutedList(newMuted, blocked);
dispatch(
setMuted({
createdAt: new Date().getTime(),
keys: newMuted,
})
);
}
function block(id: HexKey) {
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id])
setMutedList(muted, newBlocked)
dispatch(setBlocked({
createdAt: new Date().getTime(),
keys: newBlocked
}))
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]);
setMutedList(muted, newBlocked);
dispatch(
setBlocked({
createdAt: new Date().getTime(),
keys: newBlocked,
})
);
}
function muteAll(ids: HexKey[]) {
const newMuted = Array.from(new Set(muted.concat(ids)))
setMutedList(newMuted, blocked)
dispatch(setMuted({
createdAt: new Date().getTime(),
keys: newMuted
}))
const newMuted = Array.from(new Set(muted.concat(ids)));
setMutedList(newMuted, blocked);
dispatch(
setMuted({
createdAt: new Date().getTime(),
keys: newMuted,
})
);
}
return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked }
return {
muted,
mute,
muteAll,
unmute,
isMuted,
blocked,
block,
unblock,
isBlocked,
};
}

View File

@ -1,25 +1,25 @@
import { useEffect } from "react";
declare global {
interface Window {
webln?: {
enabled: boolean,
enable: () => Promise<void>,
sendPayment: (pr: string) => Promise<any>
}
}
interface Window {
webln?: {
enabled: boolean;
enable: () => Promise<void>;
sendPayment: (pr: string) => Promise<any>;
};
}
}
export default function useWebln(enable = true) {
const maybeWebLn = "webln" in window ? window.webln : null
const maybeWebLn = "webln" in window ? window.webln : null;
useEffect(() => {
if (maybeWebLn && !maybeWebLn.enabled && enable) {
maybeWebLn.enable().catch((error) => {
console.debug("Couldn't enable WebLN")
})
console.debug("Couldn't enable WebLN");
});
}
}, [maybeWebLn, enable])
}, [maybeWebLn, enable]);
return maybeWebLn
return maybeWebLn;
}

View File

@ -1,9 +1,21 @@
const ArrowBack = () => {
return (
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6667 6.5H1.33334M1.33334 6.5L6.33334 11.5M1.33334 6.5L6.33334 1.5" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
<svg
width="16"
height="13"
viewBox="0 0 16 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.6667 6.5H1.33334M1.33334 6.5L6.33334 11.5M1.33334 6.5L6.33334 1.5"
stroke="currentColor"
strokeWidth="1.66667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
);
};
export default ArrowBack
export default ArrowBack;

View File

@ -1,9 +1,21 @@
const ArrowFront = () => {
return (
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 13L7 7L1 1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 13L7 7L1 1"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default ArrowFront
export default ArrowFront;

View File

@ -1,11 +1,23 @@
import IconProps from './IconProps'
import IconProps from "./IconProps";
const Attachment = (props: IconProps) => {
return (
<svg width="21" height="22" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.1525 9.89945L10.1369 18.9151C8.08662 20.9653 4.7625 20.9653 2.71225 18.9151C0.661997 16.8648 0.661998 13.5407 2.71225 11.4904L11.7279 2.47483C13.0947 1.108 15.3108 1.108 16.6776 2.47483C18.0444 3.84167 18.0444 6.05775 16.6776 7.42458L8.01555 16.0866C7.33213 16.7701 6.22409 16.7701 5.54068 16.0866C4.85726 15.4032 4.85726 14.2952 5.54068 13.6118L13.1421 6.01037" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<svg
width="21"
height="22"
viewBox="0 0 21 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.1525 9.89945L10.1369 18.9151C8.08662 20.9653 4.7625 20.9653 2.71225 18.9151C0.661997 16.8648 0.661998 13.5407 2.71225 11.4904L11.7279 2.47483C13.0947 1.108 15.3108 1.108 16.6776 2.47483C18.0444 3.84167 18.0444 6.05775 16.6776 7.42458L8.01555 16.0866C7.33213 16.7701 6.22409 16.7701 5.54068 16.0866C4.85726 15.4032 4.85726 14.2952 5.54068 13.6118L13.1421 6.01037"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
);
};
export default Attachment
export default Attachment;

View File

@ -1,9 +1,21 @@
const Bell = () => {
return (
<svg width="20" height="23" viewBox="0 0 20 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.35419 20.5C8.05933 21.1224 8.98557 21.5 10 21.5C11.0145 21.5 11.9407 21.1224 12.6458 20.5M16 7.5C16 5.9087 15.3679 4.38258 14.2427 3.25736C13.1174 2.13214 11.5913 1.5 10 1.5C8.40872 1.5 6.8826 2.13214 5.75738 3.25736C4.63216 4.38258 4.00002 5.9087 4.00002 7.5C4.00002 10.5902 3.22049 12.706 2.34968 14.1054C1.61515 15.2859 1.24788 15.8761 1.26134 16.0408C1.27626 16.2231 1.31488 16.2926 1.46179 16.4016C1.59448 16.5 2.19261 16.5 3.38887 16.5H16.6112C17.8074 16.5 18.4056 16.5 18.5382 16.4016C18.6852 16.2926 18.7238 16.2231 18.7387 16.0408C18.7522 15.8761 18.3849 15.2859 17.6504 14.1054C16.7795 12.706 16 10.5902 16 7.5Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<svg
width="20"
height="23"
viewBox="0 0 20 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.35419 20.5C8.05933 21.1224 8.98557 21.5 10 21.5C11.0145 21.5 11.9407 21.1224 12.6458 20.5M16 7.5C16 5.9087 15.3679 4.38258 14.2427 3.25736C13.1174 2.13214 11.5913 1.5 10 1.5C8.40872 1.5 6.8826 2.13214 5.75738 3.25736C4.63216 4.38258 4.00002 5.9087 4.00002 7.5C4.00002 10.5902 3.22049 12.706 2.34968 14.1054C1.61515 15.2859 1.24788 15.8761 1.26134 16.0408C1.27626 16.2231 1.31488 16.2926 1.46179 16.4016C1.59448 16.5 2.19261 16.5 3.38887 16.5H16.6112C17.8074 16.5 18.4056 16.5 18.5382 16.4016C18.6852 16.2926 18.7238 16.2231 18.7387 16.0408C18.7522 15.8761 18.3849 15.2859 17.6504 14.1054C16.7795 12.706 16 10.5902 16 7.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
);
};
export default Bell
export default Bell;

View File

@ -1,11 +1,24 @@
import IconProps from "./IconProps"
import IconProps from "./IconProps";
const Check = (props: IconProps) => {
return (
<svg width="18" height="13" viewBox="0 0 18 13" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M17 1L6 12L1 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<svg
width="18"
height="13"
viewBox="0 0 18 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M17 1L6 12L1 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
);
};
export default Check
export default Check;

View File

@ -2,10 +2,23 @@ import IconProps from "./IconProps";
const Close = (props: IconProps) => {
return (
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M7.33332 0.666992L0.666656 7.33366M0.666656 0.666992L7.33332 7.33366" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
<svg
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M7.33332 0.666992L0.666656 7.33366M0.666656 0.666992L7.33332 7.33366"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default Close
export default Close;

View File

@ -1,11 +1,24 @@
import IconProps from './IconProps'
import IconProps from "./IconProps";
const Copy = (props: IconProps) => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M5.33331 5.33398V3.46732C5.33331 2.72058 5.33331 2.34721 5.47864 2.062C5.60647 1.81111 5.81044 1.60714 6.06133 1.47931C6.34654 1.33398 6.71991 1.33398 7.46665 1.33398H12.5333C13.28 1.33398 13.6534 1.33398 13.9386 1.47931C14.1895 1.60714 14.3935 1.81111 14.5213 2.062C14.6666 2.34721 14.6666 2.72058 14.6666 3.46732V8.53398C14.6666 9.28072 14.6666 9.65409 14.5213 9.9393C14.3935 10.1902 14.1895 10.3942 13.9386 10.522C13.6534 10.6673 13.28 10.6673 12.5333 10.6673H10.6666M3.46665 14.6673H8.53331C9.28005 14.6673 9.65342 14.6673 9.93863 14.522C10.1895 14.3942 10.3935 14.1902 10.5213 13.9393C10.6666 13.6541 10.6666 13.2807 10.6666 12.534V7.46732C10.6666 6.72058 10.6666 6.34721 10.5213 6.062C10.3935 5.81111 10.1895 5.60714 9.93863 5.47931C9.65342 5.33398 9.28005 5.33398 8.53331 5.33398H3.46665C2.71991 5.33398 2.34654 5.33398 2.06133 5.47931C1.81044 5.60714 1.60647 5.81111 1.47864 6.062C1.33331 6.34721 1.33331 6.72058 1.33331 7.46732V12.534C1.33331 13.2807 1.33331 13.6541 1.47864 13.9393C1.60647 14.1902 1.81044 14.3942 2.06133 14.522C2.34654 14.6673 2.71991 14.6673 3.46665 14.6673Z" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5.33331 5.33398V3.46732C5.33331 2.72058 5.33331 2.34721 5.47864 2.062C5.60647 1.81111 5.81044 1.60714 6.06133 1.47931C6.34654 1.33398 6.71991 1.33398 7.46665 1.33398H12.5333C13.28 1.33398 13.6534 1.33398 13.9386 1.47931C14.1895 1.60714 14.3935 1.81111 14.5213 2.062C14.6666 2.34721 14.6666 2.72058 14.6666 3.46732V8.53398C14.6666 9.28072 14.6666 9.65409 14.5213 9.9393C14.3935 10.1902 14.1895 10.3942 13.9386 10.522C13.6534 10.6673 13.28 10.6673 12.5333 10.6673H10.6666M3.46665 14.6673H8.53331C9.28005 14.6673 9.65342 14.6673 9.93863 14.522C10.1895 14.3942 10.3935 14.1902 10.5213 13.9393C10.6666 13.6541 10.6666 13.2807 10.6666 12.534V7.46732C10.6666 6.72058 10.6666 6.34721 10.5213 6.062C10.3935 5.81111 10.1895 5.60714 9.93863 5.47931C9.65342 5.33398 9.28005 5.33398 8.53331 5.33398H3.46665C2.71991 5.33398 2.34654 5.33398 2.06133 5.47931C1.81044 5.60714 1.60647 5.81111 1.47864 6.062C1.33331 6.34721 1.33331 6.72058 1.33331 7.46732V12.534C1.33331 13.2807 1.33331 13.6541 1.47864 13.9393C1.60647 14.1902 1.81044 14.3942 2.06133 14.522C2.34654 14.6673 2.71991 14.6673 3.46665 14.6673Z"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default Copy
export default Copy;

View File

@ -1,9 +1,21 @@
const Dislike = () => {
return (
<svg width="19" height="20" viewBox="0 0 19 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.1667 1.66667V10.8333M17.3333 8.16667V4.33334C17.3333 3.39992 17.3333 2.93321 17.1517 2.57669C16.9919 2.26308 16.7369 2.00812 16.4233 1.84833C16.0668 1.66667 15.6001 1.66667 14.6667 1.66667H5.76501C4.54711 1.66667 3.93816 1.66667 3.44632 1.88953C3.01284 2.08595 2.64442 2.40202 2.38437 2.8006C2.08931 3.25283 1.99672 3.8547 1.81153 5.05844L1.37563 7.89178C1.13137 9.47943 1.00925 10.2733 1.24484 10.8909C1.45162 11.4331 1.84054 11.8864 2.34494 12.1732C2.91961 12.5 3.72278 12.5 5.32912 12.5H6C6.46671 12.5 6.70007 12.5 6.87833 12.5908C7.03513 12.6707 7.16261 12.7982 7.24251 12.955C7.33334 13.1333 7.33334 13.3666 7.33334 13.8333V16.2785C7.33334 17.4133 8.25333 18.3333 9.3882 18.3333C9.65889 18.3333 9.90419 18.1739 10.0141 17.9266L12.8148 11.6252C12.9421 11.3385 13.0058 11.1952 13.1065 11.0902C13.1955 10.9973 13.3048 10.9263 13.4258 10.8827C13.5627 10.8333 13.7195 10.8333 14.0332 10.8333H14.6667C15.6001 10.8333 16.0668 10.8333 16.4233 10.6517C16.7369 10.4919 16.9919 10.2369 17.1517 9.92332C17.3333 9.5668 17.3333 9.10009 17.3333 8.16667Z" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
<svg
width="19"
height="20"
viewBox="0 0 19 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.1667 1.66667V10.8333M17.3333 8.16667V4.33334C17.3333 3.39992 17.3333 2.93321 17.1517 2.57669C16.9919 2.26308 16.7369 2.00812 16.4233 1.84833C16.0668 1.66667 15.6001 1.66667 14.6667 1.66667H5.76501C4.54711 1.66667 3.93816 1.66667 3.44632 1.88953C3.01284 2.08595 2.64442 2.40202 2.38437 2.8006C2.08931 3.25283 1.99672 3.8547 1.81153 5.05844L1.37563 7.89178C1.13137 9.47943 1.00925 10.2733 1.24484 10.8909C1.45162 11.4331 1.84054 11.8864 2.34494 12.1732C2.91961 12.5 3.72278 12.5 5.32912 12.5H6C6.46671 12.5 6.70007 12.5 6.87833 12.5908C7.03513 12.6707 7.16261 12.7982 7.24251 12.955C7.33334 13.1333 7.33334 13.3666 7.33334 13.8333V16.2785C7.33334 17.4133 8.25333 18.3333 9.3882 18.3333C9.65889 18.3333 9.90419 18.1739 10.0141 17.9266L12.8148 11.6252C12.9421 11.3385 13.0058 11.1952 13.1065 11.0902C13.1955 10.9973 13.3048 10.9263 13.4258 10.8827C13.5627 10.8333 13.7195 10.8333 14.0332 10.8333H14.6667C15.6001 10.8333 16.0668 10.8333 16.4233 10.6517C16.7369 10.4919 16.9919 10.2369 17.1517 9.92332C17.3333 9.5668 17.3333 9.10009 17.3333 8.16667Z"
stroke="currentColor"
strokeWidth="1.66667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
);
};
export default Dislike
export default Dislike;

Some files were not shown because too many files have changed in this diff Show More