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
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
title: ""
labels: ""
assignees: ""
---
**Describe the bug**
@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
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.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -1,10 +1,9 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
title: ""
labels: ""
assignees: ""
---
**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" {
const value: any
export default value
const value: any;
export default value;
}
declare module "*.svg" {
const value: any
export default value
const value: any;
export default value;
}
declare module "*.webp" {
const value: any
export default value
}
const value: any;
export default value;
}

View File

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

View File

@ -1,23 +1,26 @@
<!DOCTYPE html>
<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>
<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;" />
<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>
<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>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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