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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
182 changed files with 8686 additions and 6861 deletions

View File

@ -1,10 +1,9 @@
--- ---
name: Bug report name: Bug report
about: Create a report to help us improve about: Create a report to help us improve
title: '' title: ""
labels: '' labels: ""
assignees: '' assignees: ""
--- ---
**Describe the bug** **Describe the bug**
@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari] - OS: [e.g. iOS]
- Version [e.g. 22] - Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1] - Device: [e.g. iPhone6]
- Browser [e.g. stock browser, safari] - OS: [e.g. iOS8.1]
- Version [e.g. 22] - Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

View File

@ -1,10 +1,9 @@
--- ---
name: Feature request name: Feature request
about: Suggest an idea for this project about: Suggest an idea for this project
title: '' title: ""
labels: '' labels: ""
assignees: '' assignees: ""
--- ---
**Is your feature request related to a problem? Please describe.** **Is your feature request related to a problem? Please describe.**

1
.prettierrc.json Normal file
View File

@ -0,0 +1 @@
{}

14
d.ts
View File

@ -1,14 +1,14 @@
declare module "*.jpg" { declare module "*.jpg" {
const value: any const value: any;
export default value export default value;
} }
declare module "*.svg" { declare module "*.svg" {
const value: any const value: any;
export default value export default value;
} }
declare module "*.webp" { declare module "*.webp" {
const value: any const value: any;
export default value export default value;
} }

View File

@ -72,5 +72,8 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"prettier": "2.8.3"
} }
} }

View File

@ -1,23 +1,26 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<meta name="theme-color" content="#000000" />
<meta name="description" content="Fast nostr web ui" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;"
/>
<head> <link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
<meta charset="utf-8" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <title>snort.social - Nostr interface</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> </head>
<meta name="theme-color" content="#000000" />
<meta name="description" content="Fast nostr web ui" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>snort.social - Nostr interface</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html> </html>

View File

@ -18,12 +18,14 @@ export const VoidCatHost = "https://void.cat";
/** /**
* Kierans pubkey * Kierans pubkey
*/ */
export const KieranPubKey = "npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49"; export const KieranPubKey =
"npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
/** /**
* Official snort account * Official snort account
*/ */
export const SnortPubKey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws"; export const SnortPubKey =
"npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
/** /**
* Websocket re-connect timeout * Websocket re-connect timeout
@ -33,59 +35,61 @@ export const DefaultConnectTimeout = 2000;
/** /**
* How long profile cache should be considered valid for * 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 * Default bootstrap relays
*/ */
export const DefaultRelays = new Map<string, RelaySettings>([ export const DefaultRelays = new Map<string, RelaySettings>([
["wss://relay.snort.social", { read: true, write: true }], ["wss://relay.snort.social", { read: true, write: true }],
["wss://eden.nostr.land", { read: true, write: true }], ["wss://eden.nostr.land", { read: true, write: true }],
["wss://atlas.nostr.land", { read: true, write: true }] ["wss://atlas.nostr.land", { read: true, write: true }],
]); ]);
/** /**
* Default search relays * Default search relays
*/ */
export const SearchRelays = new Map<string, RelaySettings>([ 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 * List of recommended follows for new users
*/ */
export const RecommendedFollows = [ export const RecommendedFollows = [
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us "020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55 "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz "e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri
"A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor "A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor
"E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK "E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK
"C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers "C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers
"85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston "85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston
"C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut "C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut
"83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth "83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth
"3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss "3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss
"472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent "472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov "1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol
]; ];
/** /**
* Regex to match email address * 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 * 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 * Extract file extensions regex
@ -105,12 +109,14 @@ export const InvoiceRegex = /(lnbc\w+)/i;
/** /**
* YouTube URL regex * 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 * Tweet Regex
*/ */
export const TweetUrlRegex = /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/ export const TweetUrlRegex =
/https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
/** /**
* Hashtag regex * Hashtag regex
@ -125,12 +131,15 @@ export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
/** /**
* SoundCloud regex * 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 * 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 { MetadataCache } from "State/Users";
import { hexToBech32 } from "Util"; import { hexToBech32 } from "Util";
export const NAME = 'snortDB' export const NAME = "snortDB";
export const VERSION = 3 export const VERSION = 3;
export interface SubCache { export interface SubCache {
id: string, id: string;
ids: u256[], ids: u256[];
until?: number, until?: number;
since?: number, since?: number;
} }
const STORES = { const STORES = {
users: '++pubkey, name, display_name, picture, nip05, npub', users: "++pubkey, name, display_name, picture, nip05, npub",
events: '++id, pubkey, created_at', events: "++id, pubkey, created_at",
feeds: '++id' feeds: "++id",
} };
export class SnortDB extends Dexie { export class SnortDB extends Dexie {
users!: Table<MetadataCache>; users!: Table<MetadataCache>;
@ -26,11 +26,16 @@ export class SnortDB extends Dexie {
constructor() { constructor() {
super(NAME); super(NAME);
this.version(VERSION).stores(STORES).upgrade(async tx => { this.version(VERSION)
await tx.table("users").toCollection().modify(user => { .stores(STORES)
user.npub = hexToBech32("npub", user.pubkey) .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) { export default function AsyncButton(props: any) {
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
async function handle(e : any) { async function handle(e: any) {
if(loading) return; if (loading) return;
setLoading(true); setLoading(true);
try { try {
if (typeof props.onClick === "function") { if (typeof props.onClick === "function") {
let f = props.onClick(e); let f = props.onClick(e);
if (f instanceof Promise) { if (f instanceof Promise) {
await f; await f;
}
}
}
finally {
setLoading(false);
} }
}
} finally {
setLoading(false);
} }
}
return ( return (
<button type="button" disabled={loading} {...props} onClick={(e) => handle(e)}> <button
{props.children} type="button"
</button> disabled={loading}
) {...props}
} onClick={(e) => handle(e)}
>
{props.children}
</button>
);
}

View File

@ -1,19 +1,19 @@
.avatar { .avatar {
border-radius: 50%; border-radius: 50%;
height: 210px; height: 210px;
width: 210px; width: 210px;
background-image: var(--img-url); background-image: var(--img-url);
border: 1px solid transparent; border: 1px solid transparent;
background-origin: border-box; background-origin: border-box;
background-clip: content-box, border-box; background-clip: content-box, border-box;
background-size: cover; background-size: cover;
box-sizing: border-box; box-sizing: border-box;
} }
.avatar[data-domain="snort.social"] { .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"] { .avatar[data-domain="strike.army"] {
background-image: var(--img-url), var(--strike-army-gradient); 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 type { UserMetadata } from "Nostr";
import useImgProxy from "Feed/ImgProxy"; 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 [url, setUrl] = useState<string>(Nostrich);
const { proxy } = useImgProxy(); const { proxy } = useImgProxy();
useEffect(() => { useEffect(() => {
if (user?.picture) { if (user?.picture) {
proxy(user.picture, 120) proxy(user.picture, 120)
.then(a => setUrl(a)) .then((a) => setUrl(a))
.catch(console.warn); .catch(console.warn);
} }
}, [user]); }, [user]);
const backgroundImage = `url(${url})` const backgroundImage = `url(${url})`;
const style = { '--img-url': backgroundImage } as CSSProperties const style = { "--img-url": backgroundImage } as CSSProperties;
const domain = user?.nip05 && user.nip05.split('@')[1] const domain = user?.nip05 && user.nip05.split("@")[1];
return ( return (
<div <div
{...rest} {...rest}
style={style} style={style}
className="avatar" className="avatar"
data-domain={domain?.toLowerCase()} data-domain={domain?.toLowerCase()}
> ></div>
</div> );
) };
}
export default Avatar export default Avatar;

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useSelector } from "react-redux"; 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 MuteButton from "Element/MuteButton";
import BlockButton from "Element/BlockButton"; import BlockButton from "Element/BlockButton";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
@ -9,31 +10,45 @@ import useMutedFeed, { getMuted } from "Feed/MuteList";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
interface BlockListProps { interface BlockListProps {
variant: "muted" | "blocked" variant: "muted" | "blocked";
} }
export default function BlockList({ variant }: BlockListProps) { export default function BlockList({ variant }: BlockListProps) {
const { publicKey } = useSelector((s: RootState) => s.login) const { publicKey } = useSelector((s: RootState) => s.login);
const { blocked, muted } = useModeration(); const { blocked, muted } = useModeration();
return ( return (
<div className="main-content"> <div className="main-content">
{variant === "muted" && ( {variant === "muted" && (
<> <>
<h4>{muted.length} muted</h4> <h4>{muted.length} muted</h4>
{muted.map(a => { {muted.map((a) => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} /> return (
})} <ProfilePreview
</> actions={<MuteButton pubkey={a} />}
)} pubkey={a}
{variant === "blocked" && ( options={{ about: false }}
<> key={a}
<h4>{blocked.length} blocked</h4> />
{blocked.map(a => { );
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} /> })}
})} </>
</> )}
)} {variant === "blocked" && (
</div> <>
) <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"; import ShowMore from "Element/ShowMore";
interface CollapsedProps { interface CollapsedProps {
text?: string text?: string;
children: ReactNode children: ReactNode;
collapsed: boolean collapsed: boolean;
setCollapsed(b: boolean): void setCollapsed(b: boolean): void;
} }
const Collapsed = ({ text, children, collapsed, setCollapsed }: CollapsedProps) => { const Collapsed = ({
text,
children,
collapsed,
setCollapsed,
}: CollapsedProps) => {
return collapsed ? ( return collapsed ? (
<div className="collapsed"> <div className="collapsed">
<ShowMore text={text} onClick={() => setCollapsed(false)} /> <ShowMore text={text} onClick={() => setCollapsed(false)} />
</div> </div>
) : ( ) : (
<div className="uncollapsed"> <div className="uncollapsed">{children}</div>
{children} );
</div> };
)
}
export default Collapsed export default Collapsed;

View File

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

View File

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

View File

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

View File

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

View File

@ -3,24 +3,35 @@ import { HexKey } from "Nostr";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
export interface FollowListBaseProps { export interface FollowListBaseProps {
pubkeys: HexKey[], pubkeys: HexKey[];
title?: string title?: string;
} }
export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) { export default function FollowListBase({
const publisher = useEventPublisher(); pubkeys,
title,
}: FollowListBaseProps) {
const publisher = useEventPublisher();
async function followAll() { async function followAll() {
let ev = await publisher.addFollow(pubkeys); let ev = await publisher.addFollow(pubkeys);
publisher.broadcast(ev); publisher.broadcast(ev);
} }
return ( return (
<div className="main-content"> <div className="main-content">
<div className="flex mt10 mb10"> <div className="flex mt10 mb10">
<div className="f-grow bold">{title}</div> <div className="f-grow bold">{title}</div>
<button className="transparent" type="button" onClick={() => followAll()}>Follow All</button> <button
</div> className="transparent"
{pubkeys?.map(a => <ProfilePreview pubkey={a} key={a} />)} type="button"
</div> 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"; import FollowListBase from "Element/FollowListBase";
export interface FollowersListProps { export interface FollowersListProps {
pubkey: HexKey pubkey: HexKey;
} }
export default function FollowersList({ pubkey }: FollowersListProps) { export default function FollowersList({ pubkey }: FollowersListProps) {
const feed = useFollowersFeed(pubkey); const feed = useFollowersFeed(pubkey);
const pubkeys = useMemo(() => { const pubkeys = useMemo(() => {
let contactLists = feed?.store.notes.filter(a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)); let contactLists = feed?.store.notes.filter(
return [...new Set(contactLists?.map(a => a.pubkey))]; (a) =>
}, [feed]); 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 useFollowsFeed from "Feed/FollowsFeed";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import FollowListBase from "Element/FollowListBase"; import FollowListBase from "Element/FollowListBase";
import { getFollowers} from "Feed/FollowsFeed"; import { getFollowers } from "Feed/FollowsFeed";
export interface FollowsListProps { export interface FollowsListProps {
pubkey: HexKey pubkey: HexKey;
} }
export default function FollowsList({ pubkey }: FollowsListProps) { export default function FollowsList({ pubkey }: FollowsListProps) {
const feed = useFollowsFeed(pubkey); const feed = useFollowsFeed(pubkey);
const pubkeys = useMemo(() => { const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey); return getFollowers(feed.store, pubkey);
}, [feed]); }, [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 { .follows-you {
color: var(--font-secondary-color); color: var(--font-secondary-color);
font-size: var(--font-size-tiny); font-size: var(--font-size-tiny);
margin-left: .2em; margin-left: 0.2em;
font-weight: normal font-weight: normal;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,34 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
export default function LoadMore({ onLoadMore, shouldLoadMore, children }: { onLoadMore: () => void, shouldLoadMore: boolean, children?: React.ReactNode }) { export default function LoadMore({
const { ref, inView } = useInView(); onLoadMore,
const [tick, setTick] = useState<number>(0); shouldLoadMore,
children,
}: {
onLoadMore: () => void;
shouldLoadMore: boolean;
children?: React.ReactNode;
}) {
const { ref, inView } = useInView();
const [tick, setTick] = useState<number>(0);
useEffect(() => { useEffect(() => {
if (inView === true && shouldLoadMore === true) { if (inView === true && shouldLoadMore === true) {
onLoadMore(); onLoadMore();
} }
}, [inView, shouldLoadMore, tick]); }, [inView, shouldLoadMore, tick]);
useEffect(() => { useEffect(() => {
let t = setInterval(() => { let t = setInterval(() => {
setTick(x => x += 1); setTick((x) => (x += 1));
}, 500); }, 500);
return () => clearInterval(t); 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"; import { logout } from "State/Login";
export default function LogoutButton(){ export default function LogoutButton() {
const dispatch = useDispatch() const dispatch = useDispatch();
const navigate = useNavigate() const navigate = useNavigate();
return ( return (
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}> <button
className="secondary"
type="button"
onClick={() => {
dispatch(logout());
navigate("/");
}}
>
Logout Logout
</button> </button>
) );
} }

View File

@ -5,17 +5,21 @@ import { HexKey } from "Nostr";
import { hexToBech32, profileLink } from "Util"; import { hexToBech32, profileLink } from "Util";
export default function Mention({ pubkey }: { pubkey: HexKey }) { export default function Mention({ pubkey }: { pubkey: HexKey }) {
const user = useUserProfile(pubkey) const user = useUserProfile(pubkey);
const name = useMemo(() => { const name = useMemo(() => {
let name = hexToBech32("npub", pubkey).substring(0, 12); let name = hexToBech32("npub", pubkey).substring(0, 12);
if ((user?.display_name?.length ?? 0) > 0) { if ((user?.display_name?.length ?? 0) > 0) {
name = user!.display_name!; name = user!.display_name!;
} else if ((user?.name?.length ?? 0) > 0) { } else if ((user?.name?.length ?? 0) > 0) {
name = user!.name!; name = user!.name!;
} }
return name; return name;
}, [user, pubkey]); }, [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 { useSelector } from "react-redux";
import { RootState } from "State/Store"; 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( export default MixCloudEmbed;
<>
<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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,17 @@
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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"; import { HexKey } from "Nostr";
interface NostrJson { interface NostrJson {
names: Record<string, string> names: Record<string, string>;
} }
async function fetchNip05Pubkey(name: string, domain: string) { async function fetchNip05Pubkey(name: string, domain: string) {
@ -15,54 +19,60 @@ async function fetchNip05Pubkey(name: string, domain: string) {
return undefined; return undefined;
} }
try { 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 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 n.toLowerCase() === name.toLowerCase();
}); });
return match ? data.names[match] : undefined; return match ? data.names[match] : undefined;
} catch (error) { } catch (error) {
return undefined return undefined;
} }
} }
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000 const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000;
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000 const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000;
export function useIsVerified(pubkey: HexKey, nip05?: string) { export function useIsVerified(pubkey: HexKey, nip05?: string) {
const [name, domain] = nip05 ? nip05.split('@') : [] const [name, domain] = nip05 ? nip05.split("@") : [];
const { isError, isSuccess, data } = useQuery( const { isError, isSuccess, data } = useQuery(
['nip05', nip05], ["nip05", nip05],
() => fetchNip05Pubkey(name, domain), () => fetchNip05Pubkey(name, domain),
{ {
retry: false, retry: false,
retryOnMount: false, retryOnMount: false,
cacheTime: VERIFICATION_CACHE_TIME, cacheTime: VERIFICATION_CACHE_TIME,
staleTime: VERIFICATION_STALE_TIMEOUT, staleTime: VERIFICATION_STALE_TIMEOUT,
}, }
) );
const isVerified = isSuccess && data === pubkey const isVerified = isSuccess && data === pubkey;
const cantVerify = isSuccess && data !== pubkey const cantVerify = isSuccess && data !== pubkey;
return { isVerified, couldNotVerify: isError || cantVerify } return { isVerified, couldNotVerify: isError || cantVerify };
} }
export interface Nip05Params { export interface Nip05Params {
nip05?: string, nip05?: string;
pubkey: HexKey pubkey: HexKey;
} }
const Nip05 = (props: Nip05Params) => { const Nip05 = (props: Nip05Params) => {
const [name, domain] = props.nip05 ? props.nip05.split('@') : [] const [name, domain] = props.nip05 ? props.nip05.split("@") : [];
const isDefaultUser = name === '_' const isDefaultUser = name === "_";
const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05) const { isVerified, couldNotVerify } = useIsVerified(
props.pubkey,
props.nip05
);
return ( return (
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}> <div
{!isDefaultUser && ( className={`flex nip05${couldNotVerify ? " failed" : ""}`}
<div className="nick"> onClick={(ev) => ev.stopPropagation()}
{`${name}@`} >
</div> {!isDefaultUser && <div className="nick">{`${name}@`}</div>}
)}
<span className="domain" data-domain={domain?.toLowerCase()}> <span className="domain" data-domain={domain?.toLowerCase()}>
{domain} {domain}
</span> </span>
@ -90,7 +100,7 @@ const Nip05 = (props: Nip05Params) => {
)} )}
</span> </span>
</div> </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 { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
ServiceProvider, ServiceProvider,
ServiceConfig, ServiceConfig,
ServiceError, ServiceError,
HandleAvailability, HandleAvailability,
ServiceErrorCode, ServiceErrorCode,
HandleRegisterResponse, HandleRegisterResponse,
CheckRegisterResponse CheckRegisterResponse,
} from "Nip05/ServiceProvider"; } from "Nip05/ServiceProvider";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import SendSats from "Element/SendSats"; import SendSats from "Element/SendSats";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import { useUserProfile }from "Feed/ProfileFeed"; import { useUserProfile } from "Feed/ProfileFeed";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { debounce, hexToBech32 } from "Util"; import { debounce, hexToBech32 } from "Util";
import { UserMetadata } from "Nostr"; import { UserMetadata } from "Nostr";
type Nip05ServiceProps = { type Nip05ServiceProps = {
name: string, name: string;
service: URL | string, service: URL | string;
about: JSX.Element, about: JSX.Element;
link: string, link: string;
supportLink: string supportLink: string;
}; };
type ReduxStore = any; type ReduxStore = any;
export default function Nip5Service(props: Nip05ServiceProps) { export default function Nip5Service(props: Nip05ServiceProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey); const pubkey = useSelector<ReduxStore, string>((s) => s.login.publicKey);
const user = useUserProfile(pubkey); const user = useUserProfile(pubkey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]); const svc = useMemo(
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>(); () => new ServiceProvider(props.service),
const [error, setError] = useState<ServiceError>(); [props.service]
const [handle, setHandle] = useState<string>(""); );
const [domain, setDomain] = useState<string>(""); const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
const [availabilityResponse, setAvailabilityResponse] = useState<HandleAvailability>(); const [error, setError] = useState<ServiceError>();
const [registerResponse, setRegisterResponse] = useState<HandleRegisterResponse>(); const [handle, setHandle] = useState<string>("");
const [showInvoice, setShowInvoice] = useState<boolean>(false); const [domain, setDomain] = useState<string>("");
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>(); 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(() => { useEffect(() => {
svc.GetConfig() svc
.then(a => { .GetConfig()
if ('error' in a) { .then((a) => {
setError(a as ServiceError) if ("error" in a) {
} else { setError(a as ServiceError);
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);
} else { } else {
setRegisterResponse(rsp); let svc = a as ServiceConfig;
setShowInvoice(true); 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) { let rsp = await svc.RegisterHandle(handle, domain, pubkey);
if (user) { if ("error" in rsp) {
let newProfile = { setError(rsp);
...user, } else {
nip05: `${handle}@${domain}` setRegisterResponse(rsp);
} as UserMetadata; setShowInvoice(true);
let ev = await publisher.metadata(newProfile);
publisher.broadcast(ev);
navigate("/settings");
}
} }
}
return ( async function updateProfile(handle: string, domain: string) {
<> if (user) {
<h3>{props.name}</h3> let newProfile = {
{props.about} ...user,
<p>Find out more info about {props.name} at <a href={props.link} target="_blank" rel="noreferrer">{props.link}</a></p> nip05: `${handle}@${domain}`,
{error && <b className="error">{error.error}</b>} } as UserMetadata;
{!registerStatus && <div className="flex mb10"> let ev = await publisher.metadata(newProfile);
<input type="text" placeholder="Handle" value={handle} onChange={(e) => setHandle(e.target.value.toLowerCase())} /> publisher.broadcast(ev);
&nbsp;@&nbsp; navigate("/settings");
<select value={domain} onChange={(e) => setDomain(e.target.value)}> }
{serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)} }
</select>
</div>} return (
{availabilityResponse?.available && !registerStatus && <div className="flex"> <>
<div className="mr10"> <h3>{props.name}</h3>
{availabilityResponse.quote?.price.toLocaleString()} sats<br /> {props.about}
<small>{availabilityResponse.quote?.data.type}</small> <p>
</div> Find out more info about {props.name} at{" "}
<input type="text" className="f-grow mr10" placeholder="pubkey" value={hexToBech32("npub", pubkey)} disabled /> <a href={props.link} target="_blank" rel="noreferrer">
<AsyncButton onClick={() => startBuy(handle, domain)}>Buy Now</AsyncButton> {props.link}
</div>} </a>
{availabilityResponse?.available === false && !registerStatus && <div className="flex"> </p>
<b className="error">Not available: {mapError(availabilityResponse.why!, availabilityResponse.reasonTag || null)}</b> {error && <b className="error">{error.error}</b>}
</div>} {!registerStatus && (
<SendSats <div className="flex mb10">
invoice={registerResponse?.invoice} <input
show={showInvoice} type="text"
onClose={() => setShowInvoice(false)} placeholder="Handle"
title={`Buying ${handle}@${domain}`} /> value={handle}
{registerStatus?.paid && <div className="flex f-col"> onChange={(e) => setHandle(e.target.value.toLowerCase())}
<h4>Order Paid!</h4> />
<p>Your new NIP-05 handle is: <code>{handle}@{domain}</code></p> &nbsp;@&nbsp;
<h3>Account Support</h3> <select value={domain} onChange={(e) => setDomain(e.target.value)}>
<p>Please make sure to save the following password in order to manage your handle in the future</p> {serviceConfig?.domains.map((a) => (
<Copy text={registerStatus.password} /> <option key={a.name}>{a.name}</option>
<p>Go to <a href={props.supportLink} target="_blank" rel="noreferrer">account page</a></p> ))}
<h4>Activate Now</h4> </select>
<AsyncButton onClick={() => updateProfile(handle, domain)}>Add to Profile</AsyncButton> </div>
</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; min-height: 110px;
} }
.note>.header .reply { .note > .header .reply {
font-size: 13px; font-size: 13px;
color: var(--font-secondary-color); color: var(--font-secondary-color);
} }
.note>.header .reply a { .note > .header .reply a {
color: var(--highlight); color: var(--highlight);
} }
.note>.header .reply a:hover { .note > .header .reply a:hover {
text-decoration-color: var(--highlight); text-decoration-color: var(--highlight);
} }
.note>.header>.info { .note > .header > .info {
font-size: var(--font-size); font-size: var(--font-size);
margin-left: 4px; margin-left: 4px;
white-space: nowrap; white-space: nowrap;
color: var(--font-secondary-color); color: var(--font-secondary-color);
} }
.note>.body { .note > .body {
margin-top: 4px; margin-top: 4px;
margin-bottom: 24px; margin-bottom: 24px;
padding-left: 56px; padding-left: 56px;
@ -33,7 +33,7 @@
overflow-y: visible; overflow-y: visible;
} }
.note>.footer { .note > .footer {
padding-left: 46px; padding-left: 46px;
} }
@ -49,7 +49,7 @@
} }
} }
.note>.footer .ctx-menu { .note > .footer .ctx-menu {
background-color: var(--note-bg); background-color: var(--note-bg);
color: var(--font-secondary-color); color: var(--font-secondary-color);
border: 1px solid var(--font-secondary-color); border: 1px solid var(--font-secondary-color);
@ -57,7 +57,7 @@
min-width: 0; min-width: 0;
} }
.note>.footer .ctx-menu li { .note > .footer .ctx-menu li {
display: grid; display: grid;
grid-template-columns: 2rem auto; grid-template-columns: 2rem auto;
} }
@ -66,11 +66,13 @@
color: var(--error); 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; cursor: pointer;
} }
.note>.note-creator { .note > .note-creator {
margin-top: 12px; margin-top: 12px;
margin-left: 56px; margin-left: 56px;
} }
@ -116,7 +118,7 @@
} }
.hidden-note button { .hidden-note button {
max-height: 30px; max-height: 30px;
} }
.expand-note { .expand-note {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,62 +12,72 @@ import { RawEvent, TaggedRawEvent } from "Nostr";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
export interface NoteReactionProps { export interface NoteReactionProps {
data?: TaggedRawEvent, data?: TaggedRawEvent;
["data-ev"]?: NEvent, ["data-ev"]?: NEvent;
root?: TaggedRawEvent root?: TaggedRawEvent;
} }
export default function NoteReaction(props: NoteReactionProps) { export default function NoteReaction(props: NoteReactionProps) {
const { ["data-ev"]: dataEv, data } = props; const { ["data-ev"]: dataEv, data } = props;
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]) const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]);
const { isMuted } = useModeration(); const { isMuted } = useModeration();
const refEvent = useMemo(() => { const refEvent = useMemo(() => {
if (ev) { if (ev) {
let eTags = ev.Tags.filter(a => a.Key === "e"); let eTags = ev.Tags.filter((a) => a.Key === "e");
if (eTags.length > 0) { if (eTags.length > 0) {
return eTags[0].Event; return eTags[0].Event;
} }
}
return null;
}, [ev]);
if (ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost) {
return null;
} }
return null;
}, [ev]);
/** if (ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost) {
* Some clients embed the reposted note in the content return null;
*/ }
function extractRoot() {
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0 && ev.Content !== "#[0]") { /**
try { * Some clients embed the reposted note in the content
let r: RawEvent = JSON.parse(ev.Content); */
return r as TaggedRawEvent; function extractRoot() {
} catch (e) { if (
console.error("Could not load reposted content", e); ev?.Kind === EventKind.Repost &&
} ev.Content.length > 0 &&
} ev.Content !== "#[0]"
return props.root; ) {
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 root = extractRoot();
const isOpMuted = root && isMuted(root.pubkey) const isOpMuted = root && isMuted(root.pubkey);
const opt = { const opt = {
showHeader: ev?.Kind === EventKind.Repost, showHeader: ev?.Kind === EventKind.Repost,
showFooter: false, showFooter: false,
}; };
return isOpMuted ? null : ( return isOpMuted ? null : (
<div className="reaction"> <div className="reaction">
<div className="header flex"> <div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} /> <ProfileImage pubkey={ev.RootPubKey} />
<div className="info"> <div className="info">
<NoteTime from={ev.CreatedAt * 1000} /> <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> </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; const DayInMs = HourInMs * 24;
export interface NoteTimeProps { export interface NoteTimeProps {
from: number, from: number;
fallback?: string fallback?: string;
} }
export default function NoteTime(props: NoteTimeProps) { export default function NoteTime(props: NoteTimeProps) {
const [time, setTime] = useState<string>(); const [time, setTime] = useState<string>();
const { from, fallback } = props; const { from, fallback } = props;
const absoluteTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'long'}).format(from); const absoluteTime = new Intl.DateTimeFormat(undefined, {
const isoDate = new Date(from).toISOString(); dateStyle: "medium",
timeStyle: "long",
}).format(from);
const isoDate = new Date(from).toISOString();
function calcTime() { function calcTime() {
let fromDate = new Date(from); let fromDate = new Date(from);
let ago = (new Date().getTime()) - from; let ago = new Date().getTime() - from;
let absAgo = Math.abs(ago); let absAgo = Math.abs(ago);
if (absAgo > DayInMs) { if (absAgo > DayInMs) {
return fromDate.toLocaleDateString(undefined, { year: "2-digit", month: "short", day: "2-digit", weekday: "short" }); return fromDate.toLocaleDateString(undefined, {
} else if (absAgo > HourInMs) { year: "2-digit",
return `${fromDate.getHours().toString().padStart(2, '0')}:${fromDate.getMinutes().toString().padStart(2, '0')}`; month: "short",
} else if (absAgo < MinuteInMs) { day: "2-digit",
return fallback weekday: "short",
} else { });
let mins = Math.floor(absAgo / MinuteInMs); } else if (absAgo > HourInMs) {
if(ago < 0) { return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate
return `in ${mins}m`; .getMinutes()
} .toString()
return `${mins}m`; .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(() => { useEffect(() => {
setTime(calcTime()); setTime(calcTime());
let t = setInterval(() => { let t = setInterval(() => {
setTime(s => { setTime((s) => {
let newTime = calcTime(); let newTime = calcTime();
if (newTime !== s) { if (newTime !== s) {
return newTime; return newTime;
} }
return s; return s;
}) });
}, MinuteInMs); }, MinuteInMs);
return () => clearInterval(t); return () => clearInterval(t);
}, [from]); }, [from]);
return <time dateTime={isoDate} title={absoluteTime}>{time}</time> return (
<time dateTime={isoDate} title={absoluteTime}>
{time}
</time>
);
} }

View File

@ -1,6 +1,6 @@
.nts { .nts {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.note-to-self { .note-to-self {
@ -13,20 +13,20 @@
} }
.nts .avatar { .nts .avatar {
border-width: 1px; border-width: 1px;
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
.nts .avatar.clickable { .nts .avatar.clickable {
cursor: pointer; cursor: pointer;
} }
.nts a { .nts a {
text-decoration: none; text-decoration: none;
} }
.nts .name { .nts .name {
margin-top: -.2em; margin-top: -0.2em;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-weight: bold; font-weight: bold;
@ -34,5 +34,5 @@
.nts .nip05 { .nts .nip05 {
margin: 0; 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 { Link, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { useUserProfile } from "Feed/ProfileFeed";
import Nip05 from "Element/Nip05"; import Nip05 from "Element/Nip05";
import { profileLink } from "Util"; import { profileLink } from "Util";
export interface NoteToSelfProps { export interface NoteToSelfProps {
pubkey: string, pubkey: string;
clickable?: boolean clickable?: boolean;
className?: string, className?: string;
link?: 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>
)
} }
export default function NoteToSelf({ pubkey, clickable, className, link }: NoteToSelfProps) { function NoteLabel({ pubkey, link }: NoteToSelfProps) {
const navigate = useNavigate(); 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 = () => { export default function NoteToSelf({
if(clickable) { pubkey,
navigate(link ?? profileLink(pubkey)) clickable,
} className,
link,
}: NoteToSelfProps) {
const navigate = useNavigate();
const clickLink = () => {
if (clickable) {
navigate(link ?? profileLink(pubkey));
} }
};
return ( return (
<div className={`nts${className ? ` ${className}` : ""}`}> <div className={`nts${className ? ` ${className}` : ""}`}>
<div className="avatar-wrapper"> <div className="avatar-wrapper">
<div className={`avatar${clickable ? " clickable" : ""}`}> <div className={`avatar${clickable ? " clickable" : ""}`}>
<FontAwesomeIcon onClick={clickLink} className="note-to-self" icon={faBook} size="2xl" /> <FontAwesomeIcon
</div> onClick={clickLink}
</div> className="note-to-self"
<div className="f-grow"> icon={faBook}
<div className="name"> size="2xl"
{clickable && ( />
<Link to={link ?? profileLink(pubkey)}>
<NoteLabel pubkey={pubkey} />
</Link>
) || (
<NoteLabel pubkey={pubkey} />
)}
</div>
</div>
</div> </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 { .pfp .avatar {
width: 48px; width: 48px;
height: 48px; height: 48px;
cursor: pointer; cursor: pointer;
} }
.pfp a { .pfp a {

View File

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

View File

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

View File

@ -8,35 +8,46 @@ import { HexKey } from "Nostr";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
export interface ProfilePreviewProps { export interface ProfilePreviewProps {
pubkey: HexKey, pubkey: HexKey;
options?: { options?: {
about?: boolean about?: boolean;
}, };
actions?: ReactNode, actions?: ReactNode;
className?: string className?: string;
} }
export default function ProfilePreview(props: ProfilePreviewProps) { export default function ProfilePreview(props: ProfilePreviewProps) {
const pubkey = props.pubkey; const pubkey = props.pubkey;
const user = useUserProfile(pubkey); const user = useUserProfile(pubkey);
const { ref, inView } = useInView({ triggerOnce: true }); const { ref, inView } = useInView({ triggerOnce: true });
const options = { const options = {
about: true, about: true,
...props.options ...props.options,
}; };
return ( return (
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}> <div
{inView && <> className={`profile-preview${
<ProfileImage pubkey={pubkey} subHeader= props.className ? ` ${props.className}` : ""
{options.about ? <div className="f-ellipsis about"> }`}
{user?.about} ref={ref}
</div> : undefined} /> >
{props.actions ?? ( {inView && (
<div className="follow-button-container"> <>
<FollowButton pubkey={pubkey} /> <ProfileImage
</div> pubkey={pubkey}
)} subHeader={
</>} options.about ? (
</div> <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"; import { useEffect, useState } from "react";
export const ProxyImg = (props: any) => { export const ProxyImg = (props: any) => {
const { src, size, ...rest } = props; const { src, size, ...rest } = props;
const [url, setUrl] = useState<string>(); const [url, setUrl] = useState<string>();
const { proxy } = useImgProxy(); const { proxy } = useImgProxy();
useEffect(() => { useEffect(() => {
if (src) { if (src) {
proxy(src, size) proxy(src, size)
.then(a => setUrl(a)) .then((a) => setUrl(a))
.catch(console.warn); .catch(console.warn);
} }
}, [src]); }, [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"; import { useEffect, useRef } from "react";
export interface QrCodeProps { export interface QrCodeProps {
data?: string, data?: string;
link?: string, link?: string;
avatar?: string, avatar?: string;
height?: number, height?: number;
width?: number, width?: number;
className?: string className?: string;
} }
export default function QrCode(props: QrCodeProps) { export default function QrCode(props: QrCodeProps) {
const qrRef = useRef<HTMLDivElement>(null); const qrRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if ((props.data?.length ?? 0) > 0 && qrRef.current) { if ((props.data?.length ?? 0) > 0 && qrRef.current) {
let qr = new QRCodeStyling({ let qr = new QRCodeStyling({
width: props.width || 256, width: props.width || 256,
height: props.height || 256, height: props.height || 256,
data: props.data, data: props.data,
margin: 5, margin: 5,
type: 'canvas', type: "canvas",
image: props.avatar, image: props.avatar,
dotsOptions: { dotsOptions: {
type: 'rounded' type: "rounded",
}, },
cornersSquareOptions: { cornersSquareOptions: {
type: 'extra-rounded' type: "extra-rounded",
}, },
imageOptions: { imageOptions: {
crossOrigin: "anonymous" crossOrigin: "anonymous",
} },
}); });
qrRef.current.innerHTML = ""; qrRef.current.innerHTML = "";
qr.append(qrRef.current); qr.append(qrRef.current);
if (props.link) { if (props.link) {
qrRef.current.onclick = function (e) { qrRef.current.onclick = function (e) {
let elm = document.createElement("a"); let elm = document.createElement("a");
elm.href = props.link!; elm.href = props.link!;
elm.click(); elm.click();
} };
} }
} else if (qrRef.current) { } else if (qrRef.current) {
qrRef.current.innerHTML = ""; qrRef.current.innerHTML = "";
} }
}, [props.data, props.link]); }, [props.data, props.link]);
return ( return (
<div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div> <div
); className={`qr${props.className ? ` ${props.className}` : ""}`}
} ref={qrRef}
></div>
);
}

View File

@ -1,25 +1,25 @@
.relay { .relay {
margin-top: 10px; margin-top: 10px;
background-color: var(--gray-secondary); background-color: var(--gray-secondary);
border-radius: 5px; border-radius: 5px;
text-align: start; text-align: start;
display: grid; display: grid;
grid-template-columns: min-content auto; grid-template-columns: min-content auto;
overflow: hidden; overflow: hidden;
font-size: var(--font-size-small); font-size: var(--font-size-small);
} }
.relay > div { .relay > div {
padding: 5px; padding: 5px;
} }
.relay-extra { .relay-extra {
padding: 5px; padding: 5px;
margin: 0 5px; margin: 0 5px;
background-color: var(--gray-tertiary); background-color: var(--gray-tertiary);
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
white-space: nowrap; white-space: nowrap;
font-size: var(--font-size-small); font-size: var(--font-size-small);
} }
.icon-btn { .icon-btn {
@ -35,7 +35,7 @@
} }
.checkmark { .checkmark {
margin-left: .5em; margin-left: 0.5em;
padding: 2px 10px; padding: 2px 10px;
background-color: var(--gray); background-color: var(--gray);
border-radius: 10px; 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 useRelayState from "Feed/RelayState";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo } from "react"; import { useMemo } from "react";
@ -11,65 +18,92 @@ import { RelaySettings } from "Nostr/Connection";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export interface RelayProps { export interface RelayProps {
addr: string addr: string;
} }
export default function Relay(props: RelayProps) { export default function Relay(props: RelayProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays); const allRelaySettings = useSelector<
const relaySettings = allRelaySettings[props.addr]; RootState,
const state = useRelayState(props.addr); Record<string, RelaySettings>
const name = useMemo(() => new URL(props.addr).host, [props.addr]); >((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) { function configure(o: RelaySettings) {
dispatch(setRelays({ dispatch(
relays: { setRelays({
...allRelaySettings, relays: {
[props.addr]: o ...allRelaySettings,
}, [props.addr]: o,
createdAt: Math.floor(new Date().getTime() / 1000) },
})); createdAt: Math.floor(new Date().getTime() / 1000),
} })
);
}
let latency = Math.floor(state?.avgLatency ?? 0);
let latency = Math.floor(state?.avgLatency ?? 0); return (
return ( <>
<> <div className={`relay w-max`}>
<div className={`relay w-max`}> <div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}>
<div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}> <FontAwesomeIcon icon={faPlug} />
<FontAwesomeIcon icon={faPlug} /> </div>
</div> <div className="f-grow f-col">
<div className="f-grow f-col"> <div className="flex mb10">
<div className="flex mb10"> <b className="f-2">{name}</b>
<b className="f-2">{name}</b> <div className="f-1">
<div className="f-1"> Write
Write <span
<span className="checkmark" onClick={() => configure({ write: !relaySettings.write, read: relaySettings.read })}> className="checkmark"
<FontAwesomeIcon icon={relaySettings.write ? faSquareCheck : faSquareXmark} /> onClick={() =>
</span> configure({
</div> write: !relaySettings.write,
<div className="f-1"> read: relaySettings.read,
Read })
<span className="checkmark" onClick={() => configure({ write: relaySettings.write, read: !relaySettings.read })}> }
<FontAwesomeIcon icon={relaySettings.read ? faSquareCheck : faSquareXmark} /> >
</span> <FontAwesomeIcon
</div> icon={relaySettings.write ? faSquareCheck : faSquareXmark}
</div> />
<div className="flex"> </span>
<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> </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 { .lnurl-tip {
padding: 24px 32px; padding: 24px 32px;
background-color: #1B1B1B; background-color: #1b1b1b;
border-radius: 16px; border-radius: 16px;
position: relative; position: relative;
} }
@ -28,7 +28,7 @@
.lnurl-tip h3 { .lnurl-tip h3 {
color: var(--font-secondary-color); color: var(--font-secondary-color);
font-size: 11px; font-size: 11px;
letter-spacing: .11em; letter-spacing: 0.11em;
font-weight: 600; font-weight: 600;
line-height: 13px; line-height: 13px;
text-transform: uppercase; text-transform: uppercase;
@ -62,9 +62,9 @@
} }
.lnurl-tip .btn { .lnurl-tip .btn {
background-color: inherit; background-color: inherit;
width: 210px; width: 210px;
margin: 0 0 10px 0; margin: 0 0 10px 0;
} }
.lnurl-tip .btn:hover { .lnurl-tip .btn:hover {
@ -86,7 +86,7 @@
.sat-amount { .sat-amount {
text-align: center; text-align: center;
display: inline-block; display: inline-block;
background-color: #2A2A2A; background-color: #2a2a2a;
color: var(--font-color); color: var(--font-color);
padding: 12px 16px; padding: 12px 16px;
border-radius: 100px; border-radius: 100px;
@ -115,21 +115,21 @@
} }
.lnurl-tip .invoice { .lnurl-tip .invoice {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.lnurl-tip .invoice .actions { .lnurl-tip .invoice .actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
text-align: center; text-align: center;
} }
.lnurl-tip .invoice .actions .copy-action { .lnurl-tip .invoice .actions .copy-action {
margin: 10px auto; margin: 10px auto;
} }
.lnurl-tip .invoice .actions .wallet-action { .lnurl-tip .invoice .actions .wallet-action {

View File

@ -16,307 +16,318 @@ import useWebln from "Hooks/useWebln";
import useHorizontalScroll from "Hooks/useHorizontalScroll"; import useHorizontalScroll from "Hooks/useHorizontalScroll";
interface LNURLService { interface LNURLService {
nostrPubkey?: HexKey nostrPubkey?: HexKey;
minSendable?: number, minSendable?: number;
maxSendable?: number, maxSendable?: number;
metadata: string, metadata: string;
callback: string, callback: string;
commentAllowed?: number commentAllowed?: number;
} }
interface LNURLInvoice { interface LNURLInvoice {
pr: string, pr: string;
successAction?: LNURLSuccessAction successAction?: LNURLSuccessAction;
} }
interface LNURLSuccessAction { interface LNURLSuccessAction {
description?: string, description?: string;
url?: string url?: string;
} }
export interface LNURLTipProps { export interface LNURLTipProps {
onClose?: () => void, onClose?: () => void;
svc?: string, svc?: string;
show?: boolean, show?: boolean;
invoice?: string, // shortcut to invoice qr tab invoice?: string; // shortcut to invoice qr tab
title?: string, title?: string;
notice?: string notice?: string;
target?: string target?: string;
note?: HexKey note?: HexKey;
author?: HexKey author?: HexKey;
} }
export default function LNURLTip(props: LNURLTipProps) { export default function LNURLTip(props: LNURLTipProps) {
const onClose = props.onClose || (() => { }); const onClose = props.onClose || (() => {});
const service = props.svc; const service = props.svc;
const show = props.show || false; const show = props.show || false;
const { note, author, target } = props const { note, author, target } = props;
const amounts = [500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000]; const amounts = [
const emojis: Record<number, string> = { 500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000,
1_000: "👍", ];
5_000: "💜", const emojis: Record<number, string> = {
10_000: "😍", 1_000: "👍",
20_000: "🤩", 5_000: "💜",
50_000: "🔥", 10_000: "😍",
100_000: "🚀", 20_000: "🤩",
1_000_000: "🤯", 50_000: "🔥",
} 100_000: "🚀",
const [payService, setPayService] = useState<LNURLService>(); 1_000_000: "🤯",
const [amount, setAmount] = useState<number>(500); };
const [customAmount, setCustomAmount] = useState<number>(); const [payService, setPayService] = useState<LNURLService>();
const [invoice, setInvoice] = useState<LNURLInvoice>(); const [amount, setAmount] = useState<number>(500);
const [comment, setComment] = useState<string>(); const [customAmount, setCustomAmount] = useState<number>();
const [error, setError] = useState<string>(); const [invoice, setInvoice] = useState<LNURLInvoice>();
const [success, setSuccess] = useState<LNURLSuccessAction>(); const [comment, setComment] = useState<string>();
const webln = useWebln(show); const [error, setError] = useState<string>();
const publisher = useEventPublisher(); const [success, setSuccess] = useState<LNURLSuccessAction>();
const horizontalScroll = useHorizontalScroll(); const webln = useWebln(show);
const publisher = useEventPublisher();
const horizontalScroll = useHorizontalScroll();
useEffect(() => { useEffect(() => {
if (show && !props.invoice) { if (show && !props.invoice) {
loadService() loadService()
.then(a => setPayService(a!)) .then((a) => setPayService(a!))
.catch(() => setError("Failed to load LNURL service")); .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 { } else {
setPayService(undefined); setInvoice(data);
setError(undefined); setError("");
setInvoice(props.invoice ? { pr: props.invoice } : undefined); payWebLNIfEnabled(data);
setAmount(500);
setComment(undefined);
setSuccess(undefined);
} }
}, [show, service]); } else {
setError("Failed to load invoice");
const serviceAmounts = useMemo(() => { }
if (payService) { } catch (e) {
let min = (payService.minSendable ?? 0) / 1000; setError("Failed to load invoice");
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> { function custom() {
if (service) { let min = (payService?.minSendable ?? 1000) / 1000;
let isServiceUrl = service.toLowerCase().startsWith("lnurl"); let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
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;
return ( return (
<Modal className="lnurl-modal" onClose={onClose}> <div className="custom-amount flex">
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}> <input
<div className="close" onClick={onClose}> type="number"
<Close /> min={min}
</div> max={max}
<div className="lnurl-header"> className="f-grow mr10"
{author && <ProfileImage pubkey={author} showUsername={false} />} placeholder="Custom"
<h2> value={customAmount}
{props.title || title} onChange={(e) => setCustomAmount(parseInt(e.target.value))}
</h2> />
</div> <button
{invoiceForm()} className="secondary"
{error && <p className="error">{error}</p>} type="button"
{payInvoice()} disabled={!Boolean(customAmount)}
{successAction()} 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>
</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 { interface ShowMoreProps {
text?: string text?: string;
className?: string className?: string;
onClick: () => void onClick: () => void;
} }
const ShowMore = ({ text = "Show more", onClick, className = "" }: ShowMoreProps) => { const ShowMore = ({
const classNames = className ? `show-more ${className}` : "show-more" text = "Show more",
onClick,
className = "",
}: ShowMoreProps) => {
const classNames = className ? `show-more ${className}` : "show-more";
return ( return (
<div className="show-more-container"> <div className="show-more-container">
<button className={classNames} onClick={onClick}> <button className={classNames} onClick={onClick}>
{text} {text}
</button> </button>
</div> </div>
) );
} };
export default ShowMore export default ShowMore;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import './Text.css' import "./Text.css";
import { useMemo, useCallback } from "react"; import { useMemo, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
@ -12,154 +12,182 @@ import Hashtag from "Element/Hashtag";
import Tag from "Nostr/Tag"; import Tag from "Nostr/Tag";
import { MetadataCache } from "State/Users"; import { MetadataCache } from "State/Users";
import Mention from "Element/Mention"; import Mention from "Element/Mention";
import HyperText from 'Element/HyperText'; import HyperText from "Element/HyperText";
import { HexKey } from 'Nostr'; import { HexKey } from "Nostr";
export type Fragment = string | JSX.Element; export type Fragment = string | JSX.Element;
export interface TextFragment { export interface TextFragment {
body: Fragment[], body: Fragment[];
tags: Tag[], tags: Tag[];
users: Map<string, MetadataCache> users: Map<string, MetadataCache>;
} }
export interface TextProps { export interface TextProps {
content: string, content: string;
creator: HexKey, creator: HexKey;
tags: Tag[], tags: Tag[];
users: Map<string, MetadataCache> users: Map<string, MetadataCache>;
} }
export default function Text({ content, tags, creator, users }: TextProps) { export default function Text({ content, tags, creator, users }: TextProps) {
function extractLinks(fragments: Fragment[]) {
function extractLinks(fragments: Fragment[]) { return fragments
return fragments.map(f => { .map((f) => {
if (typeof f === "string") { if (typeof f === "string") {
return f.split(UrlRegex).map(a => { return f.split(UrlRegex).map((a) => {
if (a.startsWith("http")) { if (a.startsWith("http")) {
return <HyperText link={a} creator={creator} /> return <HyperText link={a} creator={creator} />;
}
return a;
});
} }
return f; return a;
}).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 <>{fragments}</> return f;
} })
.flat();
}
function transformText(frag: TextFragment) { function extractMentions(frag: TextFragment) {
if (frag.body === undefined) { return frag.body
debugger; .map((f) => {
} if (typeof f === "string") {
let fragments = extractMentions(frag); return f.split(MentionRegex).map((match) => {
fragments = extractLinks(fragments); let matchTag = match.match(/#\[(\d+)\]/);
fragments = extractInvoices(fragments); if (matchTag && matchTag.length === 2) {
fragments = extractHashtags(fragments); let idx = parseInt(matchTag[1]);
return fragments; let ref = frag.tags?.find((a) => a.Index === idx);
} if (ref) {
switch (ref.Key) {
const components = useMemo(() => { case "p": {
return { return <Mention pubkey={ref.PubKey!} />;
p: (x: any) => transformParagraph({ body: x.children ?? [], tags, users }), }
a: (x: any) => <HyperText link={x.href} creator={creator} />, case "e": {
li: (x: any) => transformLi({ body: x.children ?? [], tags, users }), let eText = hexToBech32("note", ref.Event!).substring(
}; 0,
}, [content]); 12
);
const disableMarkdownLinks = useCallback(() => (tree: any) => { return (
visit(tree, (node, index, parent) => { <Link
if ( key={ref.Event}
parent && to={eventLink(ref.Event!)}
typeof index === 'number' && onClick={(e) => e.stopPropagation()}
(node.type === 'link' || >
node.type === 'linkReference' || #{eText}
node.type === 'image' || </Link>
node.type === 'imageReference' || );
node.type === 'definition') }
) { case "t": {
node.type = 'text'; return <Hashtag tag={ref.Hashtag!} />;
node.value = content.slice(node.position.start.offset, node.position.end.offset).replace(/\)$/, ' )'); }
return SKIP; }
}
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
} else {
return match;
} }
}) });
}, [content]); }
return <ReactMarkdown return f;
className="text" })
components={components} .flat();
remarkPlugins={[disableMarkdownLinks]} }
>{content}</ReactMarkdown>
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) { .rta__item:not(:last-child) {
border: none; 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; text-decoration: none;
background: var(--gray-secondary); background: var(--gray-secondary);
} }
.user-item, .emoji-item { .user-item,
.emoji-item {
color: var(--font-color); color: var(--font-color);
background: var(--note-bg); background: var(--note-bg);
display: flex; display: flex;
@ -19,7 +21,8 @@
padding: 10px; padding: 10px;
} }
.user-item:hover, .emoji-item:hover { .user-item:hover,
.emoji-item:hover {
background: var(--gray-tertiary); background: var(--gray-tertiary);
} }
@ -37,9 +40,9 @@
} }
.user-picture .avatar { .user-picture .avatar {
border-width: 1px; border-width: 1px;
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
.user-details { .user-details {
@ -57,8 +60,8 @@
} }
.emoji-item .emoji { .emoji-item .emoji {
margin-right: .2em; margin-right: 0.2em;
min-width: 20px; min-width: 20px;
} }
.emoji-item .emoji-name { .emoji-item .emoji-name {

View File

@ -13,8 +13,8 @@ import { MetadataCache } from "State/Users";
import { useQuery } from "State/Users/Hooks"; import { useQuery } from "State/Users/Hooks";
interface EmojiItemProps { interface EmojiItemProps {
name: string name: string;
char: string char: string;
} }
const EmojiItem = ({ entity: { name, char } }: { entity: EmojiItemProps }) => { 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">{char}</div>
<div className="emoji-name">{name}</div> <div className="emoji-name">{name}</div>
</div> </div>
) );
} };
const UserItem = (metadata: MetadataCache) => { const UserItem = (metadata: MetadataCache) => {
const { pubkey, display_name, picture, nip05, ...rest } = metadata const { pubkey, display_name, picture, nip05, ...rest } = metadata;
return ( return (
<div key={pubkey} className="user-item"> <div key={pubkey} className="user-item">
<div className="user-picture"> <div className="user-picture">
@ -38,24 +38,24 @@ const UserItem = (metadata: MetadataCache) => {
<Nip05 nip05={nip05} pubkey={pubkey} /> <Nip05 nip05={nip05} pubkey={pubkey} />
</div> </div>
</div> </div>
) );
} };
const Textarea = ({ users, onChange, ...rest }: any) => { 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) => { const userDataProvider = (token: string) => {
setQuery(token) setQuery(token);
return allUsers return allUsers;
} };
const emojiDataProvider = (token: string) => { const emojiDataProvider = (token: string) => {
return emoji(token) return emoji(token)
.slice(0, 5) .slice(0, 5)
.map(({ name, char }) => ({ name, char })); .map(({ name, char }) => ({ name, char }));
} };
return ( return (
<ReactTextareaAutocomplete <ReactTextareaAutocomplete
@ -68,17 +68,17 @@ const Textarea = ({ users, onChange, ...rest }: any) => {
":": { ":": {
dataProvider: emojiDataProvider, dataProvider: emojiDataProvider,
component: EmojiItem, component: EmojiItem,
output: (item: EmojiItemProps, trigger) => item.char output: (item: EmojiItemProps, trigger) => item.char,
}, },
"@": { "@": {
afterWhitespace: true, afterWhitespace: true,
dataProvider: userDataProvider, dataProvider: userDataProvider,
component: (props: any) => <UserItem {...props.entity} />, 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 { .subthread-container.subthread-multi .line-container:before {
content: ''; content: "";
position: absolute; position: absolute;
left: 36px; left: 36px;
top: 48px; top: 48px;
@ -78,7 +78,7 @@
} }
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after { .subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: ''; content: "";
position: absolute; position: absolute;
left: 36px; left: 36px;
top: 48px; top: 48px;
@ -87,13 +87,14 @@
} }
@media (min-width: 720px) { @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; left: 48px;
} }
} }
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after { .subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: ''; content: "";
position: absolute; position: absolute;
border-left: 1px solid var(--gray-superdark); border-left: 1px solid var(--gray-superdark);
left: 36px; left: 36px;
@ -102,13 +103,14 @@
} }
@media (min-width: 720px) { @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; left: 48px;
} }
} }
.subthread-container.subthread-last .line-container:before { .subthread-container.subthread-last .line-container:before {
content: ''; content: "";
position: absolute; position: absolute;
border-left: 1px solid var(--gray-superdark); border-left: 1px solid var(--gray-superdark);
left: 36px; left: 36px;
@ -137,7 +139,8 @@
margin-left: 80px; margin-left: 80px;
} }
.thread-container .collapsed, .thread-container .show-more-container { .thread-container .collapsed,
.thread-container .show-more-container {
background: var(--note-bg); background: var(--note-bg);
min-height: 48px; min-height: 48px;
} }
@ -147,7 +150,7 @@
border-bottom-right-radius: 16px; border-bottom-right-radius: 16px;
} }
.thread-container .collapsed { .thread-container .collapsed {
background-color: var(--note-bg); background-color: var(--note-bg);
} }

View File

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

View File

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

View File

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

View File

@ -15,68 +15,97 @@ import ProfilePreview from "./ProfilePreview";
import Skeleton from "Element/Skeleton"; import Skeleton from "Element/Skeleton";
export interface TimelineProps { export interface TimelineProps {
postsOnly: boolean, postsOnly: boolean;
subject: TimelineSubject, subject: TimelineSubject;
method: "TIME_RANGE" | "LIMIT_UNTIL" method: "TIME_RANGE" | "LIMIT_UNTIL";
ignoreModeration?: boolean, ignoreModeration?: boolean;
window?: number window?: number;
} }
/** /**
* A list of notes by pubkeys * A list of notes by pubkeys
*/ */
export default function Timeline({ subject, postsOnly = false, method, ignoreModeration = false, window }: TimelineProps) { export default function Timeline({
const { muted, isMuted } = useModeration(); subject,
const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, { postsOnly = false,
method, method,
window: window 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[]) => { const filterPosts = useCallback(
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)); (nts: TaggedRawEvent[]) => {
}, [postsOnly, muted]); 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(() => { const mainFeed = useMemo(() => {
return filterPosts(main.notes); return filterPosts(main.notes);
}, [main, filterPosts]); }, [main, filterPosts]);
const latestFeed = useMemo(() => { const latestFeed = useMemo(() => {
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)) return filterPosts(latest.notes).filter(
}, [latest, mainFeed, filterPosts]); (a) => !mainFeed.some((b) => b.id === a.id)
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>
); );
}, [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 { .pill.unread {
background-color: var(--gray); background-color: var(--gray);
color: var(--font-color); color: var(--font-color);
} }
.pill:hover { .pill:hover {

View File

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

View File

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

View File

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

View File

@ -6,342 +6,371 @@ import EventKind from "Nostr/EventKind";
import Tag from "Nostr/Tag"; import Tag from "Nostr/Tag";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr"; import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr";
import { bech32ToHex } from "Util" import { bech32ToHex } from "Util";
import { DefaultRelays, HashtagRegex } from "Const"; import { DefaultRelays, HashtagRegex } from "Const";
import { RelaySettings } from "Nostr/Connection"; import { RelaySettings } from "Nostr/Connection";
declare global { declare global {
interface Window { interface Window {
nostr: { nostr: {
getPublicKey: () => Promise<HexKey>, getPublicKey: () => Promise<HexKey>;
signEvent: (event: RawEvent) => Promise<RawEvent>, signEvent: (event: RawEvent) => Promise<RawEvent>;
getRelays: () => Promise<Record<string, { read: boolean, write: boolean }>>, getRelays: () => Promise<
nip04: { Record<string, { read: boolean; write: boolean }>
encrypt: (pubkey: HexKey, content: string) => Promise<string>, >;
decrypt: (pubkey: HexKey, content: string) => Promise<string> nip04: {
} encrypt: (pubkey: HexKey, content: string) => Promise<string>;
} decrypt: (pubkey: HexKey, content: string) => Promise<string>;
} };
};
}
} }
export default function useEventPublisher() { export default function useEventPublisher() {
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const pubKey = useSelector<RootState, HexKey | undefined>(
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey); (s) => s.login.publicKey
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows); );
const relays = useSelector((s: RootState) => s.login.relays); const privKey = useSelector<RootState, HexKey | undefined>(
const hasNip07 = 'nostr' in window; (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> { async function signEvent(ev: NEvent): Promise<NEvent> {
if (hasNip07 && !privKey) { if (hasNip07 && !privKey) {
ev.Id = await ev.CreateId(); ev.Id = await ev.CreateId();
let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject())); let tmpEv = await barierNip07(() =>
return new NEvent(tmpEv); window.nostr.signEvent(ev.ToObject())
} else if (privKey) { );
await ev.Sign(privKey); return new NEvent(tmpEv);
} else { } else if (privKey) {
console.warn("Count not sign event, no private keys available"); await ev.Sign(privKey);
} } else {
return ev; console.warn("Count not sign event, no private keys available");
} }
return ev;
}
function processContent(ev: NEvent, msg: string) { function processContent(ev: NEvent, msg: string) {
const replaceNpub = (match: string) => { const replaceNpub = (match: string) => {
const npub = match.slice(1); const npub = match.slice(1);
try { try {
const hex = bech32ToHex(npub); const hex = bech32ToHex(npub);
const idx = ev.Tags.length; const idx = ev.Tags.length;
ev.Tags.push(new Tag(["p", hex], idx)); ev.Tags.push(new Tag(["p", hex], idx));
return `#[${idx}]` return `#[${idx}]`;
} catch (error) { } catch (error) {
return match 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); muted: async (keys: HexKey[], priv: HexKey[]) => {
const idx = ev.Tags.length; if (pubKey) {
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx)); let ev = NEvent.ForPubKey(pubKey);
return `#[${idx}]` ev.Kind = EventKind.Lists;
} catch (error) { ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length));
return match 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; ev.Content = content;
} return await signEvent(ev);
}
return { },
nip42Auth: async (challenge: string, relay: string) => { metadata: async (obj: UserMetadata) => {
if (pubKey) { if (pubKey) {
const ev = NEvent.ForPubKey(pubKey); let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Auth; ev.Kind = EventKind.SetMetadata;
ev.Content = ""; ev.Content = JSON.stringify(obj);
ev.Tags.push(new Tag(["relay", relay], 0)); return await signEvent(ev);
ev.Tags.push(new Tag(["challenge", challenge], 1)); }
return await signEvent(ev); },
} note: async (msg: string) => {
}, if (pubKey) {
broadcast: (ev: NEvent | undefined) => { let ev = NEvent.ForPubKey(pubKey);
if (ev) { ev.Kind = EventKind.TextNote;
console.debug("Sending event: ", ev); processContent(ev, msg);
System.BroadcastEvent(ev); return await signEvent(ev);
} }
}, },
/** zap: async (author: HexKey, note?: HexKey, msg?: string) => {
* Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs if (pubKey) {
* If a user removes all the DefaultRelays from their relay list and saves that relay list, let ev = NEvent.ForPubKey(pubKey);
* 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 ev.Kind = EventKind.ZapRequest;
*/ if (note) {
broadcastForBootstrap: (ev: NEvent | undefined) => { // @ts-ignore
if (ev) { ev.Tags.push(new Tag(["e", note]));
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);
}
}
} }
} // @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; let isNip07Busy = false;
const delay = (t: number) => { const delay = (t: number) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
setTimeout(resolve, t); setTimeout(resolve, t);
}); });
} };
export const barierNip07 = async (then: () => Promise<any>) => { export const barierNip07 = async (then: () => Promise<any>) => {
while (isNip07Busy) { while (isNip07Busy) {
await delay(10); await delay(10);
} }
isNip07Busy = true; isNip07Busy = true;
try { try {
return await then(); return await then();
} finally { } finally {
isNip07Busy = false; isNip07Busy = false;
} }
}; };

View File

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

View File

@ -1,24 +1,28 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
import { Subscriptions} from "Nostr/Subscriptions"; import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription, { NoteStore } from "Feed/Subscription"; import useSubscription, { NoteStore } from "Feed/Subscription";
export default function useFollowsFeed(pubkey: HexKey) { export default function useFollowsFeed(pubkey: HexKey) {
const sub = useMemo(() => { const sub = useMemo(() => {
let x = new Subscriptions(); let x = new Subscriptions();
x.Id = `follows:${pubkey.slice(0, 12)}`; x.Id = `follows:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]); x.Kinds = new Set([EventKind.ContactList]);
x.Authors = new Set([pubkey]); x.Authors = new Set([pubkey]);
return x; return x;
}, [pubkey]); }, [pubkey]);
return useSubscription(sub); return useSubscription(sub);
} }
export function getFollowers(feed: NoteStore, pubkey: HexKey) { export function getFollowers(feed: NoteStore, pubkey: HexKey) {
let contactLists = feed?.notes.filter(a => a.kind === EventKind.ContactList && a.pubkey === pubkey); let contactLists = feed?.notes.filter(
let pTags = contactLists?.map(a => a.tags.filter(b => b[0] === "p").map(c => c[1])); (a) => a.kind === EventKind.ContactList && a.pubkey === pubkey
return [...new Set(pTags?.flat())]; );
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 secp from "@noble/secp256k1";
import * as base64 from "@protobufjs/base64" import * as base64 from "@protobufjs/base64";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
export interface ImgProxySettings { export interface ImgProxySettings {
url: string, url: string;
key: string, key: string;
salt: string salt: string;
} }
export default function useImgProxy() { export default function useImgProxy() {
const settings = useSelector((s: RootState) => s.login.preferences.imgProxyConfig); const settings = useSelector(
const te = new TextEncoder(); (s: RootState) => s.login.preferences.imgProxyConfig
);
const te = new TextEncoder();
function urlSafe(s: string) { function urlSafe(s: string) {
return s.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
} }
async function signUrl(u: string) { async function signUrl(u: string) {
const result = await secp.utils.hmacSha256( const result = await secp.utils.hmacSha256(
secp.utils.hexToBytes(settings!.key), secp.utils.hexToBytes(settings!.key),
secp.utils.hexToBytes(settings!.salt), secp.utils.hexToBytes(settings!.salt),
te.encode(u)); te.encode(u)
return urlSafe(base64.encode(result, 0, result.byteLength)); );
} return urlSafe(base64.encode(result, 0, result.byteLength));
}
return { return {
proxy: async (url: string, resize?: number) => { proxy: async (url: string, resize?: number) => {
if (!settings) return url; if (!settings) return url;
const opt = resize ? `rs:fit:${resize}:${resize}` : ""; const opt = resize ? `rs:fit:${resize}:${resize}` : "";
const urlBytes = te.encode(url); const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes, 0, urlBytes.byteLength)); const urlEncoded = urlSafe(
const path = `/${opt}/${urlEncoded}`; base64.encode(urlBytes, 0, urlBytes.byteLength)
const sig = await signUrl(path); );
return `${new URL(settings.url).toString()}${sig}${path}`; 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 EventKind from "Nostr/EventKind";
import Event from "Nostr/Event"; import Event from "Nostr/Event";
import { Subscriptions } from "Nostr/Subscriptions"; 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 { RootState } from "State/Store";
import { mapEventToProfile, MetadataCache } from "State/Users"; import { mapEventToProfile, MetadataCache } from "State/Users";
import { useDb } from "State/Users/Db"; import { useDb } from "State/Users/Db";
@ -20,7 +28,12 @@ import useModeration from "Hooks/useModeration";
*/ */
export default function useLoginFeed() { export default function useLoginFeed() {
const dispatch = useDispatch(); 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 { isMuted } = useModeration();
const db = useDb(); const db = useDb();
@ -31,7 +44,7 @@ export default function useLoginFeed() {
sub.Id = `login:meta`; sub.Id = `login:meta`;
sub.Authors = new Set([pubKey]); sub.Authors = new Set([pubKey]);
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]); sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
sub.Limit = 2 sub.Limit = 2;
return sub; return sub;
}, [pubKey]); }, [pubKey]);
@ -77,35 +90,49 @@ export default function useLoginFeed() {
return dms; return dms;
}, [pubKey]); }, [pubKey]);
const metadataFeed = useSubscription(subMetadata, { leaveOpen: true, cache: true }); const metadataFeed = useSubscription(subMetadata, {
const notificationFeed = useSubscription(subNotification, { leaveOpen: true, cache: true }); leaveOpen: true,
cache: true,
});
const notificationFeed = useSubscription(subNotification, {
leaveOpen: true,
cache: true,
});
const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true }); const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true });
const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true }); const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true });
useEffect(() => { useEffect(() => {
let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList); let contactList = metadataFeed.store.notes.filter(
let metadata = metadataFeed.store.notes.filter(a => a.kind === EventKind.SetMetadata); (a) => a.kind === EventKind.ContactList
let profiles = metadata.map(a => mapEventToProfile(a)) );
.filter(a => a !== undefined) let metadata = metadataFeed.store.notes.filter(
.map(a => a!); (a) => a.kind === EventKind.SetMetadata
);
let profiles = metadata
.map((a) => mapEventToProfile(a))
.filter((a) => a !== undefined)
.map((a) => a!);
for (let cl of contactList) { for (let cl of contactList) {
if (cl.content !== "" && cl.content !== "{}") { if (cl.content !== "" && cl.content !== "{}") {
let relays = JSON.parse(cl.content); let relays = JSON.parse(cl.content);
dispatch(setRelays({ relays, createdAt: cl.created_at })); 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 })); dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
} }
(async () => { (async () => {
let maxProfile = profiles.reduce((acc, v) => { let maxProfile = profiles.reduce(
if (v.created > acc.created) { (acc, v) => {
acc.profile = v; if (v.created > acc.created) {
acc.created = v.created; acc.profile = v;
} acc.created = v.created;
return acc; }
}, { created: 0, profile: null as MetadataCache | null }); return acc;
},
{ created: 0, profile: null as MetadataCache | null }
);
if (maxProfile.profile) { if (maxProfile.profile) {
let existing = await db.find(maxProfile.profile.pubkey); let existing = await db.find(maxProfile.profile.pubkey);
if ((existing?.created ?? 0) < maxProfile.created) { if ((existing?.created ?? 0) < maxProfile.created) {
@ -116,52 +143,74 @@ export default function useLoginFeed() {
}, [dispatch, metadataFeed.store, db]); }, [dispatch, metadataFeed.store, db]);
useEffect(() => { useEffect(() => {
const replies = notificationFeed.store.notes. const replies = notificationFeed.store.notes.filter(
filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications) (a) =>
replies.forEach(nx => { a.kind === EventKind.TextNote &&
!isMuted(a.pubkey) &&
a.created_at > readNotifications
);
replies.forEach((nx) => {
dispatch(setLatestNotifications(nx.created_at)); dispatch(setLatestNotifications(nx.created_at));
makeNotification(db, nx).then(notification => { makeNotification(db, nx).then((notification) => {
if (notification) { if (notification) {
// @ts-ignore // @ts-ignore
dispatch(sendNotification(notification)) dispatch(sendNotification(notification));
} }
}) });
}) });
}, [dispatch, notificationFeed.store, db, readNotifications]); }, [dispatch, notificationFeed.store, db, readNotifications]);
useEffect(() => { useEffect(() => {
const muted = getMutedKeys(mutedFeed.store.notes) const muted = getMutedKeys(mutedFeed.store.notes);
dispatch(setMuted(muted)) dispatch(setMuted(muted));
const newest = getNewest(mutedFeed.store.notes) const newest = getNewest(mutedFeed.store.notes);
if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) { if (
decryptBlocked(newest, pubKey, privKey).then((plaintext) => { newest &&
try { newest.content.length > 0 &&
const blocked = JSON.parse(plaintext) pubKey &&
const keys = blocked.filter((p: any) => p && p.length === 2 && p[0] === "p").map((p: any) => p[1]) newest.created_at > latestMuted
dispatch(setBlocked({ ) {
keys, decryptBlocked(newest, pubKey, privKey)
createdAt: newest.created_at, .then((plaintext) => {
})) try {
} catch (error) { const blocked = JSON.parse(plaintext);
console.debug("Couldn't parse JSON") const keys = blocked
} .filter((p: any) => p && p.length === 2 && p[0] === "p")
}).catch((error) => console.warn(error)) .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(() => { 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(addDirectMessage(dms));
}, [dispatch, dmsFeed.store]); }, [dispatch, dmsFeed.store]);
} }
async function decryptBlocked(
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) { raw: TaggedRawEvent,
const ev = new Event(raw) pubKey: HexKey,
privKey?: HexKey
) {
const ev = new Event(raw);
if (pubKey && privKey) { if (pubKey && privKey) {
return await ev.DecryptData(raw.content, privKey, pubKey) return await ev.DecryptData(raw.content, privKey, pubKey);
} else { } 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"; import useSubscription, { NoteStore } from "Feed/Subscription";
export default function useMutedFeed(pubkey: HexKey) { export default function useMutedFeed(pubkey: HexKey) {
const sub = useMemo(() => { const sub = useMemo(() => {
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `muted:${pubkey.slice(0, 12)}`; sub.Id = `muted:${pubkey.slice(0, 12)}`;
sub.Kinds = new Set([EventKind.Lists]); sub.Kinds = new Set([EventKind.Lists]);
sub.Authors = new Set([pubkey]); sub.Authors = new Set([pubkey]);
sub.DTags = new Set([Lists.Muted]); sub.DTags = new Set([Lists.Muted]);
sub.Limit = 1; sub.Limit = 1;
return sub; return sub;
}, [pubkey]); }, [pubkey]);
return useSubscription(sub); return useSubscription(sub);
} }
export function getNewest(rawNotes: TaggedRawEvent[]){ export function getNewest(rawNotes: TaggedRawEvent[]) {
const notes = [...rawNotes] const notes = [...rawNotes];
notes.sort((a, b) => a.created_at - b.created_at) notes.sort((a, b) => a.created_at - b.created_at);
if (notes.length > 0) { if (notes.length > 0) {
return notes[0] return notes[0];
} }
} }
export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
const newest = getNewest(rawNotes) createdAt: number;
if (newest) { keys: HexKey[];
const { created_at, tags } = newest } {
const keys = tags.filter(t => t[0] === "p").map(t => t[1]) const newest = getNewest(rawNotes);
return { if (newest) {
keys, const { created_at, tags } = newest;
createdAt: created_at, const keys = tags.filter((t) => t[0] === "p").map((t) => t[1]);
} return {
} keys,
return { createdAt: 0, keys: [] } createdAt: created_at,
};
}
return { createdAt: 0, keys: [] };
} }
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] { export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
let lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey); let lists = feed?.notes.filter(
return getMutedKeys(lists).keys; (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"; import { System } from "Nostr/System";
export function useUserProfile(pubKey: HexKey): MetadataCache | undefined { export function useUserProfile(pubKey: HexKey): MetadataCache | undefined {
const users = useKey(pubKey); const users = useKey(pubKey);
useEffect(() => { useEffect(() => {
if (pubKey) { if (pubKey) {
System.TrackMetadata(pubKey); System.TrackMetadata(pubKey);
return () => System.UntrackMetadata(pubKey); return () => System.UntrackMetadata(pubKey);
} }
}, [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 { useEffect(() => {
const users = useKeys(pubKeys); if (pubKeys) {
System.TrackMetadata(pubKeys);
return () => System.UntrackMetadata(pubKeys);
}
}, [pubKeys]);
useEffect(() => { return users;
if (pubKeys) {
System.TrackMetadata(pubKeys);
return () => System.UntrackMetadata(pubKeys);
}
}, [pubKeys]);
return users;
} }

View File

@ -2,12 +2,17 @@ import { useSyncExternalStore } from "react";
import { System } from "Nostr/System"; import { System } from "Nostr/System";
import { CustomHook, StateSnapshot } from "Nostr/Connection"; import { CustomHook, StateSnapshot } from "Nostr/Connection";
const noop = (f: CustomHook) => { return () => { }; }; const noop = (f: CustomHook) => {
return () => {};
};
const noopState = (): StateSnapshot | undefined => { const noopState = (): StateSnapshot | undefined => {
return undefined; return undefined;
}; };
export default function useRelayState(addr: string) { export default function useRelayState(addr: string) {
let c = System.Sockets.get(addr); let c = System.Sockets.get(addr);
return useSyncExternalStore<StateSnapshot | undefined>(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState); 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"; import { db } from "Db";
export type NoteStore = { export type NoteStore = {
notes: Array<TaggedRawEvent>, notes: Array<TaggedRawEvent>;
end: boolean end: boolean;
}; };
export type UseSubscriptionOptions = { export type UseSubscriptionOptions = {
leaveOpen: boolean, leaveOpen: boolean;
cache: boolean cache: boolean;
} };
interface ReducerArg { interface ReducerArg {
type: "END" | "EVENT" | "CLEAR", type: "END" | "EVENT" | "CLEAR";
ev?: TaggedRawEvent | Array<TaggedRawEvent>, ev?: TaggedRawEvent | Array<TaggedRawEvent>;
end?: boolean end?: boolean;
} }
function notesReducer(state: NoteStore, arg: ReducerArg) { function notesReducer(state: NoteStore, arg: ReducerArg) {
if (arg.type === "END") { 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;
}
return { return {
notes: [ notes: state.notes,
...state.notes, end: arg.end!,
...evs
]
} as NoteStore; } 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 = { const initStore: NoteStore = {
notes: [], notes: [],
end: false end: false,
}; };
export interface UseSubscriptionState { export interface UseSubscriptionState {
store: NoteStore, store: NoteStore;
clear: () => void, clear: () => void;
append: (notes: TaggedRawEvent[]) => void append: (notes: TaggedRawEvent[]) => void;
} }
/** /**
@ -70,121 +67,131 @@ export interface UseSubscriptionState {
const DebounceMs = 200; const DebounceMs = 200;
/** /**
* *
* @param {Subscriptions} sub * @param {Subscriptions} sub
* @param {any} opt * @param {any} opt
* @returns * @returns
*/ */
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions): UseSubscriptionState { export default function useSubscription(
const [state, dispatch] = useReducer(notesReducer, initStore); sub: Subscriptions | null,
const [debounceOutput, setDebounceOutput] = useState<number>(0); options?: UseSubscriptionOptions
const [subDebounce, setSubDebounced] = useState<Subscriptions>(); ): UseSubscriptionState {
const useCache = useMemo(() => options?.cache === true, [options]); 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(() => { useEffect(() => {
if (sub) { if (sub) {
return debounce(DebounceMs, () => { return debounce(DebounceMs, () => {
setSubDebounced(sub); 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
});
}
} }
}, [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 * Lookup cached copy of feed
*/ */
const PreloadNotes = async (id: string): Promise<TaggedRawEvent[]> => { const PreloadNotes = async (id: string): Promise<TaggedRawEvent[]> => {
const feed = await db.feeds.get(id); const feed = await db.feeds.get(id);
if (feed) { if (feed) {
const events = await db.events.bulkGet(feed.ids); const events = await db.events.bulkGet(feed.ids);
return events.filter(a => a !== undefined).map(a => a!); return events.filter((a) => a !== undefined).map((a) => a!);
} }
return []; return [];
} };
const TrackNotesInFeed = async (id: string, notes: TaggedRawEvent[]) => { const TrackNotesInFeed = async (id: string, notes: TaggedRawEvent[]) => {
const existing = await db.feeds.get(id); const existing = await db.feeds.get(id);
const ids = Array.from(new Set([...(existing?.ids || []), ...notes.map(a => a.id)])); const ids = Array.from(
const since = notes.reduce((acc, v) => acc > v.created_at ? v.created_at : acc, +Infinity); new Set([...(existing?.ids || []), ...notes.map((a) => a.id)])
const until = notes.reduce((acc, v) => acc < v.created_at ? v.created_at : acc, -Infinity); );
await db.feeds.put({ id, ids, since, until }); 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"; import { debounce } from "Util";
export default function useThreadFeed(id: u256) { export default function useThreadFeed(id: u256) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]); const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences); const pref = useSelector<RootState, UserPreferences>(
(s) => s.login.preferences
);
function addId(id: u256[]) { function addId(id: u256[]) {
setTrackingEvent((s) => { setTrackingEvent((s) => {
let orig = new Set(s); let orig = new Set(s);
if (id.some(a => !orig.has(a))) { if (id.some((a) => !orig.has(a))) {
let tmp = new Set([...s, ...id]); let tmp = new Set([...s, ...id]);
return Array.from(tmp); return Array.from(tmp);
} else { } else {
return s; 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(() => { return main.store;
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;
} }

View File

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

View File

@ -5,13 +5,13 @@ import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription from "./Subscription"; import useSubscription from "./Subscription";
export default function useZapsFeed(pubkey: HexKey) { export default function useZapsFeed(pubkey: HexKey) {
const sub = useMemo(() => { const sub = useMemo(() => {
let x = new Subscriptions(); let x = new Subscriptions();
x.Id = `zaps:${pubkey.slice(0, 12)}`; x.Id = `zaps:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ZapReceipt]); x.Kinds = new Set([EventKind.ZapReceipt]);
x.PTags = new Set([pubkey]); x.PTags = new Set([pubkey]);
return x; return x;
}, [pubkey]); }, [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 () => el.removeEventListener("wheel", onWheel);
} }
}, []); }, []);
return elRef as LegacyRef<HTMLDivElement> | undefined return elRef as LegacyRef<HTMLDivElement> | undefined;
} }
export default useHorizontalScroll; export default useHorizontalScroll;

View File

@ -5,74 +5,93 @@ import { HexKey } from "Nostr";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { setMuted, setBlocked } from "State/Login"; import { setMuted, setBlocked } from "State/Login";
export default function useModeration() { export default function useModeration() {
const dispatch = useDispatch() const dispatch = useDispatch();
const { blocked, muted } = useSelector((s: RootState) => s.login) const { blocked, muted } = useSelector((s: RootState) => s.login);
const publisher = useEventPublisher() const publisher = useEventPublisher();
async function setMutedList(pub: HexKey[], priv: HexKey[]) { async function setMutedList(pub: HexKey[], priv: HexKey[]) {
try { try {
const ev = await publisher.muted(pub, priv) const ev = await publisher.muted(pub, priv);
console.debug(ev); console.debug(ev);
publisher.broadcast(ev) publisher.broadcast(ev);
} catch (error) { } catch (error) {
console.debug("Couldn't change mute list") console.debug("Couldn't change mute list");
} }
} }
function isMuted(id: HexKey) { function isMuted(id: HexKey) {
return muted.includes(id) || blocked.includes(id) return muted.includes(id) || blocked.includes(id);
} }
function isBlocked(id: HexKey) { function isBlocked(id: HexKey) {
return blocked.includes(id) return blocked.includes(id);
} }
function unmute(id: HexKey) { function unmute(id: HexKey) {
const newMuted = muted.filter(p => p !== id) const newMuted = muted.filter((p) => p !== id);
dispatch(setMuted({ dispatch(
createdAt: new Date().getTime(), setMuted({
keys: newMuted createdAt: new Date().getTime(),
})) keys: newMuted,
setMutedList(newMuted, blocked) })
);
setMutedList(newMuted, blocked);
} }
function unblock(id: HexKey) { function unblock(id: HexKey) {
const newBlocked = blocked.filter(p => p !== id) const newBlocked = blocked.filter((p) => p !== id);
dispatch(setBlocked({ dispatch(
createdAt: new Date().getTime(), setBlocked({
keys: newBlocked createdAt: new Date().getTime(),
})) keys: newBlocked,
setMutedList(muted, newBlocked) })
);
setMutedList(muted, newBlocked);
} }
function mute(id: HexKey) { function mute(id: HexKey) {
const newMuted = muted.includes(id) ? muted : muted.concat([id]) const newMuted = muted.includes(id) ? muted : muted.concat([id]);
setMutedList(newMuted, blocked) setMutedList(newMuted, blocked);
dispatch(setMuted({ dispatch(
createdAt: new Date().getTime(), setMuted({
keys: newMuted createdAt: new Date().getTime(),
})) keys: newMuted,
})
);
} }
function block(id: HexKey) { function block(id: HexKey) {
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]) const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]);
setMutedList(muted, newBlocked) setMutedList(muted, newBlocked);
dispatch(setBlocked({ dispatch(
createdAt: new Date().getTime(), setBlocked({
keys: newBlocked createdAt: new Date().getTime(),
})) keys: newBlocked,
})
);
} }
function muteAll(ids: HexKey[]) { function muteAll(ids: HexKey[]) {
const newMuted = Array.from(new Set(muted.concat(ids))) const newMuted = Array.from(new Set(muted.concat(ids)));
setMutedList(newMuted, blocked) setMutedList(newMuted, blocked);
dispatch(setMuted({ dispatch(
createdAt: new Date().getTime(), setMuted({
keys: newMuted 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"; import { useEffect } from "react";
declare global { declare global {
interface Window { interface Window {
webln?: { webln?: {
enabled: boolean, enabled: boolean;
enable: () => Promise<void>, enable: () => Promise<void>;
sendPayment: (pr: string) => Promise<any> sendPayment: (pr: string) => Promise<any>;
} };
} }
} }
export default function useWebln(enable = true) { export default function useWebln(enable = true) {
const maybeWebLn = "webln" in window ? window.webln : null const maybeWebLn = "webln" in window ? window.webln : null;
useEffect(() => { useEffect(() => {
if (maybeWebLn && !maybeWebLn.enabled && enable) { if (maybeWebLn && !maybeWebLn.enabled && enable) {
maybeWebLn.enable().catch((error) => { 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 = () => { const ArrowBack = () => {
return ( return (
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> 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> </svg>
) );
} };
export default ArrowBack export default ArrowBack;

View File

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

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