Add prettier formatting (#214)
* chore: add prettier * chore: format codebase
This commit is contained in:
parent
015f799cf7
commit
5ad4971fc0
10
.github/ISSUE_TEMPLATE/bug_report.md
vendored
10
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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,11 +24,13 @@ 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]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
|
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -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
1
.prettierrc.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
12
d.ts
12
d.ts
@ -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;
|
||||
}
|
@ -72,5 +72,8 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "2.8.3"
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,18 @@
|
||||
<!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="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;" />
|
||||
<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" />
|
||||
@ -19,5 +23,4 @@
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
31
src/Const.ts
31
src/Const.ts
@ -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,7 +35,7 @@ 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
|
||||
@ -41,7 +43,7 @@ export const ProfileCacheExpire = (1_000 * 60 * 5);
|
||||
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://atlas.nostr.land", { read: true, write: true }],
|
||||
]);
|
||||
|
||||
/**
|
||||
@ -80,12 +82,14 @@ export const RecommendedFollows = [
|
||||
/**
|
||||
* 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]+)/;
|
||||
|
@ -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,9 +26,14 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { useState } from "react";
|
||||
|
||||
export default function AsyncButton(props: any) {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@ -13,15 +13,19 @@ export default function AsyncButton(props: any) {
|
||||
await f;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" disabled={loading} {...props} onClick={(e) => handle(e)}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
{...props}
|
||||
onClick={(e) => handle(e)}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
@ -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;
|
||||
|
@ -7,7 +7,7 @@
|
||||
}
|
||||
|
||||
.back-button svg {
|
||||
margin-right: .5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
|
@ -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;
|
||||
|
@ -2,11 +2,11 @@ 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
|
||||
@ -15,7 +15,7 @@ const BlockButton = ({ pubkey }: BlockButtonProps) => {
|
||||
<button className="secondary" type="button" onClick={() => block(pubkey)}>
|
||||
Block
|
||||
</button>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockButton
|
||||
export default BlockButton;
|
||||
|
@ -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,11 +10,11 @@ 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 { publicKey } = useSelector((s: RootState) => s.login);
|
||||
const { blocked, muted } = useModeration();
|
||||
|
||||
return (
|
||||
@ -21,19 +22,33 @@ export default function BlockList({ variant }: BlockListProps) {
|
||||
{variant === "muted" && (
|
||||
<>
|
||||
<h4>{muted.length} muted</h4>
|
||||
{muted.map(a => {
|
||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
||||
{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} />
|
||||
{blocked.map((a) => {
|
||||
return (
|
||||
<ProfilePreview
|
||||
actions={<BlockButton pubkey={a} />}
|
||||
pubkey={a}
|
||||
options={{ about: false }}
|
||||
key={a}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 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 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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,18 +13,22 @@ 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 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 otherPubkey = isMe
|
||||
? pubKey
|
||||
: props.data.tags.find((a) => a[0] === "p")![1];
|
||||
|
||||
async function decrypt() {
|
||||
let e = new Event(props.data);
|
||||
@ -45,10 +49,17 @@ export default function DM(props: DMProps) {
|
||||
|
||||
return (
|
||||
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
|
||||
<div><NoteTime from={props.data.created_at * 1000} fallback={'Just now'} /></div>
|
||||
<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} />
|
||||
<Text
|
||||
content={content}
|
||||
tags={[]}
|
||||
users={new Map()}
|
||||
creator={otherPubkey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -8,14 +8,16 @@ 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 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);
|
||||
@ -31,9 +33,9 @@ export default function FollowButton(props: FollowButtonProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
|
||||
onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}
|
||||
onClick={() => (isFollowing ? unfollow(pubkey) : follow(pubkey))}
|
||||
>
|
||||
{isFollowing ? 'Unfollow' : 'Follow'}
|
||||
{isFollowing ? "Unfollow" : "Follow"}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -3,10 +3,13 @@ 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) {
|
||||
export default function FollowListBase({
|
||||
pubkeys,
|
||||
title,
|
||||
}: FollowListBaseProps) {
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
async function followAll() {
|
||||
@ -18,9 +21,17 @@ export default function FollowListBase({ pubkeys, title }: FollowListBaseProps)
|
||||
<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>
|
||||
<button
|
||||
className="transparent"
|
||||
type="button"
|
||||
onClick={() => followAll()}
|
||||
>
|
||||
Follow All
|
||||
</button>
|
||||
</div>
|
||||
{pubkeys?.map(a => <ProfilePreview pubkey={a} key={a} />)}
|
||||
{pubkeys?.map((a) => (
|
||||
<ProfilePreview pubkey={a} key={a} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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 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))];
|
||||
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`} />
|
||||
);
|
||||
}
|
@ -5,7 +5,7 @@ import FollowListBase from "Element/FollowListBase";
|
||||
import { getFollowers } from "Feed/FollowsFeed";
|
||||
|
||||
export interface FollowsListProps {
|
||||
pubkey: HexKey
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
export default function FollowsList({ pubkey }: FollowsListProps) {
|
||||
@ -15,5 +15,7 @@ export default function FollowsList({ pubkey }: FollowsListProps) {
|
||||
return getFollowers(feed.store, pubkey);
|
||||
}, [feed]);
|
||||
|
||||
return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
|
||||
return (
|
||||
<FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -7,12 +7,14 @@ 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);
|
||||
const loginPubKey = useSelector<RootState, HexKey | undefined>(
|
||||
(s) => s.login.publicKey
|
||||
);
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
return getFollowers(feed.store, pubkey);
|
||||
@ -21,8 +23,6 @@ export default function FollowsYou({ pubkey }: FollowsYouProps ) {
|
||||
const followsMe = pubkeys.includes(loginPubKey!) ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ followsMe ? <span className="follows-you">follows you</span> : null }
|
||||
</>
|
||||
)
|
||||
<>{followsMe ? <span className="follows-you">follows you</span> : null}</>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 {
|
||||
@ -9,26 +9,43 @@ import {
|
||||
TidalRegex,
|
||||
SoundCloudRegex,
|
||||
MixCloudRegex,
|
||||
SpotifyRegex
|
||||
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 }) {
|
||||
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);
|
||||
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>
|
||||
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;
|
||||
@ -37,7 +54,8 @@ export default function HyperText({ link, creator }: { link: string, creator: He
|
||||
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;
|
||||
const extension =
|
||||
FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||
if (extension) {
|
||||
switch (extension) {
|
||||
case "gif":
|
||||
@ -51,24 +69,35 @@ export default function HyperText({ link, creator }: { link: string, creator: He
|
||||
case "wav":
|
||||
case "mp3":
|
||||
case "ogg": {
|
||||
return <audio key={url.toString()} src={url.toString()} controls />
|
||||
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 />
|
||||
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
|
||||
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 (
|
||||
<>
|
||||
@ -84,22 +113,40 @@ export default function HyperText({ link, creator }: { link: string, creator: He
|
||||
/>
|
||||
<br />
|
||||
</>
|
||||
)
|
||||
);
|
||||
} else if (tidalId) {
|
||||
return <TidalEmbed link={a} />
|
||||
return <TidalEmbed link={a} />;
|
||||
} else if (soundcloundId) {
|
||||
return <SoundCloudEmbed link={a} />
|
||||
return <SoundCloudEmbed link={a} />;
|
||||
} else if (mixcloudId) {
|
||||
return <MixCloudEmbed link={a} />
|
||||
return <MixCloudEmbed link={a} />;
|
||||
} else if (spotifyId) {
|
||||
return <SpotifyEmbed link={a} />
|
||||
return <SpotifyEmbed link={a} />;
|
||||
} else {
|
||||
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
|
||||
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>
|
||||
|
||||
} catch (error) {}
|
||||
return (
|
||||
<a
|
||||
href={a}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ext"
|
||||
>
|
||||
{a}
|
||||
</a>
|
||||
);
|
||||
}, [link]);
|
||||
|
||||
return render();
|
||||
|
@ -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;
|
||||
|
@ -9,7 +9,7 @@ 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;
|
||||
@ -20,18 +20,26 @@ export default function Invoice(props: InvoiceProps) {
|
||||
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 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,
|
||||
amount: !isNaN(amount) ? amount / 1000 : 0,
|
||||
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
|
||||
description,
|
||||
expired: false
|
||||
expired: false,
|
||||
};
|
||||
if (ret.expire) {
|
||||
ret.expired = ret.expire < (new Date().getTime() / 1000);
|
||||
ret.expired = ret.expire < new Date().getTime() / 1000;
|
||||
}
|
||||
return ret;
|
||||
} catch (e) {
|
||||
@ -40,18 +48,23 @@ export default function Invoice(props: InvoiceProps) {
|
||||
}, [invoice]);
|
||||
|
||||
const [isPaid, setIsPaid] = useState(false);
|
||||
const isExpired = info?.expired
|
||||
const amount = info?.amount ?? 0
|
||||
const description = info?.description
|
||||
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)} />
|
||||
<SendSats
|
||||
title="Pay Invoice"
|
||||
invoice={invoice}
|
||||
show={showInvoice}
|
||||
onClose={() => setShowInvoice(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function payInvoice(e: any) {
|
||||
@ -59,7 +72,7 @@ export default function Invoice(props: InvoiceProps) {
|
||||
if (webln?.enabled) {
|
||||
try {
|
||||
await webln.sendPayment(invoice);
|
||||
setIsPaid(true)
|
||||
setIsPaid(true);
|
||||
} catch (error) {
|
||||
setShowInvoice(true);
|
||||
}
|
||||
@ -70,15 +83,18 @@ export default function Invoice(props: InvoiceProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`note-invoice flex ${isExpired ? 'expired' : ''} ${isPaid ? 'paid' : ''}`}>
|
||||
<div className="invoice-header">
|
||||
{header()}
|
||||
</div>
|
||||
<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>
|
||||
{amount.toLocaleString()}{" "}
|
||||
<span className="sats">sat{amount === 1 ? "" : "s"}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
@ -86,18 +102,14 @@ export default function Invoice(props: InvoiceProps) {
|
||||
<div className="invoice-body">
|
||||
{description && <p>{description}</p>}
|
||||
{isPaid ? (
|
||||
<div className="paid">
|
||||
Paid
|
||||
</div>
|
||||
<div className="paid">Paid</div>
|
||||
) : (
|
||||
<button disabled={isExpired} type="button" onClick={payInvoice}>
|
||||
{isExpired ? "Expired" : "Pay"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,15 @@
|
||||
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 }) {
|
||||
export default function LoadMore({
|
||||
onLoadMore,
|
||||
shouldLoadMore,
|
||||
children,
|
||||
}: {
|
||||
onLoadMore: () => void;
|
||||
shouldLoadMore: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
const [tick, setTick] = useState<number>(0);
|
||||
|
||||
@ -13,10 +21,14 @@ export default function LoadMore({ onLoadMore, shouldLoadMore, children }: { onL
|
||||
|
||||
useEffect(() => {
|
||||
let t = setInterval(() => {
|
||||
setTick(x => x += 1);
|
||||
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>
|
||||
);
|
||||
}
|
@ -4,11 +4,18 @@ import { useNavigate } from "react-router-dom";
|
||||
import { logout } from "State/Login";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const dispatch = useDispatch()
|
||||
const navigate = useNavigate()
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ 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);
|
||||
@ -17,5 +17,9 @@ export default function Mention({ pubkey }: { pubkey: HexKey }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -3,10 +3,14 @@ import { useSelector } from "react-redux";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
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";
|
||||
|
||||
@ -21,7 +25,7 @@ const MixCloudEmbed = ({link}: {link: string}) => {
|
||||
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default MixCloudEmbed;
|
@ -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);
|
||||
@ -25,8 +25,8 @@ 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 className = props.className || "";
|
||||
useOnClickOutside(ref, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add("scroll-lock");
|
||||
@ -39,5 +39,5 @@ export default function Modal(props: ModalProps) {
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -2,11 +2,11 @@ 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
|
||||
@ -15,7 +15,7 @@ const MuteButton = ({ pubkey }: MuteButtonProps) => {
|
||||
<button type="button" onClick={() => mute(pubkey)}>
|
||||
Mute
|
||||
</button>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default MuteButton
|
||||
export default MuteButton;
|
||||
|
@ -1,23 +1,24 @@
|
||||
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 feed = useMutedFeed(pubkey);
|
||||
const pubkeys = useMemo(() => {
|
||||
return getMuted(feed.store, pubkey);
|
||||
}, [feed, pubkey]);
|
||||
const hasAllMuted = pubkeys.every(isMuted)
|
||||
const hasAllMuted = pubkeys.every(isMuted);
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
@ -25,14 +26,23 @@ export default function MutedList({ pubkey }: MutedListProps) {
|
||||
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
|
||||
<button
|
||||
disabled={hasAllMuted || pubkeys.length === 0}
|
||||
className="transparent" type="button" onClick={() => muteAll(pubkeys)}
|
||||
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} />
|
||||
{pubkeys?.map((a) => {
|
||||
return (
|
||||
<ProfilePreview
|
||||
actions={<MuteButton pubkey={a} />}
|
||||
pubkey={a}
|
||||
options={{ about: false }}
|
||||
key={a}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -47,5 +47,5 @@
|
||||
}
|
||||
|
||||
.nip05 .badge {
|
||||
margin: .1em .2em;
|
||||
margin: 0.1em 0.2em;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
HandleAvailability,
|
||||
ServiceErrorCode,
|
||||
HandleRegisterResponse,
|
||||
CheckRegisterResponse
|
||||
CheckRegisterResponse,
|
||||
} from "Nip05/ServiceProvider";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import SendSats from "Element/SendSats";
|
||||
@ -19,45 +19,55 @@ 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 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 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 [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)
|
||||
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;
|
||||
let defaultDomain =
|
||||
svc.domains.find((a) => a.default)?.name || svc.domains[0].name;
|
||||
setDomain(defaultDomain);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}, [props, svc]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -72,15 +82,19 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
|
||||
return;
|
||||
}
|
||||
let rx = new RegExp(domainConfig?.regex[0] ?? "", domainConfig?.regex[1] ?? "");
|
||||
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) {
|
||||
svc
|
||||
.CheckAvailable(handle, domain)
|
||||
.then((a) => {
|
||||
if ("error" in a) {
|
||||
setError(a as ServiceError);
|
||||
} else {
|
||||
setAvailabilityResponse(a as HandleAvailability);
|
||||
@ -95,7 +109,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
if (registerResponse && showInvoice) {
|
||||
let t = setInterval(async () => {
|
||||
let status = await svc.CheckRegistration(registerResponse.token);
|
||||
if ('error' in status) {
|
||||
if ("error" in status) {
|
||||
setError(status);
|
||||
setRegisterResponse(undefined);
|
||||
setShowInvoice(false);
|
||||
@ -111,7 +125,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
}, 2_000);
|
||||
return () => clearInterval(t);
|
||||
}
|
||||
}, [registerResponse, showInvoice, svc])
|
||||
}, [registerResponse, showInvoice, svc]);
|
||||
|
||||
function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
|
||||
let whyMap = new Map([
|
||||
@ -132,7 +146,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
}
|
||||
|
||||
let rsp = await svc.RegisterHandle(handle, domain, pubkey);
|
||||
if ('error' in rsp) {
|
||||
if ("error" in rsp) {
|
||||
setError(rsp);
|
||||
} else {
|
||||
setRegisterResponse(rsp);
|
||||
@ -144,7 +158,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
if (user) {
|
||||
let newProfile = {
|
||||
...user,
|
||||
nip05: `${handle}@${domain}`
|
||||
nip05: `${handle}@${domain}`,
|
||||
} as UserMetadata;
|
||||
let ev = await publisher.metadata(newProfile);
|
||||
publisher.broadcast(ev);
|
||||
@ -156,41 +170,92 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
<>
|
||||
<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>
|
||||
<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())} />
|
||||
{!registerStatus && (
|
||||
<div className="flex mb10">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Handle"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value.toLowerCase())}
|
||||
/>
|
||||
@
|
||||
<select value={domain} onChange={(e) => setDomain(e.target.value)}>
|
||||
{serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)}
|
||||
{serviceConfig?.domains.map((a) => (
|
||||
<option key={a.name}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>}
|
||||
{availabilityResponse?.available && !registerStatus && <div className="flex">
|
||||
</div>
|
||||
)}
|
||||
{availabilityResponse?.available && !registerStatus && (
|
||||
<div className="flex">
|
||||
<div className="mr10">
|
||||
{availabilityResponse.quote?.price.toLocaleString()} sats<br />
|
||||
{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>}
|
||||
<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">
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<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>}
|
||||
<AsyncButton onClick={() => updateProfile(handle, domain)}>
|
||||
Add to Profile
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -66,7 +66,9 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
@ -148,34 +167,38 @@ export default function Note(props: NoteProps) {
|
||||
{pubMentions}
|
||||
{others}
|
||||
</>
|
||||
) : replyId && (
|
||||
) : (
|
||||
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>
|
||||
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>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,33 +206,56 @@ export default function Note(props: NoteProps) {
|
||||
if (!inView) return null;
|
||||
return (
|
||||
<>
|
||||
{options.showHeader ?
|
||||
{options.showHeader ? (
|
||||
<div className="header flex">
|
||||
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
||||
{options.showTime ?
|
||||
<ProfileImage
|
||||
pubkey={ev.RootPubKey}
|
||||
subHeader={replyTag() ?? undefined}
|
||||
/>
|
||||
{options.showTime ? (
|
||||
<div className="info">
|
||||
<NoteTime from={ev.CreatedAt * 1000} />
|
||||
</div> : null}
|
||||
</div> : null}
|
||||
</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)}>
|
||||
<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.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
|
||||
);
|
||||
}
|
||||
|
@ -29,11 +29,15 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
|
@ -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>
|
||||
|
@ -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 }) => {
|
||||
return reactions?.reduce(
|
||||
(acc, { content }) => {
|
||||
let r = normalizeReaction(content);
|
||||
const amount = acc[r] || 0
|
||||
return { ...acc, [r]: amount + 1 }
|
||||
}, {
|
||||
const amount = acc[r] || 0;
|
||||
return { ...acc, [r]: amount + 1 };
|
||||
},
|
||||
{
|
||||
[Reaction.Positive]: 0,
|
||||
[Reaction.Negative]: 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])}
|
||||
|
||||
Dislike
|
||||
Dislike
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => share()}>
|
||||
@ -237,7 +289,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -246,16 +298,22 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
<div className="footer-reactions">
|
||||
{tipButton()}
|
||||
{reactionIcons()}
|
||||
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
|
||||
<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">
|
||||
<Menu
|
||||
menuButton={
|
||||
<div className="reaction-pill">
|
||||
<div className="reaction-pill-icon">
|
||||
<Dots />
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
menuClassName="ctx-menu"
|
||||
>
|
||||
{menuItems()}
|
||||
@ -281,5 +339,5 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
<ZapsSummary zaps={zaps} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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 : ''}`
|
||||
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 className="body">{props.children}</div>
|
||||
<div className="footer"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -12,18 +12,18 @@ 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 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");
|
||||
let eTags = ev.Tags.filter((a) => a.Key === "e");
|
||||
if (eTags.length > 0) {
|
||||
return eTags[0].Event;
|
||||
}
|
||||
@ -39,7 +39,11 @@ export default function NoteReaction(props: NoteReactionProps) {
|
||||
* Some clients embed the reposted note in the content
|
||||
*/
|
||||
function extractRoot() {
|
||||
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0 && ev.Content !== "#[0]") {
|
||||
if (
|
||||
ev?.Kind === EventKind.Repost &&
|
||||
ev.Content.length > 0 &&
|
||||
ev.Content !== "#[0]"
|
||||
) {
|
||||
try {
|
||||
let r: RawEvent = JSON.parse(ev.Content);
|
||||
return r as TaggedRawEvent;
|
||||
@ -51,7 +55,7 @@ export default function NoteReaction(props: NoteReactionProps) {
|
||||
}
|
||||
|
||||
const root = extractRoot();
|
||||
const isOpMuted = root && isMuted(root.pubkey)
|
||||
const isOpMuted = root && isMuted(root.pubkey);
|
||||
const opt = {
|
||||
showHeader: ev?.Kind === EventKind.Repost,
|
||||
showFooter: false,
|
||||
@ -67,7 +71,13 @@ export default function NoteReaction(props: NoteReactionProps) {
|
||||
</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}
|
||||
{!root && refEvent ? (
|
||||
<p>
|
||||
<Link to={eventLink(refEvent)}>
|
||||
#{hexToBech32("note", refEvent).substring(0, 12)}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -5,26 +5,37 @@ 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 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 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" });
|
||||
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')}`;
|
||||
return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
} else if (absAgo < MinuteInMs) {
|
||||
return fallback
|
||||
return fallback;
|
||||
} else {
|
||||
let mins = Math.floor(absAgo / MinuteInMs);
|
||||
if (ago < 0) {
|
||||
@ -37,16 +48,20 @@ export default function NoteTime(props: NoteTimeProps) {
|
||||
useEffect(() => {
|
||||
setTime(calcTime());
|
||||
let t = setInterval(() => {
|
||||
setTime(s => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -26,7 +26,7 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
@ -2,17 +2,17 @@ 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
|
||||
};
|
||||
pubkey: string;
|
||||
clickable?: boolean;
|
||||
className?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
function NoteLabel({ pubkey, link }: NoteToSelfProps) {
|
||||
const user = useUserProfile(pubkey);
|
||||
@ -21,36 +21,44 @@ function NoteLabel({pubkey, link}:NoteToSelfProps) {
|
||||
Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" />
|
||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function NoteToSelf({ pubkey, clickable, className, link }: NoteToSelfProps) {
|
||||
export default function NoteToSelf({
|
||||
pubkey,
|
||||
clickable,
|
||||
className,
|
||||
link,
|
||||
}: NoteToSelfProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const clickLink = () => {
|
||||
if (clickable) {
|
||||
navigate(link ?? profileLink(pubkey))
|
||||
}
|
||||
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" />
|
||||
<FontAwesomeIcon
|
||||
onClick={clickLink}
|
||||
className="note-to-self"
|
||||
icon={faBook}
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="f-grow">
|
||||
<div className="name">
|
||||
{clickable && (
|
||||
{(clickable && (
|
||||
<Link to={link ?? profileLink(pubkey)}>
|
||||
<NoteLabel pubkey={pubkey} />
|
||||
</Link>
|
||||
) || (
|
||||
<NoteLabel pubkey={pubkey} />
|
||||
)}
|
||||
)) || <NoteLabel pubkey={pubkey} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -4,20 +4,26 @@ 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) {
|
||||
export default function ProfileImage({
|
||||
pubkey,
|
||||
subHeader,
|
||||
showUsername = true,
|
||||
className,
|
||||
link,
|
||||
}: ProfileImageProps) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserProfile(pubkey);
|
||||
|
||||
@ -28,26 +34,34 @@ export default function ProfileImage({ pubkey, subHeader, showUsername = true, c
|
||||
return (
|
||||
<div className={`pfp${className ? ` ${className}` : ""}`}>
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
|
||||
<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)}>
|
||||
<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 className="subheader">{subHeader}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) {
|
||||
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!;
|
||||
|
@ -8,12 +8,12 @@ import { HexKey } from "Nostr";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
export interface ProfilePreviewProps {
|
||||
pubkey: HexKey,
|
||||
pubkey: HexKey;
|
||||
options?: {
|
||||
about?: boolean
|
||||
},
|
||||
actions?: ReactNode,
|
||||
className?: string
|
||||
about?: boolean;
|
||||
};
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
const pubkey = props.pubkey;
|
||||
@ -21,22 +21,33 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
const options = {
|
||||
about: true,
|
||||
...props.options
|
||||
...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} />
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -9,10 +9,10 @@ export const ProxyImg = (props: any) => {
|
||||
useEffect(() => {
|
||||
if (src) {
|
||||
proxy(src, size)
|
||||
.then(a => setUrl(a))
|
||||
.then((a) => setUrl(a))
|
||||
.catch(console.warn);
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
return <img src={url} {...rest} />
|
||||
}
|
||||
return <img src={url} {...rest} />;
|
||||
};
|
||||
|
@ -2,12 +2,12 @@ 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) {
|
||||
@ -20,17 +20,17 @@ export default function QrCode(props: QrCodeProps) {
|
||||
height: props.height || 256,
|
||||
data: props.data,
|
||||
margin: 5,
|
||||
type: 'canvas',
|
||||
type: "canvas",
|
||||
image: props.avatar,
|
||||
dotsOptions: {
|
||||
type: 'rounded'
|
||||
type: "rounded",
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: 'extra-rounded'
|
||||
type: "extra-rounded",
|
||||
},
|
||||
imageOptions: {
|
||||
crossOrigin: "anonymous"
|
||||
}
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
});
|
||||
qrRef.current.innerHTML = "";
|
||||
qr.append(qrRef.current);
|
||||
@ -39,7 +39,7 @@ export default function QrCode(props: QrCodeProps) {
|
||||
let elm = document.createElement("a");
|
||||
elm.href = props.link!;
|
||||
elm.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
} else if (qrRef.current) {
|
||||
qrRef.current.innerHTML = "";
|
||||
@ -47,6 +47,9 @@ export default function QrCode(props: QrCodeProps) {
|
||||
}, [props.data, props.link]);
|
||||
|
||||
return (
|
||||
<div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div>
|
||||
<div
|
||||
className={`qr${props.className ? ` ${props.className}` : ""}`}
|
||||
ref={qrRef}
|
||||
></div>
|
||||
);
|
||||
}
|
@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
margin-left: .5em;
|
||||
margin-left: 0.5em;
|
||||
padding: 2px 10px;
|
||||
background-color: var(--gray);
|
||||
border-radius: 10px;
|
||||
|
@ -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,28 +18,32 @@ 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 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({
|
||||
dispatch(
|
||||
setRelays({
|
||||
relays: {
|
||||
...allRelaySettings,
|
||||
[props.addr]: o
|
||||
[props.addr]: o,
|
||||
},
|
||||
createdAt: Math.floor(new Date().getTime() / 1000)
|
||||
}));
|
||||
createdAt: Math.floor(new Date().getTime() / 1000),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
let latency = Math.floor(state?.avgLatency ?? 0);
|
||||
return (
|
||||
<>
|
||||
@ -45,20 +56,43 @@ export default function Relay(props: RelayProps) {
|
||||
<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
|
||||
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
|
||||
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`}
|
||||
<FontAwesomeIcon icon={faWifi} />{" "}
|
||||
{latency > 2000
|
||||
? `${(latency / 1000).toFixed(0)} secs`
|
||||
: `${latency.toLocaleString()} ms`}
|
||||
|
||||
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
|
||||
</div>
|
||||
@ -71,5 +105,5 @@ export default function Relay(props: RelayProps) {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
@ -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;
|
||||
|
@ -16,42 +16,44 @@ 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 { 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: "💜",
|
||||
@ -60,7 +62,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
50_000: "🔥",
|
||||
100_000: "🚀",
|
||||
1_000_000: "🤯",
|
||||
}
|
||||
};
|
||||
const [payService, setPayService] = useState<LNURLService>();
|
||||
const [amount, setAmount] = useState<number>(500);
|
||||
const [customAmount, setCustomAmount] = useState<number>();
|
||||
@ -75,7 +77,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
useEffect(() => {
|
||||
if (show && !props.invoice) {
|
||||
loadService()
|
||||
.then(a => setPayService(a!))
|
||||
.then((a) => setPayService(a!))
|
||||
.catch(() => setError("Failed to load LNURL service"));
|
||||
} else {
|
||||
setPayService(undefined);
|
||||
@ -91,7 +93,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
if (payService) {
|
||||
let min = (payService.minSendable ?? 0) / 1000;
|
||||
let max = (payService.maxSendable ?? 0) / 1000;
|
||||
return amounts.filter(a => a >= min && a <= max);
|
||||
return amounts.filter((a) => a >= min && a <= max);
|
||||
}
|
||||
return [];
|
||||
}, [payService]);
|
||||
@ -99,11 +101,11 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
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");
|
||||
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
|
||||
image: image ? image[1] : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@ -142,12 +144,15 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
|
||||
async function loadInvoice() {
|
||||
if (!amount || !payService) return null;
|
||||
let url = ''
|
||||
const amountParam = `amount=${Math.floor(amount * 1000)}`
|
||||
const commentParam = comment ? `&comment=${encodeURIComponent(comment)}` : ""
|
||||
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()))}`
|
||||
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}`;
|
||||
@ -170,7 +175,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
} catch (e) {
|
||||
setError("Failed to load invoice");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function custom() {
|
||||
let min = (payService?.minSendable ?? 1000) / 1000;
|
||||
@ -217,16 +222,20 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
<>
|
||||
<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)}>
|
||||
{serviceAmounts.map((a) => (
|
||||
<span
|
||||
className={`sat-amount ${amount === a ? "active" : ""}`}
|
||||
key={a}
|
||||
onClick={() => selectAmount(a)}
|
||||
>
|
||||
{emojis[a] && <>{emojis[a]} </>}
|
||||
{formatShort(a)}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{payService && custom()}
|
||||
<div className="flex">
|
||||
{(payService?.commentAllowed ?? 0) > 0 &&
|
||||
{(payService?.commentAllowed ?? 0) > 0 && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Comment"
|
||||
@ -234,10 +243,14 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
maxLength={payService?.commentAllowed}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
{(amount ?? 0) > 0 && (
|
||||
<button type="button" className="zap-action" onClick={() => loadInvoice()}>
|
||||
<button
|
||||
type="button"
|
||||
className="zap-action"
|
||||
onClick={() => loadInvoice()}
|
||||
>
|
||||
<div className="zap-action-container">
|
||||
<Zap /> Zap
|
||||
{target && ` ${target} `}
|
||||
@ -246,7 +259,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function payInvoice() {
|
||||
@ -263,7 +276,11 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
<div className="copy-action">
|
||||
<Copy text={pr} maxSize={26} />
|
||||
</div>
|
||||
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${pr}`)}>
|
||||
<button
|
||||
className="wallet-action"
|
||||
type="button"
|
||||
onClick={() => window.open(`lightning:${pr}`)}
|
||||
>
|
||||
Open Wallet
|
||||
</button>
|
||||
</>
|
||||
@ -271,7 +288,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function successAction() {
|
||||
@ -282,23 +299,19 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
<Check className="success mr10" />
|
||||
{success?.description ?? "Paid!"}
|
||||
</p>
|
||||
{success.url &&
|
||||
{success.url && (
|
||||
<p>
|
||||
<a
|
||||
href={success.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<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
|
||||
const title = target ? `${defaultTitle} to ${target}` : defaultTitle;
|
||||
if (!show) return null;
|
||||
return (
|
||||
<Modal className="lnurl-modal" onClose={onClose}>
|
||||
@ -308,9 +321,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
</div>
|
||||
<div className="lnurl-header">
|
||||
{author && <ProfileImage pubkey={author} showUsername={false} />}
|
||||
<h2>
|
||||
{props.title || title}
|
||||
</h2>
|
||||
<h2>{props.title || title}</h2>
|
||||
</div>
|
||||
{invoiceForm()}
|
||||
{error && <p className="error">{error}</p>}
|
||||
@ -318,5 +329,5 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
{successAction()}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
src={`https://w.soundcloud.com/player/?url=${link}`}
|
||||
></iframe>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoundCloudEmbed;
|
||||
|
@ -31,7 +31,6 @@
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
|
||||
.tabs > div {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -57,7 +57,10 @@
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.text img, .text video, .text iframe, .text audio {
|
||||
.text img,
|
||||
.text video,
|
||||
.text iframe,
|
||||
.text audio {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
margin: 10px auto;
|
||||
@ -65,7 +68,8 @@
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.text iframe, .text video {
|
||||
.text iframe,
|
||||
.text video {
|
||||
width: -webkit-fill-available;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
@ -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,59 +12,72 @@ 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 => {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(UrlRegex).map(a => {
|
||||
return f.split(UrlRegex).map((a) => {
|
||||
if (a.startsWith("http")) {
|
||||
return <HyperText link={a} creator={creator} />
|
||||
return <HyperText link={a} creator={creator} />;
|
||||
}
|
||||
return a;
|
||||
});
|
||||
}
|
||||
return f;
|
||||
}).flat();
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function extractMentions(frag: TextFragment) {
|
||||
return frag.body.map(f => {
|
||||
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);
|
||||
let ref = frag.tags?.find((a) => a.Index === idx);
|
||||
if (ref) {
|
||||
switch (ref.Key) {
|
||||
case "p": {
|
||||
return <Mention pubkey={ref.PubKey!} />
|
||||
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>;
|
||||
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 <Hashtag tag={ref.Hashtag!} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -75,50 +88,55 @@ export default function Text({ content, tags, creator, users }: TextProps) {
|
||||
});
|
||||
}
|
||||
return f;
|
||||
}).flat();
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function extractInvoices(fragments: Fragment[]) {
|
||||
return fragments.map(f => {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(InvoiceRegex).map(i => {
|
||||
return f.split(InvoiceRegex).map((i) => {
|
||||
if (i.toLowerCase().startsWith("lnbc")) {
|
||||
return <Invoice key={i} invoice={i} />
|
||||
return <Invoice key={i} invoice={i} />;
|
||||
} else {
|
||||
return i;
|
||||
}
|
||||
});
|
||||
}
|
||||
return f;
|
||||
}).flat();
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function extractHashtags(fragments: Fragment[]) {
|
||||
return fragments.map(f => {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(HashtagRegex).map(i => {
|
||||
return f.split(HashtagRegex).map((i) => {
|
||||
if (i.toLowerCase().startsWith("#")) {
|
||||
return <Hashtag tag={i.substring(1)} />
|
||||
return <Hashtag tag={i.substring(1)} />;
|
||||
} else {
|
||||
return i;
|
||||
}
|
||||
});
|
||||
}
|
||||
return f;
|
||||
}).flat();
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function transformLi(frag: TextFragment) {
|
||||
let fragments = transformText(frag)
|
||||
return <li>{fragments}</li>
|
||||
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>
|
||||
const fragments = transformText(frag);
|
||||
if (fragments.every((f) => typeof f === "string")) {
|
||||
return <p>{fragments}</p>;
|
||||
}
|
||||
return <>{fragments}</>
|
||||
return <>{fragments}</>;
|
||||
}
|
||||
|
||||
function transformText(frag: TextFragment) {
|
||||
@ -134,32 +152,42 @@ export default function Text({ content, tags, creator, users }: TextProps) {
|
||||
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
p: (x: any) => transformParagraph({ body: x.children ?? [], tags, users }),
|
||||
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) => {
|
||||
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')
|
||||
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(/\)$/, ' )');
|
||||
node.type = "text";
|
||||
node.value = content
|
||||
.slice(node.position.start.offset, node.position.end.offset)
|
||||
.replace(/\)$/, " )");
|
||||
return SKIP;
|
||||
}
|
||||
})
|
||||
}, [content]);
|
||||
return <ReactMarkdown
|
||||
});
|
||||
},
|
||||
[content]
|
||||
);
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className="text"
|
||||
components={components}
|
||||
remarkPlugins={[disableMarkdownLinks]}
|
||||
>{content}</ReactMarkdown>
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
@ -57,7 +60,7 @@
|
||||
}
|
||||
|
||||
.emoji-item .emoji {
|
||||
margin-right: .2em;
|
||||
margin-right: 0.2em;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -13,57 +13,72 @@ 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 className={className}></div>
|
||||
</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)
|
||||
const isLastSubthread = idx === notes.length - 1;
|
||||
const replies = getReplies(a.Id, chains);
|
||||
return (
|
||||
<>
|
||||
<div className={`subthread-container ${replies.length > 0 ? 'subthread-multi' : ''}`}>
|
||||
<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' : ''}`}
|
||||
className={`thread-note ${
|
||||
isLastSubthread && replies.length === 0 ? "is-last-note" : ""
|
||||
}`}
|
||||
data-ev={a}
|
||||
key={a.Id}
|
||||
related={related}
|
||||
/>
|
||||
<div className="line-container">
|
||||
</div>
|
||||
<div className="line-container"></div>
|
||||
</div>
|
||||
{replies.length > 0 && (
|
||||
<TierTwo
|
||||
@ -78,44 +93,51 @@ const Subthread = ({ active, path, from, notes, related, chains, onNavigate }: S
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return <div className="subthread">{notes.map(renderSubthread)}</div>;
|
||||
};
|
||||
|
||||
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
|
||||
note: NEvent;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="subthread">
|
||||
{notes.map(renderSubthread)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 className="line-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
{replies.length > 0 && (
|
||||
activeInReplies ? (
|
||||
{replies.length > 0 &&
|
||||
(activeInReplies ? (
|
||||
<TierThree
|
||||
active={active}
|
||||
path={path}
|
||||
@ -127,7 +149,11 @@ const ThreadNote = ({ active, note, isLast, path, isLastSubthread, from, related
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
) : (
|
||||
<Collapsed text="Show replies" collapsed={collapsed} setCollapsed={setCollapsed}>
|
||||
<Collapsed
|
||||
text="Show replies"
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
>
|
||||
<TierThree
|
||||
active={active}
|
||||
path={path}
|
||||
@ -139,14 +165,22 @@ const ThreadNote = ({ active, note, isLast, path, isLastSubthread, from, related
|
||||
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,7 +197,7 @@ 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
|
||||
active={active}
|
||||
@ -176,45 +210,62 @@ 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 && (
|
||||
{path.length <= 1 || !activeInReplies
|
||||
? replies.length > 0 && (
|
||||
<div className="show-more-container">
|
||||
<button className="show-more" type="button" onClick={() => onNavigate(from)}>
|
||||
<button
|
||||
className="show-more"
|
||||
type="button"
|
||||
onClick={() => onNavigate(from)}
|
||||
>
|
||||
Show replies
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
replies.length > 0 && (
|
||||
: replies.length > 0 && (
|
||||
<TierThree
|
||||
active={active}
|
||||
path={path.slice(1)}
|
||||
@ -225,58 +276,68 @@ const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains
|
||||
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 className="line-container"></div>
|
||||
</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));
|
||||
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 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) => {
|
||||
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)) {
|
||||
@ -294,87 +355,113 @@ export default function Thread(props: ThreadProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!root) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigated) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.Id === urlNoteHex) {
|
||||
setPath([root.Id])
|
||||
setNavigated(true)
|
||||
return
|
||||
setPath([root.Id]);
|
||||
setNavigated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let subthreadPath = []
|
||||
let parent = getParent(urlNoteHex, chains)
|
||||
let subthreadPath = [];
|
||||
let parent = getParent(urlNoteHex, chains);
|
||||
while (parent) {
|
||||
subthreadPath.unshift(parent)
|
||||
parent = getParent(parent, chains)
|
||||
subthreadPath.unshift(parent);
|
||||
parent = getParent(parent, chains);
|
||||
}
|
||||
setPath(subthreadPath)
|
||||
setNavigated(true)
|
||||
}, [root, navigated, urlNoteHex, 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));
|
||||
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' : ''}`
|
||||
const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`;
|
||||
if (note) {
|
||||
return <Note className={className} key={note.Id} data-ev={note} related={notes} />
|
||||
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])
|
||||
setPath([...path, to]);
|
||||
}
|
||||
|
||||
function renderChain(from: u256): ReactNode {
|
||||
if (!from || !chains) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
let replies = chains.get(from);
|
||||
if (replies) {
|
||||
return <Subthread active={urlNoteHex} path={path} from={from} notes={replies} related={notes} chains={chains} onNavigate={onNavigate} />
|
||||
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)
|
||||
const newPath = path.slice(0, path.length - 1);
|
||||
setPath(newPath);
|
||||
} else {
|
||||
navigate("/")
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content mt10">
|
||||
<BackButton onClick={goBack} text={path?.length > 1 ? "Parent" : "Back"} />
|
||||
<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 => {
|
||||
{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
|
||||
className={`thread-note thread-root ghost-root`}
|
||||
key={a}
|
||||
>
|
||||
Missing event{" "}
|
||||
<Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
||||
</NoteGhost>
|
||||
{renderChain(a)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
@ -385,9 +472,8 @@ export default function Thread(props: ThreadProps) {
|
||||
|
||||
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
|
||||
if (!from || !chains) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
let replies = chains.get(from);
|
||||
return replies ? replies : []
|
||||
return replies ? replies : [];
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,7 @@ async function oembedLookup (link: string) {
|
||||
const regexResult = TidalRegex.exec(link);
|
||||
|
||||
if (!regexResult) {
|
||||
return Promise.reject('Not a TIDAL link.');
|
||||
return Promise.reject("Not a TIDAL link.");
|
||||
}
|
||||
|
||||
const [, productType, productId] = regexResult;
|
||||
@ -18,33 +18,56 @@ async function oembedLookup (link: string) {
|
||||
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.');
|
||||
return Promise.reject("No iframe delivered.");
|
||||
}
|
||||
|
||||
return {
|
||||
source: iframe.getAttribute('src'),
|
||||
height: json.height
|
||||
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 extraStyles = link.includes("video")
|
||||
? { aspectRatio: "16 / 9" }
|
||||
: { height };
|
||||
|
||||
useEffect(() => {
|
||||
oembedLookup(link).then(data => {
|
||||
oembedLookup(link)
|
||||
.then((data) => {
|
||||
setSource(data.source || undefined);
|
||||
setHeight(data.height);
|
||||
}).catch(console.error);
|
||||
})
|
||||
.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;
|
||||
|
@ -15,62 +15,91 @@ 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, {
|
||||
export default function Timeline({
|
||||
subject,
|
||||
postsOnly = false,
|
||||
method,
|
||||
window: window
|
||||
ignoreModeration = false,
|
||||
window,
|
||||
}: TimelineProps) {
|
||||
const { muted, isMuted } = useModeration();
|
||||
const { main, related, latest, parent, loadMore, showLatest } =
|
||||
useTimelineFeed(subject, {
|
||||
method,
|
||||
window: window,
|
||||
});
|
||||
|
||||
const filterPosts = useCallback((nts: TaggedRawEvent[]) => {
|
||||
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 latestFeed = useMemo(() => {
|
||||
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id))
|
||||
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" />
|
||||
return <ProfilePreview pubkey={e.pubkey} className="card" />;
|
||||
}
|
||||
case EventKind.TextNote: {
|
||||
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
|
||||
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} />
|
||||
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)} />
|
||||
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()}>
|
||||
{latestFeed.length > 1 && (
|
||||
<div className="card latest-notes pointer" onClick={() => showLatest()}>
|
||||
<FontAwesomeIcon icon={faForward} size="xl" />
|
||||
|
||||
Show latest {latestFeed.length - 1} notes
|
||||
</div>)}
|
||||
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" />
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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> zapped</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Zap
|
||||
export default Zap;
|
||||
|
@ -6,9 +6,8 @@ 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 ZapButton = ({ pubkey, svc }: { pubkey?: HexKey; svc?: string }) => {
|
||||
const profile = useUserProfile(pubkey!);
|
||||
const [zap, setZap] = useState(false);
|
||||
const service = svc ?? (profile?.lud16 || profile?.lud06);
|
||||
|
||||
@ -19,9 +18,15 @@ const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => {
|
||||
<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} />
|
||||
<SendSats
|
||||
target={profile?.display_name || profile?.name}
|
||||
svc={service}
|
||||
show={zap}
|
||||
onClose={() => setZap(false)}
|
||||
author={pubkey}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ZapButton;
|
||||
|
@ -6,35 +6,43 @@ 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 }>>,
|
||||
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>
|
||||
}
|
||||
}
|
||||
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 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 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()));
|
||||
let tmpEv = await barierNip07(() =>
|
||||
window.nostr.signEvent(ev.ToObject())
|
||||
);
|
||||
return new NEvent(tmpEv);
|
||||
} else if (privKey) {
|
||||
await ev.Sign(privKey);
|
||||
@ -51,28 +59,29 @@ export default function useEventPublisher() {
|
||||
const hex = bech32ToHex(npub);
|
||||
const idx = ev.Tags.length;
|
||||
ev.Tags.push(new Tag(["p", hex], idx));
|
||||
return `#[${idx}]`
|
||||
return `#[${idx}]`;
|
||||
} catch (error) {
|
||||
return match
|
||||
}
|
||||
return match;
|
||||
}
|
||||
};
|
||||
const replaceNoteId = (match: string) => {
|
||||
try {
|
||||
const hex = bech32ToHex(match);
|
||||
const idx = ev.Tags.length;
|
||||
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
|
||||
return `#[${idx}]`
|
||||
return `#[${idx}]`;
|
||||
} catch (error) {
|
||||
return match
|
||||
}
|
||||
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)
|
||||
};
|
||||
const content = msg
|
||||
.replace(/@npub[a-z0-9]+/g, replaceNpub)
|
||||
.replace(/note[a-z0-9]+/g, replaceNoteId)
|
||||
.replace(HashtagRegex, replaceHashtag);
|
||||
ev.Content = content;
|
||||
@ -111,18 +120,20 @@ export default function useEventPublisher() {
|
||||
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 = ""
|
||||
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)
|
||||
const ps = priv.map((p) => ["p", p]);
|
||||
const plaintext = JSON.stringify(ps);
|
||||
if (hasNip07 && !privKey) {
|
||||
content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
|
||||
content = await barierNip07(() =>
|
||||
window.nostr.nip04.encrypt(pubKey, plaintext)
|
||||
);
|
||||
} else if (privKey) {
|
||||
content = await ev.EncryptData(plaintext, pubKey, privKey)
|
||||
content = await ev.EncryptData(plaintext, pubKey, privKey);
|
||||
}
|
||||
}
|
||||
ev.Content = content;
|
||||
@ -151,15 +162,15 @@ export default function useEventPublisher() {
|
||||
ev.Kind = EventKind.ZapRequest;
|
||||
if (note) {
|
||||
// @ts-ignore
|
||||
ev.Tags.push(new Tag(["e", note]))
|
||||
ev.Tags.push(new Tag(["e", note]));
|
||||
}
|
||||
// @ts-ignore
|
||||
ev.Tags.push(new Tag(["p", author]))
|
||||
ev.Tags.push(new Tag(["p", author]));
|
||||
// @ts-ignore
|
||||
const relayTag = ['relays', ...Object.keys(relays).slice(0, 10)]
|
||||
const relayTag = ["relays", ...Object.keys(relays).slice(0, 10)];
|
||||
// @ts-ignore
|
||||
ev.Tags.push(new Tag(relayTag))
|
||||
processContent(ev, msg || '');
|
||||
ev.Tags.push(new Tag(relayTag));
|
||||
processContent(ev, msg || "");
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
@ -174,7 +185,12 @@ export default function useEventPublisher() {
|
||||
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", thread.Root?.Event ?? thread.ReplyTo?.Event!, "", "root"],
|
||||
ev.Tags.length
|
||||
)
|
||||
);
|
||||
}
|
||||
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
|
||||
|
||||
@ -222,14 +238,17 @@ export default function useEventPublisher() {
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
|
||||
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));
|
||||
pkAdd.forEach((a) => temp.add(a));
|
||||
} else {
|
||||
temp.add(pkAdd);
|
||||
}
|
||||
@ -285,13 +304,21 @@ export default function useEventPublisher() {
|
||||
},
|
||||
decryptDm: async (note: NEvent): Promise<string | undefined> => {
|
||||
if (pubKey) {
|
||||
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === 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;
|
||||
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));
|
||||
return await barierNip07(() =>
|
||||
window.nostr.nip04.decrypt(otherPubKey, note.Content)
|
||||
);
|
||||
} else if (privKey) {
|
||||
await note.DecryptDm(privKey, otherPubKey);
|
||||
return note.Content;
|
||||
@ -311,7 +338,9 @@ export default function useEventPublisher() {
|
||||
|
||||
try {
|
||||
if (hasNip07 && !privKey) {
|
||||
let cx: string = await barierNip07(() => window.nostr.nip04.encrypt(to, content));
|
||||
let cx: string = await barierNip07(() =>
|
||||
window.nostr.nip04.encrypt(to, content)
|
||||
);
|
||||
ev.Content = cx;
|
||||
return await signEvent(ev);
|
||||
} else if (privKey) {
|
||||
@ -322,8 +351,8 @@ export default function useEventPublisher() {
|
||||
console.error("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let isNip07Busy = false;
|
||||
@ -332,7 +361,7 @@ const delay = (t: number) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(resolve, t);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const barierNip07 = async (then: () => Promise<any>) => {
|
||||
while (isNip07Busy) {
|
||||
|
@ -18,7 +18,11 @@ export default function useFollowsFeed(pubkey: HexKey) {
|
||||
}
|
||||
|
||||
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]));
|
||||
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())];
|
||||
}
|
||||
|
@ -1,27 +1,30 @@
|
||||
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 settings = useSelector(
|
||||
(s: RootState) => s.login.preferences.imgProxyConfig
|
||||
);
|
||||
const te = new TextEncoder();
|
||||
|
||||
function urlSafe(s: string) {
|
||||
return s.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
async function signUrl(u: string) {
|
||||
const result = await secp.utils.hmacSha256(
|
||||
secp.utils.hexToBytes(settings!.key),
|
||||
secp.utils.hexToBytes(settings!.salt),
|
||||
te.encode(u));
|
||||
te.encode(u)
|
||||
);
|
||||
return urlSafe(base64.encode(result, 0, result.byteLength));
|
||||
}
|
||||
|
||||
@ -30,10 +33,12 @@ export default function useImgProxy() {
|
||||
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 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}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
@ -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) => {
|
||||
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 });
|
||||
},
|
||||
{ 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) => {
|
||||
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({
|
||||
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")
|
||||
console.debug("Couldn't parse JSON");
|
||||
}
|
||||
}).catch((error) => console.warn(error))
|
||||
})
|
||||
.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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -20,27 +20,32 @@ export default function useMutedFeed(pubkey: HexKey) {
|
||||
}
|
||||
|
||||
export function getNewest(rawNotes: TaggedRawEvent[]) {
|
||||
const notes = [...rawNotes]
|
||||
notes.sort((a, b) => a.created_at - b.created_at)
|
||||
const notes = [...rawNotes];
|
||||
notes.sort((a, b) => a.created_at - b.created_at);
|
||||
if (notes.length > 0) {
|
||||
return notes[0]
|
||||
return notes[0];
|
||||
}
|
||||
}
|
||||
|
||||
export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } {
|
||||
const newest = getNewest(rawNotes)
|
||||
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])
|
||||
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: [] }
|
||||
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);
|
||||
let lists = feed?.notes.filter(
|
||||
(a) => a.kind === EventKind.Lists && a.pubkey === pubkey
|
||||
);
|
||||
return getMutedKeys(lists).keys;
|
||||
}
|
||||
|
@ -17,8 +17,9 @@ export function useUserProfile(pubKey: HexKey): MetadataCache | undefined {
|
||||
return users;
|
||||
}
|
||||
|
||||
|
||||
export function useUserProfiles(pubKeys: Array<HexKey>): Map<HexKey, MetadataCache> | undefined {
|
||||
export function useUserProfiles(
|
||||
pubKeys: Array<HexKey>
|
||||
): Map<HexKey, MetadataCache> | undefined {
|
||||
const users = useKeys(pubKeys);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
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);
|
||||
return useSyncExternalStore<StateSnapshot | undefined>(
|
||||
c?.StatusHook.bind(c) ?? noop,
|
||||
c?.GetState.bind(c) ?? noopState
|
||||
);
|
||||
}
|
@ -6,26 +6,26 @@ 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!
|
||||
end: arg.end!,
|
||||
} as NoteStore;
|
||||
}
|
||||
|
||||
@ -40,28 +40,25 @@ function notesReducer(state: NoteStore, arg: ReducerArg) {
|
||||
if (!Array.isArray(evs)) {
|
||||
evs = [evs];
|
||||
}
|
||||
let existingIds = new Set(state.notes.map(a => a.id));
|
||||
evs = evs.filter(a => !existingIds.has(a.id));
|
||||
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
|
||||
]
|
||||
notes: [...state.notes, ...evs],
|
||||
} as NoteStore;
|
||||
}
|
||||
|
||||
const initStore: NoteStore = {
|
||||
notes: [],
|
||||
end: false
|
||||
end: false,
|
||||
};
|
||||
|
||||
export interface UseSubscriptionState {
|
||||
store: NoteStore,
|
||||
clear: () => void,
|
||||
append: (notes: TaggedRawEvent[]) => void
|
||||
store: NoteStore;
|
||||
clear: () => void;
|
||||
append: (notes: TaggedRawEvent[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -75,7 +72,10 @@ const DebounceMs = 200;
|
||||
* @param {any} opt
|
||||
* @returns
|
||||
*/
|
||||
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions): UseSubscriptionState {
|
||||
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>();
|
||||
@ -93,16 +93,16 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
|
||||
if (subDebounce) {
|
||||
dispatch({
|
||||
type: "END",
|
||||
end: false
|
||||
end: false,
|
||||
});
|
||||
|
||||
if (useCache) {
|
||||
// preload notes from db
|
||||
PreloadNotes(subDebounce.Id)
|
||||
.then(ev => {
|
||||
.then((ev) => {
|
||||
dispatch({
|
||||
type: "EVENT",
|
||||
ev: ev
|
||||
ev: ev,
|
||||
});
|
||||
})
|
||||
.catch(console.warn);
|
||||
@ -110,7 +110,7 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
|
||||
subDebounce.OnEvent = (e) => {
|
||||
dispatch({
|
||||
type: "EVENT",
|
||||
ev: e
|
||||
ev: e,
|
||||
});
|
||||
if (useCache) {
|
||||
db.events.put(e);
|
||||
@ -126,7 +126,7 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
|
||||
}
|
||||
dispatch({
|
||||
type: "END",
|
||||
end: true
|
||||
end: true,
|
||||
});
|
||||
};
|
||||
|
||||
@ -142,15 +142,14 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
|
||||
useEffect(() => {
|
||||
if (subDebounce && useCache) {
|
||||
return debounce(500, () => {
|
||||
TrackNotesInFeed(subDebounce.Id, state.notes)
|
||||
.catch(console.warn);
|
||||
TrackNotesInFeed(subDebounce.Id, state.notes).catch(console.warn);
|
||||
});
|
||||
}
|
||||
}, [state, useCache]);
|
||||
|
||||
useEffect(() => {
|
||||
return debounce(DebounceMs, () => {
|
||||
setDebounceOutput(s => s += 1);
|
||||
setDebounceOutput((s) => (s += 1));
|
||||
});
|
||||
}, [state]);
|
||||
|
||||
@ -163,10 +162,10 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
|
||||
append: (n: TaggedRawEvent[]) => {
|
||||
dispatch({
|
||||
type: "EVENT",
|
||||
ev: n
|
||||
ev: n,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -176,15 +175,23 @@ 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 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);
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
@ -10,18 +10,20 @@ 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 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))) {
|
||||
if (id.some((a) => !orig.has(a))) {
|
||||
let tmp = new Set([...s, ...id]);
|
||||
return Array.from(tmp);
|
||||
} else {
|
||||
return s;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const sub = useMemo(() => {
|
||||
@ -31,7 +33,17 @@ export default function useThreadFeed(id: u256) {
|
||||
|
||||
// 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.Kinds = new Set(
|
||||
pref.enableReactions
|
||||
? [
|
||||
EventKind.Reaction,
|
||||
EventKind.TextNote,
|
||||
EventKind.Deletion,
|
||||
EventKind.Repost,
|
||||
EventKind.ZapReceipt,
|
||||
]
|
||||
: [EventKind.TextNote]
|
||||
);
|
||||
subRelated.ETags = thisSub.Ids;
|
||||
thisSub.AddSubscription(subRelated);
|
||||
|
||||
@ -43,15 +55,18 @@ export default function useThreadFeed(id: u256) {
|
||||
useEffect(() => {
|
||||
if (main.store) {
|
||||
return debounce(200, () => {
|
||||
let mainNotes = main.store.notes.filter(a => a.kind === EventKind.TextNote);
|
||||
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);
|
||||
.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]);
|
||||
|
||||
|
@ -9,24 +9,29 @@ 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) {
|
||||
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 pref = useSelector<RootState, UserPreferences>(
|
||||
(s) => s.login.preferences
|
||||
);
|
||||
|
||||
const createSub = useCallback(() => {
|
||||
if (subject.type !== "global" && subject.items.length === 0) {
|
||||
@ -101,14 +106,21 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
return subLatest;
|
||||
}, [pref, createSub]);
|
||||
|
||||
const latest = useSubscription(subRealtime, { leaveOpen: true, cache: false });
|
||||
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.Kinds = new Set([
|
||||
EventKind.Reaction,
|
||||
EventKind.Deletion,
|
||||
EventKind.ZapReceipt,
|
||||
]);
|
||||
sub.ETags = new Set(trackingEvents);
|
||||
}
|
||||
return sub ?? null;
|
||||
@ -130,26 +142,26 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
|
||||
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))) {
|
||||
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]);
|
||||
.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))) {
|
||||
setTrackingParentEvents((s) => {
|
||||
if (reposts.some((a) => !s.includes(a))) {
|
||||
let temp = new Set([...s, ...reposts]);
|
||||
return Array.from(temp);
|
||||
}
|
||||
return s;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [main.store]);
|
||||
@ -160,18 +172,21 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
latest: latest.store,
|
||||
parent: parent.store,
|
||||
loadMore: () => {
|
||||
console.debug("Timeline load more!")
|
||||
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());
|
||||
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);
|
||||
setUntil((s) => s - window);
|
||||
setSince((s) => s - window);
|
||||
}
|
||||
},
|
||||
showLatest: () => {
|
||||
main.append(latest.store.notes);
|
||||
latest.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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({
|
||||
const newMuted = muted.filter((p) => p !== id);
|
||||
dispatch(
|
||||
setMuted({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newMuted
|
||||
}))
|
||||
setMutedList(newMuted, blocked)
|
||||
keys: newMuted,
|
||||
})
|
||||
);
|
||||
setMutedList(newMuted, blocked);
|
||||
}
|
||||
|
||||
function unblock(id: HexKey) {
|
||||
const newBlocked = blocked.filter(p => p !== id)
|
||||
dispatch(setBlocked({
|
||||
const newBlocked = blocked.filter((p) => p !== id);
|
||||
dispatch(
|
||||
setBlocked({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newBlocked
|
||||
}))
|
||||
setMutedList(muted, newBlocked)
|
||||
keys: newBlocked,
|
||||
})
|
||||
);
|
||||
setMutedList(muted, newBlocked);
|
||||
}
|
||||
|
||||
function mute(id: HexKey) {
|
||||
const newMuted = muted.includes(id) ? muted : muted.concat([id])
|
||||
setMutedList(newMuted, blocked)
|
||||
dispatch(setMuted({
|
||||
const newMuted = muted.includes(id) ? muted : muted.concat([id]);
|
||||
setMutedList(newMuted, blocked);
|
||||
dispatch(
|
||||
setMuted({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newMuted
|
||||
}))
|
||||
keys: newMuted,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function block(id: HexKey) {
|
||||
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id])
|
||||
setMutedList(muted, newBlocked)
|
||||
dispatch(setBlocked({
|
||||
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]);
|
||||
setMutedList(muted, newBlocked);
|
||||
dispatch(
|
||||
setBlocked({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newBlocked
|
||||
}))
|
||||
keys: newBlocked,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function muteAll(ids: HexKey[]) {
|
||||
const newMuted = Array.from(new Set(muted.concat(ids)))
|
||||
setMutedList(newMuted, blocked)
|
||||
dispatch(setMuted({
|
||||
const newMuted = Array.from(new Set(muted.concat(ids)));
|
||||
setMutedList(newMuted, blocked);
|
||||
dispatch(
|
||||
setMuted({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newMuted
|
||||
}))
|
||||
keys: newMuted,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked }
|
||||
return {
|
||||
muted,
|
||||
mute,
|
||||
muteAll,
|
||||
unmute,
|
||||
isMuted,
|
||||
blocked,
|
||||
block,
|
||||
unblock,
|
||||
isBlocked,
|
||||
};
|
||||
}
|
||||
|
@ -3,23 +3,23 @@ import { useEffect } from "react";
|
||||
declare global {
|
||||
interface Window {
|
||||
webln?: {
|
||||
enabled: boolean,
|
||||
enable: () => Promise<void>,
|
||||
sendPayment: (pr: string) => Promise<any>
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
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;
|
||||
|
@ -1,11 +1,23 @@
|
||||
import IconProps from './IconProps'
|
||||
import IconProps from "./IconProps";
|
||||
|
||||
const Attachment = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="21" height="22" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.1525 9.89945L10.1369 18.9151C8.08662 20.9653 4.7625 20.9653 2.71225 18.9151C0.661997 16.8648 0.661998 13.5407 2.71225 11.4904L11.7279 2.47483C13.0947 1.108 15.3108 1.108 16.6776 2.47483C18.0444 3.84167 18.0444 6.05775 16.6776 7.42458L8.01555 16.0866C7.33213 16.7701 6.22409 16.7701 5.54068 16.0866C4.85726 15.4032 4.85726 14.2952 5.54068 13.6118L13.1421 6.01037" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="21"
|
||||
height="22"
|
||||
viewBox="0 0 21 22"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.1525 9.89945L10.1369 18.9151C8.08662 20.9653 4.7625 20.9653 2.71225 18.9151C0.661997 16.8648 0.661998 13.5407 2.71225 11.4904L11.7279 2.47483C13.0947 1.108 15.3108 1.108 16.6776 2.47483C18.0444 3.84167 18.0444 6.05775 16.6776 7.42458L8.01555 16.0866C7.33213 16.7701 6.22409 16.7701 5.54068 16.0866C4.85726 15.4032 4.85726 14.2952 5.54068 13.6118L13.1421 6.01037"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Attachment
|
||||
export default Attachment;
|
||||
|
@ -1,9 +1,21 @@
|
||||
const Bell = () => {
|
||||
return (
|
||||
<svg width="20" height="23" viewBox="0 0 20 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.35419 20.5C8.05933 21.1224 8.98557 21.5 10 21.5C11.0145 21.5 11.9407 21.1224 12.6458 20.5M16 7.5C16 5.9087 15.3679 4.38258 14.2427 3.25736C13.1174 2.13214 11.5913 1.5 10 1.5C8.40872 1.5 6.8826 2.13214 5.75738 3.25736C4.63216 4.38258 4.00002 5.9087 4.00002 7.5C4.00002 10.5902 3.22049 12.706 2.34968 14.1054C1.61515 15.2859 1.24788 15.8761 1.26134 16.0408C1.27626 16.2231 1.31488 16.2926 1.46179 16.4016C1.59448 16.5 2.19261 16.5 3.38887 16.5H16.6112C17.8074 16.5 18.4056 16.5 18.5382 16.4016C18.6852 16.2926 18.7238 16.2231 18.7387 16.0408C18.7522 15.8761 18.3849 15.2859 17.6504 14.1054C16.7795 12.706 16 10.5902 16 7.5Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="20"
|
||||
height="23"
|
||||
viewBox="0 0 20 23"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.35419 20.5C8.05933 21.1224 8.98557 21.5 10 21.5C11.0145 21.5 11.9407 21.1224 12.6458 20.5M16 7.5C16 5.9087 15.3679 4.38258 14.2427 3.25736C13.1174 2.13214 11.5913 1.5 10 1.5C8.40872 1.5 6.8826 2.13214 5.75738 3.25736C4.63216 4.38258 4.00002 5.9087 4.00002 7.5C4.00002 10.5902 3.22049 12.706 2.34968 14.1054C1.61515 15.2859 1.24788 15.8761 1.26134 16.0408C1.27626 16.2231 1.31488 16.2926 1.46179 16.4016C1.59448 16.5 2.19261 16.5 3.38887 16.5H16.6112C17.8074 16.5 18.4056 16.5 18.5382 16.4016C18.6852 16.2926 18.7238 16.2231 18.7387 16.0408C18.7522 15.8761 18.3849 15.2859 17.6504 14.1054C16.7795 12.706 16 10.5902 16 7.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Bell
|
||||
export default Bell;
|
||||
|
@ -1,11 +1,24 @@
|
||||
import IconProps from "./IconProps"
|
||||
import IconProps from "./IconProps";
|
||||
|
||||
const Check = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="18" height="13" viewBox="0 0 18 13" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M17 1L6 12L1 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="18"
|
||||
height="13"
|
||||
viewBox="0 0 18 13"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M17 1L6 12L1 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Check
|
||||
export default Check;
|
||||
|
@ -2,10 +2,23 @@ import IconProps from "./IconProps";
|
||||
|
||||
const Close = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M7.33332 0.666992L0.666656 7.33366M0.666656 0.666992L7.33332 7.33366" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M7.33332 0.666992L0.666656 7.33366M0.666656 0.666992L7.33332 7.33366"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Close
|
||||
export default Close;
|
||||
|
@ -1,11 +1,24 @@
|
||||
import IconProps from './IconProps'
|
||||
import IconProps from "./IconProps";
|
||||
|
||||
const Copy = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M5.33331 5.33398V3.46732C5.33331 2.72058 5.33331 2.34721 5.47864 2.062C5.60647 1.81111 5.81044 1.60714 6.06133 1.47931C6.34654 1.33398 6.71991 1.33398 7.46665 1.33398H12.5333C13.28 1.33398 13.6534 1.33398 13.9386 1.47931C14.1895 1.60714 14.3935 1.81111 14.5213 2.062C14.6666 2.34721 14.6666 2.72058 14.6666 3.46732V8.53398C14.6666 9.28072 14.6666 9.65409 14.5213 9.9393C14.3935 10.1902 14.1895 10.3942 13.9386 10.522C13.6534 10.6673 13.28 10.6673 12.5333 10.6673H10.6666M3.46665 14.6673H8.53331C9.28005 14.6673 9.65342 14.6673 9.93863 14.522C10.1895 14.3942 10.3935 14.1902 10.5213 13.9393C10.6666 13.6541 10.6666 13.2807 10.6666 12.534V7.46732C10.6666 6.72058 10.6666 6.34721 10.5213 6.062C10.3935 5.81111 10.1895 5.60714 9.93863 5.47931C9.65342 5.33398 9.28005 5.33398 8.53331 5.33398H3.46665C2.71991 5.33398 2.34654 5.33398 2.06133 5.47931C1.81044 5.60714 1.60647 5.81111 1.47864 6.062C1.33331 6.34721 1.33331 6.72058 1.33331 7.46732V12.534C1.33331 13.2807 1.33331 13.6541 1.47864 13.9393C1.60647 14.1902 1.81044 14.3942 2.06133 14.522C2.34654 14.6673 2.71991 14.6673 3.46665 14.6673Z" stroke="currentColor" strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M5.33331 5.33398V3.46732C5.33331 2.72058 5.33331 2.34721 5.47864 2.062C5.60647 1.81111 5.81044 1.60714 6.06133 1.47931C6.34654 1.33398 6.71991 1.33398 7.46665 1.33398H12.5333C13.28 1.33398 13.6534 1.33398 13.9386 1.47931C14.1895 1.60714 14.3935 1.81111 14.5213 2.062C14.6666 2.34721 14.6666 2.72058 14.6666 3.46732V8.53398C14.6666 9.28072 14.6666 9.65409 14.5213 9.9393C14.3935 10.1902 14.1895 10.3942 13.9386 10.522C13.6534 10.6673 13.28 10.6673 12.5333 10.6673H10.6666M3.46665 14.6673H8.53331C9.28005 14.6673 9.65342 14.6673 9.93863 14.522C10.1895 14.3942 10.3935 14.1902 10.5213 13.9393C10.6666 13.6541 10.6666 13.2807 10.6666 12.534V7.46732C10.6666 6.72058 10.6666 6.34721 10.5213 6.062C10.3935 5.81111 10.1895 5.60714 9.93863 5.47931C9.65342 5.33398 9.28005 5.33398 8.53331 5.33398H3.46665C2.71991 5.33398 2.34654 5.33398 2.06133 5.47931C1.81044 5.60714 1.60647 5.81111 1.47864 6.062C1.33331 6.34721 1.33331 6.72058 1.33331 7.46732V12.534C1.33331 13.2807 1.33331 13.6541 1.47864 13.9393C1.60647 14.1902 1.81044 14.3942 2.06133 14.522C2.34654 14.6673 2.71991 14.6673 3.46665 14.6673Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Copy
|
||||
export default Copy;
|
||||
|
@ -1,9 +1,21 @@
|
||||
const Dislike = () => {
|
||||
return (
|
||||
<svg width="19" height="20" viewBox="0 0 19 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.1667 1.66667V10.8333M17.3333 8.16667V4.33334C17.3333 3.39992 17.3333 2.93321 17.1517 2.57669C16.9919 2.26308 16.7369 2.00812 16.4233 1.84833C16.0668 1.66667 15.6001 1.66667 14.6667 1.66667H5.76501C4.54711 1.66667 3.93816 1.66667 3.44632 1.88953C3.01284 2.08595 2.64442 2.40202 2.38437 2.8006C2.08931 3.25283 1.99672 3.8547 1.81153 5.05844L1.37563 7.89178C1.13137 9.47943 1.00925 10.2733 1.24484 10.8909C1.45162 11.4331 1.84054 11.8864 2.34494 12.1732C2.91961 12.5 3.72278 12.5 5.32912 12.5H6C6.46671 12.5 6.70007 12.5 6.87833 12.5908C7.03513 12.6707 7.16261 12.7982 7.24251 12.955C7.33334 13.1333 7.33334 13.3666 7.33334 13.8333V16.2785C7.33334 17.4133 8.25333 18.3333 9.3882 18.3333C9.65889 18.3333 9.90419 18.1739 10.0141 17.9266L12.8148 11.6252C12.9421 11.3385 13.0058 11.1952 13.1065 11.0902C13.1955 10.9973 13.3048 10.9263 13.4258 10.8827C13.5627 10.8333 13.7195 10.8333 14.0332 10.8333H14.6667C15.6001 10.8333 16.0668 10.8333 16.4233 10.6517C16.7369 10.4919 16.9919 10.2369 17.1517 9.92332C17.3333 9.5668 17.3333 9.10009 17.3333 8.16667Z" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="19"
|
||||
height="20"
|
||||
viewBox="0 0 19 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.1667 1.66667V10.8333M17.3333 8.16667V4.33334C17.3333 3.39992 17.3333 2.93321 17.1517 2.57669C16.9919 2.26308 16.7369 2.00812 16.4233 1.84833C16.0668 1.66667 15.6001 1.66667 14.6667 1.66667H5.76501C4.54711 1.66667 3.93816 1.66667 3.44632 1.88953C3.01284 2.08595 2.64442 2.40202 2.38437 2.8006C2.08931 3.25283 1.99672 3.8547 1.81153 5.05844L1.37563 7.89178C1.13137 9.47943 1.00925 10.2733 1.24484 10.8909C1.45162 11.4331 1.84054 11.8864 2.34494 12.1732C2.91961 12.5 3.72278 12.5 5.32912 12.5H6C6.46671 12.5 6.70007 12.5 6.87833 12.5908C7.03513 12.6707 7.16261 12.7982 7.24251 12.955C7.33334 13.1333 7.33334 13.3666 7.33334 13.8333V16.2785C7.33334 17.4133 8.25333 18.3333 9.3882 18.3333C9.65889 18.3333 9.90419 18.1739 10.0141 17.9266L12.8148 11.6252C12.9421 11.3385 13.0058 11.1952 13.1065 11.0902C13.1955 10.9973 13.3048 10.9263 13.4258 10.8827C13.5627 10.8333 13.7195 10.8333 14.0332 10.8333H14.6667C15.6001 10.8333 16.0668 10.8333 16.4233 10.6517C16.7369 10.4919 16.9919 10.2369 17.1517 9.92332C17.3333 9.5668 17.3333 9.10009 17.3333 8.16667Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Dislike
|
||||
export default Dislike;
|
||||
|
@ -1,11 +1,35 @@
|
||||
const Dots = () => {
|
||||
return (
|
||||
<svg width="4" height="16" viewBox="0 0 4 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.99996 8.86865C2.4602 8.86865 2.83329 8.49556 2.83329 8.03532C2.83329 7.57508 2.4602 7.20199 1.99996 7.20199C1.53972 7.20199 1.16663 7.57508 1.16663 8.03532C1.16663 8.49556 1.53972 8.86865 1.99996 8.86865Z" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M1.99996 3.03532C2.4602 3.03532 2.83329 2.66222 2.83329 2.20199C2.83329 1.74175 2.4602 1.36865 1.99996 1.36865C1.53972 1.36865 1.16663 1.74175 1.16663 2.20199C1.16663 2.66222 1.53972 3.03532 1.99996 3.03532Z" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M1.99996 14.702C2.4602 14.702 2.83329 14.3289 2.83329 13.8687C2.83329 13.4084 2.4602 13.0353 1.99996 13.0353C1.53972 13.0353 1.16663 13.4084 1.16663 13.8687C1.16663 14.3289 1.53972 14.702 1.99996 14.702Z" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="4"
|
||||
height="16"
|
||||
viewBox="0 0 4 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1.99996 8.86865C2.4602 8.86865 2.83329 8.49556 2.83329 8.03532C2.83329 7.57508 2.4602 7.20199 1.99996 7.20199C1.53972 7.20199 1.16663 7.57508 1.16663 8.03532C1.16663 8.49556 1.53972 8.86865 1.99996 8.86865Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.99996 3.03532C2.4602 3.03532 2.83329 2.66222 2.83329 2.20199C2.83329 1.74175 2.4602 1.36865 1.99996 1.36865C1.53972 1.36865 1.16663 1.74175 1.16663 2.20199C1.16663 2.66222 1.53972 3.03532 1.99996 3.03532Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.99996 14.702C2.4602 14.702 2.83329 14.3289 2.83329 13.8687C2.83329 13.4084 2.4602 13.0353 1.99996 13.0353C1.53972 13.0353 1.16663 13.4084 1.16663 13.8687C1.16663 14.3289 1.53972 14.702 1.99996 14.702Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Dots
|
||||
export default Dots;
|
||||
|
@ -1,11 +1,24 @@
|
||||
import type IconProps from './IconProps'
|
||||
import type IconProps from "./IconProps";
|
||||
|
||||
const Envelope = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="22" height="19" viewBox="0 0 22 19" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M1 4.5L9.16492 10.2154C9.82609 10.6783 10.1567 10.9097 10.5163 10.9993C10.8339 11.0785 11.1661 11.0785 11.4837 10.9993C11.8433 10.9097 12.1739 10.6783 12.8351 10.2154L21 4.5M5.8 17.5H16.2C17.8802 17.5 18.7202 17.5 19.362 17.173C19.9265 16.8854 20.3854 16.4265 20.673 15.862C21 15.2202 21 14.3802 21 12.7V6.3C21 4.61984 21 3.77976 20.673 3.13803C20.3854 2.57354 19.9265 2.1146 19.362 1.82698C18.7202 1.5 17.8802 1.5 16.2 1.5H5.8C4.11984 1.5 3.27976 1.5 2.63803 1.82698C2.07354 2.1146 1.6146 2.57354 1.32698 3.13803C1 3.77976 1 4.61984 1 6.3V12.7C1 14.3802 1 15.2202 1.32698 15.862C1.6146 16.4265 2.07354 16.8854 2.63803 17.173C3.27976 17.5 4.11984 17.5 5.8 17.5Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="22"
|
||||
height="19"
|
||||
viewBox="0 0 22 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M1 4.5L9.16492 10.2154C9.82609 10.6783 10.1567 10.9097 10.5163 10.9993C10.8339 11.0785 11.1661 11.0785 11.4837 10.9993C11.8433 10.9097 12.1739 10.6783 12.8351 10.2154L21 4.5M5.8 17.5H16.2C17.8802 17.5 18.7202 17.5 19.362 17.173C19.9265 16.8854 20.3854 16.4265 20.673 15.862C21 15.2202 21 14.3802 21 12.7V6.3C21 4.61984 21 3.77976 20.673 3.13803C20.3854 2.57354 19.9265 2.1146 19.362 1.82698C18.7202 1.5 17.8802 1.5 16.2 1.5H5.8C4.11984 1.5 3.27976 1.5 2.63803 1.82698C2.07354 2.1146 1.6146 2.57354 1.32698 3.13803C1 3.77976 1 4.61984 1 6.3V12.7C1 14.3802 1 15.2202 1.32698 15.862C1.6146 16.4265 2.07354 16.8854 2.63803 17.173C3.27976 17.5 4.11984 17.5 5.8 17.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Envelope
|
||||
export default Envelope;
|
||||
|
@ -1,12 +1,31 @@
|
||||
import IconProps from './IconProps'
|
||||
import IconProps from "./IconProps";
|
||||
|
||||
const Gear = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="20" height="22" viewBox="0 0 20 22" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M7.39504 18.3711L7.97949 19.6856C8.15323 20.0768 8.43676 20.4093 8.79571 20.6426C9.15466 20.8759 9.5736 21.0001 10.0017 21C10.4298 21.0001 10.8488 20.8759 11.2077 20.6426C11.5667 20.4093 11.8502 20.0768 12.0239 19.6856L12.6084 18.3711C12.8164 17.9047 13.1664 17.5159 13.6084 17.26C14.0532 17.0034 14.5677 16.8941 15.0784 16.9478L16.5084 17.1C16.934 17.145 17.3636 17.0656 17.7451 16.8713C18.1265 16.6771 18.4434 16.3763 18.6573 16.0056C18.8714 15.635 18.9735 15.2103 18.951 14.7829C18.9285 14.3555 18.7825 13.9438 18.5306 13.5978L17.6839 12.4344C17.3825 12.0171 17.2214 11.5148 17.2239 11C17.2238 10.4866 17.3864 9.98635 17.6884 9.57111L18.535 8.40778C18.7869 8.06175 18.933 7.65007 18.9554 7.22267C18.9779 6.79528 18.8759 6.37054 18.6617 6C18.4478 5.62923 18.1309 5.32849 17.7495 5.13423C17.3681 4.93997 16.9385 4.86053 16.5128 4.90556L15.0828 5.05778C14.5722 5.11141 14.0576 5.00212 13.6128 4.74556C13.1699 4.48825 12.8199 4.09736 12.6128 3.62889L12.0239 2.31444C11.8502 1.92317 11.5667 1.59072 11.2077 1.3574C10.8488 1.12408 10.4298 0.99993 10.0017 1C9.5736 0.99993 9.15466 1.12408 8.79571 1.3574C8.43676 1.59072 8.15323 1.92317 7.97949 2.31444L7.39504 3.62889C7.18797 4.09736 6.83792 4.48825 6.39504 4.74556C5.95026 5.00212 5.43571 5.11141 4.92504 5.05778L3.4906 4.90556C3.06493 4.86053 2.63534 4.93997 2.25391 5.13423C1.87249 5.32849 1.55561 5.62923 1.34171 6C1.12753 6.37054 1.02549 6.79528 1.04798 7.22267C1.07046 7.65007 1.2165 8.06175 1.46838 8.40778L2.31504 9.57111C2.61698 9.98635 2.77958 10.4866 2.77949 11C2.77958 11.5134 2.61698 12.0137 2.31504 12.4289L1.46838 13.5922C1.2165 13.9382 1.07046 14.3499 1.04798 14.7773C1.02549 15.2047 1.12753 15.6295 1.34171 16C1.55582 16.3706 1.87274 16.6712 2.25411 16.8654C2.63548 17.0596 3.06496 17.1392 3.4906 17.0944L4.9206 16.9422C5.43127 16.8886 5.94581 16.9979 6.3906 17.2544C6.83513 17.511 7.18681 17.902 7.39504 18.3711Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9.99992 14C11.6568 14 12.9999 12.6569 12.9999 11C12.9999 9.34315 11.6568 8 9.99992 8C8.34307 8 6.99992 9.34315 6.99992 11C6.99992 12.6569 8.34307 14 9.99992 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
width="20"
|
||||
height="22"
|
||||
viewBox="0 0 20 22"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M7.39504 18.3711L7.97949 19.6856C8.15323 20.0768 8.43676 20.4093 8.79571 20.6426C9.15466 20.8759 9.5736 21.0001 10.0017 21C10.4298 21.0001 10.8488 20.8759 11.2077 20.6426C11.5667 20.4093 11.8502 20.0768 12.0239 19.6856L12.6084 18.3711C12.8164 17.9047 13.1664 17.5159 13.6084 17.26C14.0532 17.0034 14.5677 16.8941 15.0784 16.9478L16.5084 17.1C16.934 17.145 17.3636 17.0656 17.7451 16.8713C18.1265 16.6771 18.4434 16.3763 18.6573 16.0056C18.8714 15.635 18.9735 15.2103 18.951 14.7829C18.9285 14.3555 18.7825 13.9438 18.5306 13.5978L17.6839 12.4344C17.3825 12.0171 17.2214 11.5148 17.2239 11C17.2238 10.4866 17.3864 9.98635 17.6884 9.57111L18.535 8.40778C18.7869 8.06175 18.933 7.65007 18.9554 7.22267C18.9779 6.79528 18.8759 6.37054 18.6617 6C18.4478 5.62923 18.1309 5.32849 17.7495 5.13423C17.3681 4.93997 16.9385 4.86053 16.5128 4.90556L15.0828 5.05778C14.5722 5.11141 14.0576 5.00212 13.6128 4.74556C13.1699 4.48825 12.8199 4.09736 12.6128 3.62889L12.0239 2.31444C11.8502 1.92317 11.5667 1.59072 11.2077 1.3574C10.8488 1.12408 10.4298 0.99993 10.0017 1C9.5736 0.99993 9.15466 1.12408 8.79571 1.3574C8.43676 1.59072 8.15323 1.92317 7.97949 2.31444L7.39504 3.62889C7.18797 4.09736 6.83792 4.48825 6.39504 4.74556C5.95026 5.00212 5.43571 5.11141 4.92504 5.05778L3.4906 4.90556C3.06493 4.86053 2.63534 4.93997 2.25391 5.13423C1.87249 5.32849 1.55561 5.62923 1.34171 6C1.12753 6.37054 1.02549 6.79528 1.04798 7.22267C1.07046 7.65007 1.2165 8.06175 1.46838 8.40778L2.31504 9.57111C2.61698 9.98635 2.77958 10.4866 2.77949 11C2.77958 11.5134 2.61698 12.0137 2.31504 12.4289L1.46838 13.5922C1.2165 13.9382 1.07046 14.3499 1.04798 14.7773C1.02549 15.2047 1.12753 15.6295 1.34171 16C1.55582 16.3706 1.87274 16.6712 2.25411 16.8654C2.63548 17.0596 3.06496 17.1392 3.4906 17.0944L4.9206 16.9422C5.43127 16.8886 5.94581 16.9979 6.3906 17.2544C6.83513 17.511 7.18681 17.902 7.39504 18.3711Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.99992 14C11.6568 14 12.9999 12.6569 12.9999 11C12.9999 9.34315 11.6568 8 9.99992 8C8.34307 8 6.99992 9.34315 6.99992 11C6.99992 12.6569 8.34307 14 9.99992 14Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Gear
|
||||
export default Gear;
|
||||
|
@ -1,9 +1,23 @@
|
||||
const Heart = () => {
|
||||
return (
|
||||
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M9.99425 3.315C8.32813 1.36716 5.54975 0.843192 3.4622 2.62683C1.37466 4.41048 1.08077 7.39264 2.72012 9.50216C4.08314 11.2561 8.2081 14.9552 9.56004 16.1525C9.7113 16.2865 9.78692 16.3534 9.87514 16.3798C9.95213 16.4027 10.0364 16.4027 10.1134 16.3798C10.2016 16.3534 10.2772 16.2865 10.4285 16.1525C11.7804 14.9552 15.9054 11.2561 17.2684 9.50216C18.9077 7.39264 18.6497 4.39171 16.5263 2.62683C14.4029 0.861954 11.6604 1.36716 9.99425 3.315Z" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="20"
|
||||
height="18"
|
||||
viewBox="0 0 20 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.99425 3.315C8.32813 1.36716 5.54975 0.843192 3.4622 2.62683C1.37466 4.41048 1.08077 7.39264 2.72012 9.50216C4.08314 11.2561 8.2081 14.9552 9.56004 16.1525C9.7113 16.2865 9.78692 16.3534 9.87514 16.3798C9.95213 16.4027 10.0364 16.4027 10.1134 16.3798C10.2016 16.3534 10.2772 16.2865 10.4285 16.1525C11.7804 14.9552 15.9054 11.2561 17.2684 9.50216C18.9077 7.39264 18.6497 4.39171 16.5263 2.62683C14.4029 0.861954 11.6604 1.36716 9.99425 3.315Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Heart
|
||||
export default Heart;
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default interface IconProps {
|
||||
className?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
@ -1,9 +1,21 @@
|
||||
const Link = () => {
|
||||
return (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.99996 12C9.42941 12.5742 9.97731 13.0492 10.6065 13.393C11.2357 13.7367 11.9315 13.9411 12.6466 13.9924C13.3617 14.0436 14.0795 13.9404 14.7513 13.6898C15.4231 13.4392 16.0331 13.0471 16.54 12.54L19.54 9.54003C20.4507 8.59702 20.9547 7.334 20.9433 6.02302C20.9319 4.71204 20.4061 3.45797 19.479 2.53093C18.552 1.60389 17.2979 1.07805 15.987 1.06666C14.676 1.05526 13.413 1.55924 12.47 2.47003L10.75 4.18003M13 10C12.5705 9.4259 12.0226 8.95084 11.3934 8.60709C10.7642 8.26333 10.0684 8.05891 9.3533 8.00769C8.63816 7.95648 7.92037 8.05966 7.24861 8.31025C6.57685 8.56083 5.96684 8.95296 5.45996 9.46003L2.45996 12.46C1.54917 13.403 1.04519 14.666 1.05659 15.977C1.06798 17.288 1.59382 18.5421 2.52086 19.4691C3.4479 20.3962 4.70197 20.922 6.01295 20.9334C7.32393 20.9448 8.58694 20.4408 9.52995 19.53L11.24 17.82" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.99996 12C9.42941 12.5742 9.97731 13.0492 10.6065 13.393C11.2357 13.7367 11.9315 13.9411 12.6466 13.9924C13.3617 14.0436 14.0795 13.9404 14.7513 13.6898C15.4231 13.4392 16.0331 13.0471 16.54 12.54L19.54 9.54003C20.4507 8.59702 20.9547 7.334 20.9433 6.02302C20.9319 4.71204 20.4061 3.45797 19.479 2.53093C18.552 1.60389 17.2979 1.07805 15.987 1.06666C14.676 1.05526 13.413 1.55924 12.47 2.47003L10.75 4.18003M13 10C12.5705 9.4259 12.0226 8.95084 11.3934 8.60709C10.7642 8.26333 10.0684 8.05891 9.3533 8.00769C8.63816 7.95648 7.92037 8.05966 7.24861 8.31025C6.57685 8.56083 5.96684 8.95296 5.45996 9.46003L2.45996 12.46C1.54917 13.403 1.04519 14.666 1.05659 15.977C1.06798 17.288 1.59382 18.5421 2.52086 19.4691C3.4479 20.3962 4.70197 20.922 6.01295 20.9334C7.32393 20.9448 8.58694 20.4408 9.52995 19.53L11.24 17.82"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Link
|
||||
export default Link;
|
||||
|
@ -1,11 +1,23 @@
|
||||
import IconProps from './IconProps'
|
||||
import IconProps from "./IconProps";
|
||||
|
||||
const Logout = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="22" height="20" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 6L21 10M21 10L17 14M21 10H8M14 2.20404C12.7252 1.43827 11.2452 1 9.66667 1C4.8802 1 1 5.02944 1 10C1 14.9706 4.8802 19 9.66667 19C11.2452 19 12.7252 18.5617 14 17.796" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="22"
|
||||
height="20"
|
||||
viewBox="0 0 22 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17 6L21 10M21 10L17 14M21 10H8M14 2.20404C12.7252 1.43827 11.2452 1 9.66667 1C4.8802 1 1 5.02944 1 10C1 14.9706 4.8802 19 9.66667 19C11.2452 19 12.7252 18.5617 14 17.796"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Logout
|
||||
export default Logout;
|
||||
|
@ -1,9 +1,21 @@
|
||||
const Plus = () => {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1V15M1 8H15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1V15M1 8H15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Plus
|
||||
export default Plus;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user